Compare commits

...
Author SHA1 Message Date
enricobuehler 6774c4e7a2 feat(wol): support WoWLAN so Wi-Fi hosts wake like wired ones
ci / bun-nix (pull_request) Successful in 1m33s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m5s
ci / web (pull_request) Successful in 2m14s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m57s
ci / docs-site (pull_request) Successful in 3m4s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m41s
android / android (pull_request) Successful in 7m3s
ci / rust (pull_request) Successful in 10m23s
The host's arming check asked `ethtool` about every NIC, which is the wrong
question for Wi-Fi: the magic-packet trigger lives in nl80211's WoWLAN state,
and most wireless drivers print `Wake-on: d` whether or not it is armed. An
armed Wi-Fi host was therefore told it was NOT armed, and handed an
`ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy
(`/sys/class/net/<i>/phy80211`) is now asked `iw phy <phy> wowlan show`
instead, and the warning carries WoWLAN-correct guidance — `iw ... wowlan
enable magic-packet`, plus the NetworkManager
`802-11-wireless.wake-on-wlan magic` that survives a reconnect. Two fallbacks
for when `iw` can't answer (missing binary, driver without the command, or
privilege the user-level host service lacks): a POSITIVE ethtool reading
counts (brcmfmac & co do report there), a negative one never does, and sysfs
`device/power/wakeup` reading `disabled` is conclusive in the negative.

The client sender now emits from a socket bound to EACH non-loopback
interface's own address rather than leaving the path to the routing table. A
station in WoWLAN sleep stays associated and its AP buffers broadcast frames
for it until the next DTIM beacon — but only if the datagram reaches the
wireless segment at all, and with a VPN or mesh interface holding the default
route `255.255.255.255` never did. A failed bind falls back to the routed
socket, so no segment is lost.

Tests: `iw`/`ethtool` output parsing split from the commands so both are unit-
tested on any platform, and a new end-to-end test asserts a real listener
receives the 102 magic-packet bytes.

Verified on Linux (Ubuntu 26.04, 12 interfaces): `cargo fmt --all --check`,
`cargo clippy -p punktfunk-core -p punktfunk-host --all-targets --locked
-- -D warnings`, and both wol test sets green. NOT yet exercised against real
Wi-Fi hardware — no Wi-Fi Linux box was reachable.
2026-08-13 00:49:21 +02:00
enricobuehler f06b84be63 Merge pull request 'plugin-kit 0.4.1 — publish the icon field, without which no plugin can name its mark' (#186) from worktree-launcher-icon-kit-release into main
ci / web (push) Successful in 1m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
ci / bun-nix (push) Successful in 3m7s
ci / docs-site (push) Successful in 4m32s
ci / rust-arm64 (push) Successful in 5m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
docker / builders-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 33s
plugin-kit-publish / publish (push) Successful in 1m2s
ci / rust (push) Successful in 9m41s
nix / flake (push) Failing after 12m47s
2026-08-12 22:30:11 +00:00
enricobuehler d6dbb391d6 chore(plugin-kit): 0.4.1 — publish the icon field, without which no plugin can name its mark
ci / bun-nix (pull_request) Successful in 40s
ci / docs-site (pull_request) Successful in 1m18s
ci / web (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 2m25s
ci / rust (pull_request) Successful in 11m40s
nix / flake (pull_request) Successful in 14m54s
`ProviderEntry.icon` landed in f62a48d4 along with the token's whole
supporting cast: the host-side shape guard, the seven masters, six client
renderers, the SDK and the OpenAPI. What it did not get was a version
bump, and the kit had cut 0.4.0 the day before.

So the registry's 0.4.0 is the tarball WITHOUT the field, and it is the
newest thing any plugin can resolve. A plugin that emits `icon` on a
launcher entry therefore fails `tsc --noEmit` — "Object literal may only
specify known properties, and 'icon' does not exist" — which is a CI gate
in every plugin repo. That is why the three plugins that were supposed to
carry the token never shipped it: the edits could not be committed
against a kit that had no field to fill.

Nothing but the version moves here. The only plugin-kit change since
0.4.0 was published is f62a48d4 itself, so 0.4.1 is exactly that commit's
kit surface — one optional string on an existing struct, additive, and
inert for a plugin that never sets it.
2026-08-13 00:29:26 +02:00
enricobuehler 907080f92b Merge pull request 'The Windows host could tank a local game's 1% lows — mint retries broadcast PnP device changes at the whole box, and session tuning never reverted' (#185) from worktree-audio-stutter-fixes into main
apple / swift (push) Successful in 1m43s
ci / rust-arm64 (push) Successful in 2m10s
ci / web (push) Successful in 2m48s
ci / docs-site (push) Successful in 1m18s
ci / bun-nix (push) Successful in 2m6s
ci / rust (push) Successful in 6m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
android / android (push) Successful in 6m42s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
apple / screenshots (push) Successful in 5m58s
deb / build-publish-host (push) Successful in 6m34s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-client-arm64 (push) Failing after 5m41s
docker / builders-arm64cross (push) Successful in 1m54s
windows-host / package (push) Successful in 13m17s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 22s
arch / build-publish (push) Successful in 11m8s
deb / build-publish (push) Successful in 12m23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m15s
Reviewed-on: #185
2026-08-12 22:03:47 +00:00
enricobuehler 9425c6d40a docs(changelog): audio no longer costs local-game frame time on Windows hosts
ci / bun-nix (pull_request) Successful in 24s
ci / rust (pull_request) Failing after 57s
apple / swift (pull_request) Successful in 1m54s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 4m46s
android / android (pull_request) Successful in 11m12s
ci / web (pull_request) Failing after 13m50s
2026-08-12 22:38:35 +02:00
enricobuehler ab8c7ec37c fix(audio/windows): stop the mint retry path from broadcasting PnP device changes at the whole box, and revert session tuning when streaming ends
Field report (2026-08-12): Punktfunk's audio devices tank Helldivers 2 to
1% lows of 2-5 FPS; uninstalling restores performance. Two host-side
mechanisms can plausibly do that, both fixed here.

The mint retry storm: minted::ensure_blocking() ran a FULL provisioning
pass on every mic-pump open with no cooldown, no in-flight guard, and no
give-up - and ensure_role() reached UpdateDriverForPlugAndPlayDevicesW
even when the devnode already existed. On a box where minting never
latches, the pump's reopen backoff (capped 60 s) turned that into a PnP
driver rebind + system-wide device-change broadcast roughly once a
minute, forever - and games rebuild their audio graph on each broadcast.
Now:

* ensure_role() gets a steady-state fast path: a marker devnode whose
  endpoints are all live resolves without touching PnP or the
  default-device policy.
* ensure_blocking() waits on an in-flight pass instead of racing a
  second SetupAPI sweep against it (the dead-mic-air deploy race),
  honours RETRY_COOLDOWN after a failed pass (first-ever resolve still
  blocks, per the cold-boot mint contract), and
* five unlatched passes stop minting for the host lifetime (a service
  restart re-arms) - counted across the worker and the blocking path.

The never-reverted session tuning: pf-frame's tune_process_once() put
the whole host at HIGH_PRIORITY_CLASS with timeBeginPeriod(1) and DWM
MMCSS on the first hot stream thread and documented '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. The process-wide tuning is now refcounted across the
hot threads via a TLS guard: the first hot thread applies it, the last
one's exit reverts it (timeEndPeriod, DwmEnableMMCSS(0), NORMAL class) -
the same thread-exit lifetime the MMCSS and execution-state effects
already ride. Every on_hot_thread() call site is a session-scoped
thread (capture/encode, packetizer, send, NVENC retrieve), so the
revert lands at session teardown.
2026-08-12 22:29:54 +02:00
enricobuehler 64e2af17c5 Merge pull request 'fix(decky): duplicate shortcut minted every boot + toast noise cut' (#184) from worktree-decky-shortcut-dup into main
decky / build-publish (push) Successful in 1m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m34s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 20s
docker / builders-arm64cross (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m36s
ci / rust-arm64 (push) Successful in 1m32s
ci / web (push) Successful in 3m45s
ci / docs-site (push) Successful in 4m1s
ci / bun-nix (push) Successful in 29s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m8s
docker / deploy-docs (push) Successful in 32s
ci / rust (push) Canceled after 4m8s
2026-08-12 20:25:57 +00:00
enricobuehler 72189b29ec Merge branch 'main' into worktree-decky-shortcut-dup
ci / rust (pull_request) Successful in 6m55s
ci / web (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 34s
ci / rust-arm64 (pull_request) Successful in 1m30s
2026-08-12 22:24:34 +02:00
enricobuehler 339a1d70f9 fix(decky): stop toasting on every launch and every failed panel refresh
Field complaint: the plugin toasts too much. Inventory of all 14 toast
sites says almost all are rare, explicit-tap feedback (pairing, update
buttons, recovery actions) — but two were routine-volume offenders:

  * startStream toasted "Starting stream — <host>" on EVERY successful
    launch, i.e. the overwhelming majority of all toasts the plugin ever
    shows. It repeats the button the user just pressed, and lands ON TOP
    of the starting stream after the QAM closes. Gone; launch FAILURES
    still toast (the QAM may already be closed, so inline state would go
    unseen).
  * useHosts.refresh() toasted "Couldn't list hosts" from its catch —
    and the panel remounts (and refreshes) on every QAM open, so a broken
    backend nagged on each open. It's now a third inline `problem` row
    ("Couldn't scan for hosts"), sitting next to the Refresh button that
    retries it, like the client-unavailable/client-outdated states
    already did.

The update-flow, pairing, trust and recovery toasts stay: each is a rare,
single, information-carrying response to an explicit tap (or, for the
request-access hint, the only warning that the connect is about to park).

Verified: tsc --noEmit and the rollup bundle pass.
2026-08-12 22:05:16 +02:00
enricobuehler 79dba7f95a fix(decky): a boot race minted a new library shortcut on every plugin load
Field report: each Steam start added another visible "Punktfunk" entry
(spotted in the desktop client, where the pile is plain to see).

Mechanism: db063792 made shortcutStillExists() actually answer for the
first time — and its callers treat a null overview as "the user deleted
the shortcut" and AddShortcut a replacement. But 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 on every boot:
mint a duplicate, remember the new id, orphan yesterday's. One new entry
per load, forever.

The deleted verdict now has to be earned, and creation is a last resort:

  * shortcutStillExists() only believes "absent" once the store is
    demonstrably hydrated: wait out App.WaitForServicesInitialized (raced
    against the poll budget so a wedged signal can't hang the guard),
    poll until allApps is non-empty, then one grace recheck — overview
    registration can trail the bulk hydration. Unverifiable within budget
    answers true: a false "alive" merely no-ops until the next ask, a
    false "dead" duplicates forever.
  * On a genuinely lost id, both ensure paths first ADOPT an existing
    same-named shortcut (excluding the other role's) instead of minting
    an N+1th — which also heals installs the old builds already littered.
  * Both ensures are single-flight: mount's fire-and-forget can now be
    mid-wait when a QAM press arrives, and two ensures racing past the
    liveness check would each AddShortcut.
  * "Recreate library shortcut" additionally sweeps surplus "Punktfunk"
    shortcuts (RemoveShortcut) and toasts the count — cleanup for piles
    already minted. Deliberately button-only, never mount: automatic
    library deletion at boot is a bigger hazard than the mess.

Verified: tsc --noEmit and the rollup bundle both pass; the launch paths
(launchStream / launchGamepadUi) hit the fast path unchanged — a live
overview answers the first query and nothing waits.
2026-08-12 21:54:06 +02:00
enricobuehler d7430fe2bd Merge pull request 'fix(ci): gate C counts comments, and a comment named the env mutators verbatim' (#183) from fix/gate-c-comment-token into main
ci / rust-arm64 (push) Successful in 2m24s
ci / web (push) Successful in 1m46s
ci / bun-nix (push) Successful in 46s
ci / docs-site (push) Successful in 1m50s
deb / build-publish (push) Successful in 4m25s
deb / build-publish-client-arm64 (push) Successful in 6m32s
flatpak / build-publish (push) Successful in 6m59s
deb / build-publish-host (push) Successful in 12m2s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m42s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m23s
arch / build-publish (push) Successful in 13m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m34s
ci / rust (push) Successful in 13m31s
2026-08-12 16:57:38 +00:00
enricobuehler 6f81ec24ba fix(ci): gate C counts comments, and a comment named the env mutators verbatim
ci / rust-arm64 (pull_request) Successful in 1m50s
ci / docs-site (pull_request) Successful in 1m33s
ci / web (pull_request) Successful in 2m30s
ci / rust (pull_request) Successful in 14m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m21s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m32s
ci / bun-nix (pull_request) Failing after 13m41s
55a3d8b9 (#181) added the edition-2024 lint-level rationale to the session
bin's header naming std::env::set_var/remove_var — gate C's grep counts
comments by contract, so main went red at 5 mentions against the 4-call-site
baseline. Reword the comment instead of raising the baseline: a baseline of 5
with one comment inside would hide the next real call site.

Verified: scripts/ci/check-unsafe-hygiene.sh clean, cargo fmt clean.
2026-08-12 18:57:07 +02:00
enricobuehler 539236de91 Merge pull request 'feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)' (#182) from worktree-linux-pad-audio into main
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 41s
ci / rust-arm64 (push) Canceled after 0s
ci / docs-site (push) Canceled after 14s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 17s
deb / build-publish-host (push) Canceled after 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
apple / swift (push) Successful in 1m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
android / android (push) Successful in 6m37s
apple / screenshots (push) Successful in 5m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 3m48s
docker / builders-arm64cross (push) Successful in 26s
windows-host / package (push) Successful in 13m15s
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 46s
windows-host / canary-manifest (push) Successful in 32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 29m10s
2026-08-12 16:55:06 +00:00
enricobuehler 118758ff0b fix(pad-audio): the Linux pad sink speaks GE-Proton's AUX0-3 channel shape
ci / bun-nix (pull_request) Successful in 1m31s
ci / rust-arm64 (pull_request) Successful in 1m47s
ci / docs-site (pull_request) Successful in 1m54s
apple / swift (pull_request) Successful in 1m59s
ci / web (pull_request) Successful in 2m3s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 3m18s
android / android (pull_request) Successful in 6m59s
A field report (GE-Proton 11-5, real DualSense on-host) surfaced the missing
constraint: haptics only work when the pad's card runs the Pro Audio profile —
because GE's route opens the node through its bundled pipewire-alsa plugin
with aux_channels=1, and its pulse fallback forces a PA AUX0..3 map with
stream.dont-remix (proton-ds5-haptic patches 0013/0115/0116: "the hidden
PipeWire parent for a DualSense output exposes AUX0 through AUX3"). A
positioned FL FR RL RR sink puts those writers through position channelmix
instead of index passthrough.

The sink now advertises AUX0..AUX3. Proven on the box: an AUX-mapped
rear-pair-only tone captures index-exact (speaker pair 0.0000, coil pair
0.3662); a positioned stray stream folds into the speaker pair and never
excites the coils. The devtest reports per-pair peaks so exactly this class
of remix bug is visible.

Also confirmed from the GE patch set while here: device matching is
device.bus/vendor.id/product.id + the Sony/Wireless_Controller name
substrings (both of which the sink carries), and the MMDevice container is
now synthesized from the wine-side HID USB parent (patch 0112) — the old
pure-PW-node GUID_NULL concern no longer applies on GE >= 11-4.
2026-08-12 18:53:35 +02:00
enricobuehler dcde856178 feat(pad-audio): Linux hosts stream pad audio — the per-pad PipeWire sink (WP3)
The 0xD1 plane was Windows-host-only: host_cap() answered false and spawn()
was a stub everywhere else, so an Android tier-A client against a Linux host
negotiated the cap off and stayed on wire rumble. The whole downstream
machinery (framer, silence gate, lanes, 0xD1 send) was already capture-
agnostic — only the capturer was WASAPI.

- audio/linux/pad_sink.rs: one Audio/Sink stream node per DualSense-family
  pad, minted with the identity the matchers read (ALSA-style node.name with
  the pad's pairing MAC, description "Wireless Controller", bus/vendor/
  product/form-factor proplist, per-pad serial), 4-ch F32 48 kHz FL FR RL RR,
  no default-sink claim, priority.session 50. The process() callback IS the
  capture. PUNKTFUNK_PAD_SINK_NAME/_DESC override the strings for field
  debugging ({pad}/{mac} expand).
- native/pad_audio.rs: the shared logic and lanes compile on Linux;
  pad_audio_thread is generic over the capturer (open-with-backoff kept);
  host_cap() Linux arm = client asked + PUNKTFUNK_PAD_AUDIO + a reachable
  PipeWire socket; spawn() Linux arm mints the sink lazily in the streamer
  thread. spawn() gains an edge flag (Edge identity; ignored on Windows).
- devtest pad-sink-test: mint one sink and capture from it, no client — the
  WP3 on-glass gate. Verified on a Bazzite 44 host: identity served through
  pipewire-pulse, rear-pair (voice-coil) tone captured bit-exact over both
  the native and pulse legs.
- docs: PUNKTFUNK_PAD_AUDIO{,_SLOTS} are no longer (Windows); the roadmap
  non-goal narrows to Bluetooth client pads.

Gates (fedora:44 container, natively on the .41 box): cargo build --release
--locked (nvenc+vulkan-encode), clippy --all-targets -D warnings, cargo test
pad_audio+pad_sink 11/11, cargo fmt.
2026-08-12 18:53:35 +02:00
enricobuehler 77918674c3 Merge pull request 'An over-declared HEVC level no longer demotes native Vulkan decode, and the Windows client legs build again' (#181) from worktree-vk-level-gate-clamp into main
android / android (push) Failing after 1m20s
apple / swift (push) Successful in 1m37s
ci / rust-arm64 (push) Successful in 1m45s
ci / docs-site (push) Successful in 1m29s
ci / rust (push) Failing after 3m2s
ci / bun-nix (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m4s
ci / web (push) Successful in 3m46s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
apple / screenshots (push) Canceled after 3m29s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
arch / build-publish (push) Canceled after 5m25s
deb / build-publish (push) Canceled after 3m2s
deb / build-publish-host (push) Canceled after 2m45s
deb / build-publish-client-arm64 (push) Canceled after 2m39s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 33s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 30s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 28s
flatpak / build-publish (push) Canceled after 2m37s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 3m11s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 2s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-12 16:48:56 +00:00
enricobuehler faefbae830 fix(pf-presenter): spell MAKEINTRESOURCE(1) as ptr::without_provenance — clippy 1.96's manual_dangling_ptr reads the integer-ordinal cast as a dangling pointer and fails the Windows -D warnings gate (masked on main by the client bins failing to build first)
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Failing after 1m35s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m18s
ci / rust (pull_request) Failing after 2m21s
ci / rust-arm64 (pull_request) Successful in 3m45s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m42s
android / android (pull_request) Successful in 4m29s
2026-08-12 18:47:45 +02:00
enricobuehler 44fa12a298 test(pf-vkdecode): bind the PTL level reads so the SAFETY comments precede their unsafe blocks (clippy::undocumented_unsafe_blocks counts nothing inside macro arguments) 2026-08-12 18:47:45 +02:00
enricobuehler 55a3d8b919 fix(clients/session): the edition-2024 session bin cannot build on Windows
The half of the #177 fallout #180's follow-up could not reach: WP20 wrapped
the session bin's single-threaded-startup env writes in the `unsafe {}`
blocks edition 2024 requires — under `#![forbid(unsafe_code)]`, which no
inner attribute can override, so `punktfunk-client-session` fails with two
hard errors on every Windows leg (main push runs 17615/17616 red at Build;
verified on .173). Same resolution as #180 gave the GTK shell: `forbid`
becomes `deny`, and the three documented SAFETY sites carry the localized
`#[allow(unsafe_code)]` pf-update models.
2026-08-12 18:47:45 +02:00
enricobuehler a02014ec19 fix(pf-vkdecode): treat an over-declared stream level as a clamp, not a refusal
A 2026-08-12 field report (RTX 5060 client): every HEVC session demoted to
D3D11VA with 81 "outside device caps: stream level (Std code point 12) above
the device's maxLevelIdc (H.265 Std level 11)" refusals — the host's AMF
encoder stamps general_level_idc 6.2 (the codec maximum) on a 4K120 stream
that needs 5.2, and NVIDIA's driver caps H.265 decode at 6.1. The hardware
decodes the actual stream trivially; only the declaration was oversized.
AV1 passed the same gate, which is why "native-vulkan runs only with AV1".

The declared level is a claim, and the stream's real demands are enforced
where they are physical facts — coded extent and DPB depth, both checked at
session build. So the up-front level gate (H.264 + H.265) now warns once and
proceeds, and every SPS/VPS handed to the Vulkan parameters object has its
level clamped to the device ceiling (a set above maxLevelIdc is invalid
usage). AV1's gate is untouched: its code space is the bitstream's own and
no over-declaration has been seen in the field.

Verified on .173 (RTX 4090, driver 610.88): HEVC and AV1 both decode on the
native Vulkan rung at 60 fps against an NVENC host; unit tests pin the clamp
(lowers, only lowers, mutates the driver-visible block in place).
2026-08-12 18:46:57 +02:00
enricobuehler 5f55b820bc Merge pull request 'Edition-2024 follow-up: the three gates only the PR's own CI could reach' (#180) from worktree-edition-2024 into main
ci / rust (push) Failing after 3m38s
apple / swift (push) Successful in 1m49s
android / android (push) Failing after 5m32s
ci / docs-site (push) Successful in 1m12s
ci / rust-arm64 (push) Failing after 3m36s
ci / bun-nix (push) Successful in 1m24s
ci / web (push) Successful in 2m24s
arch / build-publish (push) Failing after 5m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish (push) Failing after 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m14s
docker / builders-arm64cross (push) Successful in 10s
deb / build-publish-client-arm64 (push) Failing after 3m43s
apple / screenshots (push) Successful in 5m38s
deb / build-publish-host (push) Successful in 6m17s
docker / deploy-docs (push) Failing after 4m38s
windows-host / package (push) Successful in 12m27s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 17s
flatpak / build-publish (push) Canceled after 6m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m50s
2026-08-12 16:34:13 +00:00
enricobuehler 57703fe31c Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / rust-arm64 (pull_request) Failing after 58s
ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 2m9s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m10s
ci / rust (pull_request) Failing after 5m14s
android / android (pull_request) Successful in 9m15s
2026-08-12 18:30:50 +02:00
enricobuehler 90a3304c2b Merge pull request 'The Windows host no longer blocks system sleep while idle' (#179) from worktree-win-sleep-blockers into main
deb / build-publish (push) Failing after 1m16s
ci / bun-nix (push) Successful in 27s
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 1m11s
apple / swift (push) Successful in 1m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Failing after 1m24s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
android / android (push) Canceled after 3m59s
apple / screenshots (push) Canceled after 2m19s
ci / rust-arm64 (push) Failing after 3m56s
ci / rust (push) Failing after 3m56s
arch / build-publish (push) Canceled after 4m1s
deb / build-publish-host (push) Canceled after 2m54s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m11s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 17s
windows-host / package (push) Canceled after 4m4s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
Reviewed-on: #179
2026-08-12 16:30:11 +00:00
enricobuehler 017867f211 fix(ci): the gates the PR run reaches and the boxes could not
ci / rust-arm64 (pull_request) Failing after 1m43s
ci / web (pull_request) Successful in 1m52s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 3m36s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 2m52s
android / android (pull_request) Successful in 6m23s
- adl_emul.rs adl_malloc: panic-free (a reachable expect in an extern fn is an
  abort — gate B; the Err arm is unreachable, ADL treats null as failure)
- punktfunk-host main.rs: reword the carve-out comments so gate C's textual
  count stays at its baseline (comments count)
- clients/linux: forbid(unsafe_code) -> deny with two named allows — the SDL
  device-filter clear and the spawn test's HOME scoping are unsafe calls in
  edition 2024 (caught by the aarch64 leg, the only one with glib)
2026-08-12 17:44:15 +02:00
enricobuehler ef0af3b558 docs(changelog): the Windows host no longer blocks system sleep while idle
ci / rust (pull_request) Failing after 1m1s
ci / web (pull_request) Successful in 1m7s
ci / bun-nix (pull_request) Successful in 27s
ci / rust-arm64 (pull_request) Successful in 6m17s
ci / docs-site (pull_request) Successful in 1m35s
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 9m51s
2026-08-12 17:41:08 +02:00
enricobuehler 535e95c4c0 fix(punktfunk-host): idle-stop the virtual-mic stream so a Windows host can sleep
The mic pump's WASAPI backend rendered silence into the virtual mic's
render endpoint (typically the Steam Streaming Microphone) for the whole
host lifetime. A RUNNING stream makes the Windows audio stack hold a
kernel power request ("An audio stream is currently in use", visible in
powercfg /requests), so every idle Punktfunk host blocked system sleep
forever — field-reported 2026-08-12 ("doesn't go to sleep anymore since
installing punktfunk; powercfg shows the Steam Streaming Microphone").

Stop the render stream (IAudioClient::Stop — client stays initialized,
the mic endpoint keeps existing) after 10 s of silence-only output at an
unchanged queue length, and park the render thread on a condvar the
producer notifies on the empty->non-empty edge, so the next mic frame
resumes the stream within one device period — well under the jitter
buffer's prime depth, so nothing audible changes. Keying the idle window
on the queue LENGTH (not emptiness) also covers a sub-prime tail a
vanished client left behind, while any fresh burst moves the length and
resets the window instead of being dropped.

During a session the box stays awake through the session's own
DisplayWakeRequest (pf-frame), never through this silence.
PUNKTFUNK_MIC_ALWAYS_ON=1 restores the old always-running stream in case
a virtual audio driver misbehaves while its render side is paused.
2026-08-12 17:39:22 +02:00
enricobuehler b385f0a031 Merge pull request 'Rust edition 2024: the whole tree, with the env-mutation class made visible (WP20)' (#177) from worktree-edition-2024 into main
ci / rust (push) Failing after 56s
apple / swift (push) Successful in 1m36s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m15s
deb / build-publish-client-arm64 (push) Failing after 2m11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m28s
apple / screenshots (push) Successful in 5m59s
ci / rust-arm64 (push) Failing after 8m18s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m43s
windows-drivers / probe-and-proto (push) Successful in 23s
deb / build-publish-host (push) Successful in 7m58s
decky / build-publish (push) Successful in 36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
windows-drivers / driver-build (push) Successful in 1m50s
arch / build-publish (push) Failing after 11m24s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
ci / bun-nix (push) Failing after 10m37s
deb / build-publish (push) Failing after 3m38s
flatpak / build-publish (push) Failing after 8m49s
android / android (push) Successful in 16m29s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 35s
release / apple (push) Successful in 10m11s
nix / flake (push) Successful in 15m25s
windows-host / package (push) Successful in 13m20s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 2m16s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 56s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
Reviewed-on: #177
2026-08-12 15:38:22 +00:00
enricobuehler 7528fd48a9 Merge pull request 'A stats tier picked between streams reached nothing until the app was restarted' (#178) from worktree-console-stats-tier-relatch into main
android / android (push) Canceled after 21s
apple / swift (push) Canceled after 21s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 26s
ci / rust (push) Canceled after 32s
ci / rust-arm64 (push) Canceled after 12s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 1s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 1s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Reviewed-on: #178
2026-08-12 15:38:03 +00:00
enricobuehler c68e0be688 Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / bun-nix (pull_request) Successful in 24s
windows-drivers / probe-and-proto (pull_request) Successful in 34s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m4s
windows-drivers / driver-build (pull_request) Successful in 2m23s
ci / rust (pull_request) Failing after 4m6s
ci / rust-arm64 (pull_request) Failing after 5m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m54s
nix / flake (pull_request) Successful in 13m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 54s
android / android (pull_request) Successful in 15m6s
2026-08-12 17:27:27 +02:00
enricobuehler 71c1970b93 fix(client/console): a stats tier picked between streams reached nothing until a restart
ci / bun-nix (pull_request) Successful in 31s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Failing after 59s
android / android (pull_request) Successful in 3m59s
ci / web (pull_request) Successful in 4m18s
ci / rust-arm64 (pull_request) Successful in 5m5s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 4m17s
ci / docs-site (pull_request) Successful in 4m32s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m15s
Field report: "no matter what I select the stats overlay is stuck showing as
detailed" on the Deck, cured by restarting the client app.

The console (Gaming Mode, and therefore Decky) builds its window and its run loop
ONCE and streams every session through them, and the loop took its stats tier from
the settings snapshot read at process start. Its own settings screen writes the
chosen tier to the file and redraws its row, so the choice looked taken while every
stream kept the tier the process happened to start on — Detailed for anyone who had
been on Detailed. Only a restart re-read it. The desktop shells were never affected:
they spawn a session process per stream, which resolves settings for itself.

The tier now rides `SessionParams` per launch, so browse mode adopts what THIS launch
resolved and the start-of-process value only seeds the loop until the first stream.
Two things fall out of resolving per launch rather than per process: a profile bound
to a host can finally move the tier in console mode (part of the documented P4 gap),
and the adoption sits in the `Start` arm rather than `StreamState::new`, so the
codec-fallback retry can't snap the overlay back and undo an in-stream cycle.

The `--stats` rule (a floor that lifts Off to Normal and demotes nothing) was written
out three times and is now one tested helper. The rest of the console's latched
presentation tier — touch and mouse model, shortcut inhibit, match-window, render
scale — is unchanged and still needs the models rebuilt per launch.

Gate: clippy --all-targets -D warnings, plain build, and tests for pf-client-core,
pf-presenter and punktfunk-client-session, all green in pf-lxcheck2 (linux/amd64);
clippy proven non-vacuous by touching the four edited files. cargo fmt --all --check
clean.
2026-08-12 17:26:33 +02:00
enricobuehler f373dffb5e chore: migrate the main workspace and pf-vkhdr-layer to edition 2024 (WP20)
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.

Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.

Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.

env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
  so a child-spawning thread (child spawn snapshots the env block) was live while
  `set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
  parallel tests could race the write; the set now happens exactly once inside
  `get_or_init`.

cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).

rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.

Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.

Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
2026-08-12 16:12:35 +02:00
enricobuehler 28b6633058 Merge pull request 'feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming' (#176) from worktree-edid-lock-toggle into main
audit / bun-audit (sdk) (push) Successful in 25s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / bun-audit (web) (push) Successful in 23s
audit / docs-site-audit (push) Successful in 22s
audit / pnpm-audit (push) Successful in 13s
apple / swift (push) Successful in 1m36s
audit / license-gate (push) Successful in 4m52s
ci / bun-nix (push) Successful in 23s
audit / miri (push) Successful in 5m50s
apple / screenshots (push) Successful in 5m56s
ci / web (push) Successful in 3m49s
ci / rust-arm64 (push) Successful in 4m6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
ci / docs-site (push) Successful in 3m50s
audit / c-abi-asan (push) Successful in 5m55s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 2m56s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m38s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m54s
audit / cargo-audit (push) Successful in 36s
deb / build-publish-host (push) Successful in 8m38s
release / apple (push) Successful in 9m55s
deb / build-publish (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 14m53s
docker / builders-arm64cross (push) Skipped
android / android (push) Successful in 15m34s
ci / rust (push) Failing after 14m34s
flatpak / build-publish (push) Successful in 20m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m1s
nix / flake (push) Failing after 20m46s
arch / build-publish (push) Successful in 21m46s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m45s
windows-host / package (push) Successful in 38m25s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 29s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m20s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m45s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m20s
2026-08-12 13:12:11 +00:00
enricobuehler c58217e403 Merge pull request 'fix(client): stop the double-arm race re-freezing RFI-healed streams' (#174) from worktree-gate-double-arm-fix into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
audit / cargo-audit (push) Canceled after 0s
audit / bun-audit (plugin-kit) (push) Canceled after 0s
audit / bun-audit (sdk) (push) Canceled after 0s
audit / bun-audit (web) (push) Canceled after 0s
audit / docs-site-audit (push) Canceled after 0s
audit / pnpm-audit (push) Canceled after 0s
audit / license-gate (push) Canceled after 0s
audit / miri (push) Canceled after 0s
audit / c-abi-asan (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
nix / flake (push) Canceled after 0s
release / apple (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-12 13:11:51 +00:00
enricobuehler 23f9b1130e Merge pull request 'Black-stream fixes: CCD restore snapshot pruning, iOS silent-black recovery, devnode journal retention' (#175) from worktree-black-stream-fixes into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-12 13:11:29 +00:00
enricobuehler c2c71f0ac5 Merge pull request 'fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default' (#173) from worktree-rt-gpu-priority-defaults into main
android / android (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 24s
windows-drivers / driver-build (push) Successful in 1m54s
2026-08-12 13:11:02 +00:00
enricobuehler 6a506a8fa9 fix(vdisplay/driver,pf-frame): no punktfunk process holds REALTIME GPU priority by default
windows-drivers / probe-and-proto (pull_request) Successful in 30s
ci / bun-nix (pull_request) Successful in 56s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m31s
windows-drivers / driver-build (pull_request) Successful in 1m47s
android / android (pull_request) Successful in 4m40s
ci / rust (pull_request) Successful in 9m54s
apple / swift (pull_request) Failing after 13m27s
apple / screenshots (pull_request) Skipped
The RX 9070 XT field A/B (2026-08-11/12 logs) convicted BOTH of our REALTIME
GPU-scheduling levers of generating the metronomic capture-stall class the
stall program has chased for weeks — compose-silence holes of 150-800 ms in
which ETW shows NO process presenting while the GPU stays responsive:

- the vdisplay driver's IddCxSetRealtimeGPUPriority raise beat at ~1.75-1.78 s
  (PFVD_NO_RT_GPU=1 alone removed that metronome: ~0.35 stalls/s metronomic ->
  10 sparse aperiodic over 3.9 min);
- the host auto-gate's HIGH->REALTIME upgrade (pf-frame dxgi.rs, T2.3) beat at
  ~3.58 s in the AV1 sessions where it promoted (vram_pct=1, 12:59:26); pinning
  PUNKTFUNK_GPU_PRIORITY_CLASS=high removed that residual too (13:45 session:
  zero metronomic, stall rate at the clean-run baseline).

Neither period matches any punktfunk clock: the full periodic-actor census
(driver: event-paced drain + 16 ms E_PENDING wait, 33 ms cursor poll, 3 s
watchdog reap; host: 250 ms descriptor poll, 5/50/100 ms probes + ~2 s scanline
retarget, 2 s VRAM gate, 2 s exclusive re-assert, 3.33 s pinger, 1 s stats,
~1 Hz phase-lock, fps/2 LTR marks) has nothing in the 1.69-2.29 s band, and
every host-side actor ran unchanged in the A/B that killed the fast metronome.
The periodicity is emergent from holding an unreachable-priority queue against
the WDDM scheduler on this AMD family (the period even differs by which of our
processes holds REALTIME); it is not a punktfunk cadence being amplified, so
there is nothing punktfunk-periodic to fix - the fix is to stop holding
REALTIME by default, which is also canonical parity (no shipping IDD raises
it, and HIGH was the class that delivered the original Sunshine-parity encode
win).

- Driver: PFVD_NO_RT_GPU (default-ON, opt-OUT) becomes the PFVD_RT_GPU ladder,
  default OFF on every vendor: unset = no raise (canonical IDD behavior);
  =thread = SetGPUThreadPriority(+7), a graduated in-band middle rung for field
  A/B (not default: unmeasured here, and the host measured the same call as "no
  help" for its own starvation case); anything else = the old REALTIME DDI.
  PFVD_NO_RT_GPU stays recognized and WINS over the opt-in, so the field boxes
  that carry it through the default-ON era keep meaning OFF. Both directions
  remain A/B-able without a rebuild (machine env + device restart). The CPU
  half of the original branch-2 hardening (MMCSS / TIME_CRITICAL) is untouched
  - it addressed the delivery holes that were actually observed.
- Host: PUNKTFUNK_GPU_PRIORITY_CLASS default auto -> high. `auto` (the gated
  REALTIME upgrade) stays available as an explicit opt-in, `realtime` still
  pins; unrecognized values now land on the HIGH default instead of silently
  opting into the gate - a typo must not buy the hazard. The VRAM/HAGS gate
  machinery is unchanged for `auto`; it guards the NVENC-hang hazard but cannot
  see this one.
- stall.rs: the no-OS-event METRONOMIC warning now carries rt_gpu_driver /
  rt_gpu_host fields (the machine-env state of both levers) and names clearing
  them as the FIRST cure, ahead of the display-hardware suspects - a field log
  self-answers the triage question this program just spent a week on.

No console policy axis for the driver knob: the lever is default-safe now, the
driver reads config at WUDFHost scope where machine env already matches the
device-restart lifecycle, and a policy axis would need pf-driver-proto churn
(or a device-key registry write) for an experimental lever that only exists to
be A/B-ed. If the `thread` rung ever proves out as a default-worthy raise,
that is the moment to revisit.
2026-08-12 13:57:20 +02:00
enricobuehler 712ee935d6 fix(win-display): a failed devnode re-enable must keep its crash-journal entry
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / docs-site (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m43s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 3m34s
ci / web (pull_request) Successful in 4m2s
android / android (pull_request) Successful in 4m13s
ci / rust (pull_request) Successful in 5m56s
enable_instances cleared every requested id from the pnp-disabled-monitors
journal unconditionally — a mid-life re-enable failure erased its own
crash-recovery entry, so neither the running host nor the next start would
ever retry, leaving the operator's monitor disabled (invisible to Windows and
every display listing) until a manual Device Manager fix. Field 2026-08-12
(Android-client host running the pnp_disable_monitors axis): displays gone
from the client after 2-3 sessions, only a full host restart bringing them
back — the restart path only works while the journal still has the entry.

Only successfully re-enabled ids clear now; failures stay journaled (and are
logged with the consequence) so startup_recover retries them.
2026-08-12 13:30:04 +02:00
enricobuehler a2aa0a5f97 fix(apple): never sit black AND silent while the video format is unseeded
Both pumps (Stage2Pipeline + the stage-1 StreamPump) dropped every AU in a
silent guard while 'format' was nil — the opening IDR's parameter sets never
arrived or never parsed, and under the host's infinite GOP nothing re-delivers
them unless the client asks. Field 2026-08-12 (iOS, H265): sessions decoding
nothing, requesting nothing, host streaming perfectly — a black stream with
zero recovery requests, randomly self-healing only when some later event
produced a fresh IDR.

While format is nil after the first received AU, the pumps now set awaitingIDR
(routing through the existing 100 ms-throttled recovery.request()) and log the
state once, so a lost/unparsed opening IDR converts to a normal recovery
round-trip instead of a permanent silent black.
2026-08-12 13:16:58 +02:00
enricobuehler d6b9862f1e fix(win-display): prune unplugged targets from the CCD restore snapshot
A monitor unplugged mid-session left the teardown replaying a snapshot whose
paths reference an absent target — SetDisplayConfig rejects the WHOLE array
with 0x57 ERROR_INVALID_PARAMETER, nothing restores, the desk stays dark, and
the next session snapshots the wreckage (the poisoned-snapshot chain; field
2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping
between black and working at random, incl. one that genuinely encoded black).

The restore now prunes the snapshot against a live QDC_ALL_PATHS availability
sweep before replaying: stale paths drop, the mode table is rebuilt with only
the entries the survivors reference (an orphaned mode entry is itself an
0x57), and an empty survivor set skips the apply so the dark-desk backstop
decides. Verbatim behavior is unchanged when everything is still attached or
when the availability query itself fails.
2026-08-12 13:15:02 +02:00
enricobuehler f4e39a442b feat(display): edid_lock policy axis — pin AMD connector EDID emulation while streaming
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m45s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m14s
ci / web (pull_request) Successful in 1m27s
ci / bun-nix (pull_request) Successful in 34s
android / android (pull_request) Successful in 5m52s
ci / rust (pull_request) Failing after 7m55s
ci / docs-site (pull_request) Successful in 7m54s
ci / rust-arm64 (pull_request) Successful in 9m13s
nix / flake (pull_request) Successful in 14m26s
Productizes the adl-emul probe (the prior commit) as the display-policy axis its
PR promised: the ADL FFI moves to pf_win_display::adl_emul (one surface shared by
the probe tool and the host, so a reporter's probe and the console's toggle
exercise byte-identical driver calls), and an EXPERIMENTAL edid_lock axis joins
ddc_power_off/pnp_disable_monitors — orthogonal to presets, off by default.

At the first Exclusive isolate the host pins each occupied AMD connector's live
EDID + ADL_EMUL_MODE_ALWAYS (the software HPD dummy) BEFORE the physicals
deactivate; last-member teardown unlocks. Pinned emulation outlives the process,
so a crash journal (edid-lock-active.json) unlocks on the next host start,
mirroring the pnp_disable_monitors recovery. Inert without an AMD driver.

The console shows the toggle ONLY when the GPU inventory lists an AMD adapter —
the lever exists nowhere else, and a toggle that can never act is the 'saved and
then did nothing' trap the enforced-axes list exists to prevent.
2026-08-12 08:47:42 +02:00
enricobuehler a0577cb86e feat(tools/display-disturb): adl-emul — AMD connector-emulation probe (software HPD dummy)
The standby-sink stall program's §3 dead-end list marked ADL EmulationMode
'likely Pro-gated' on field hearsay, with 'probe once, log rc' as the owed
falsification — never run. Three RX 9070 XT field cases later (ASUS
VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI), this is that
probe, shippable to reporters: read-only caps/board-layout/connection-state
walk by default, --lock pins the live EDID + ADL_EMUL_MODE_ALWAYS on
occupied connectors (the software HPD-holding dummy), --unlock restores.
Every call prints the bench's epoch_ms correlation line with the decoded
ADL rc — ADL_ERR_NOT_SUPPORTED(-8) vs ADL_OK on consumer Adrenalin is the
Pro-gating answer, and a --lock run during a stream with the sink asleep
is the direct A/B for the metronomic stall class.

atiadlxx.dll is bound dynamically (absent = clean exit 2), structs mirror
adl_structures.h verbatim, and the probe touches only connectors the
board-layout walk enumerated. Gates: check/clippy -D warnings (msvc
cross-target) + fmt clean; native stub unaffected.
2026-08-12 08:16:33 +02:00
enricobuehler 66ba61b12c Merge pull request 'Safety round 2: ASAN+LSAN over the C ABI boundary, two soundness fixes, and WP4's AvFrame RAII' (#172) from worktree-safety-round-2 into main
audit / bun-audit (plugin-kit) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 24s
audit / bun-audit (web) (push) Successful in 27s
audit / cargo-audit (push) Successful in 34s
audit / docs-site-audit (push) Successful in 26s
apple / swift (push) Successful in 1m46s
audit / pnpm-audit (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m35s
ci / web (push) Successful in 1m25s
ci / docs-site (push) Successful in 1m22s
ci / bun-nix (push) Successful in 23s
audit / miri (push) Successful in 5m10s
audit / license-gate (push) Successful in 7m0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
android / android (push) Successful in 8m2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
audit / c-abi-asan (push) Successful in 7m51s
arch / build-publish (push) Successful in 8m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
deb / build-publish (push) Successful in 5m49s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m25s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m39s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Failing after 1m4s
deb / build-publish-host (push) Successful in 8m54s
release / apple (push) Successful in 12m10s
windows-host / package (push) Successful in 13m41s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m19s
apple / screenshots (push) Successful in 5m52s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m14s
ci / rust (push) Successful in 13m52s
flatpak / build-publish (push) Successful in 15m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m41s
Reviewed-on: #172
2026-08-12 05:57:16 +00:00
enricobuehler 5002849737 feat(pf-encode): WP4 — AvFrame/AvSwsContext RAII across all three libav backends
ci / bun-nix (pull_request) Successful in 31s
ci / docs-site (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m45s
apple / swift (pull_request) Successful in 1m49s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m7s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m56s
android / android (pull_request) Successful in 6m21s
ci / rust (pull_request) Successful in 7m42s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 12m28s
Two newtypes beside AvBuffer/AvFilterGraph (same house shape: alloc/from_raw
rejects the allocator's null once, as_ptr lends, Drop frees, no Clone; NonNull
inside so the Options get a niche). 8 av_frame_alloc + 3 sws_getContext sites
converted; all 22 hand-placed av_frame_free and 5 sws_freeContext calls are
gone, and the three hand-written Drop impls (CpuInner, SystemInner,
NvencEncoder) with them.

The live defect this closes: ZeroCopyInner::submit (ffmpeg_win) leaked the
frame AND one pooled hwframe surface on each of three ? exits between the
pool pull and the send — under a SAFETY comment asserting no leak — and with
POOL=8, eight such failures starved the pool and wedged the encoder with no
error naming the cause. Every exit now returns the surface.

Drop-order care (the hidden cost the survey flagged): NvencEncoder's sws_csc
moved to field #1 (its hand-Drop freed it before all fields; this path runs
on every stall-watchdog recovery via *self = fresh); CpuInner's nv12/sws
declaration order flipped to match its hand-Drop; SystemInner's already
agreed. Pinned by FIELD ORDER comments, not offset_of asserts — the survey's
assert suggestion is the wrong tool: offset_of measures repr(Rust) memory
layout, which the compiler may reorder independently of the declaration
order that drop order actually follows.

The dmabuf path keeps its early descriptor release via an explicit drop()
at the exact point the hand-written free sat.

Gates: .25 clippy -D warnings + tests green (nvenc,vulkan-encode,pyrowave);
.133 check --all-targets + clippy --release -D warnings + 80 tests green
(nvenc,amf-qsv,qsv; test step needs ffmpeg\bin on Path — 0xC0000135
otherwise). Owed on hardware: the #[ignore]d alloc/drop cycles on
.136/.116/.173/.47 and the pool-exhaustion assertion (9th submit succeeds
after 8 forced failures).
2026-08-12 00:31:14 +02:00
enricobuehler 9a59504ba4 fix(punktfunk-core): validate InputKind before forming &InputEvent in the C ABI
abi.rs's two send-input entry points built &InputEvent straight out of
caller memory with ev.as_ref(); InputKind is repr(u8) with 16 valid
discriminants, so a C embedder writing ev->kind = 42 was immediate UB the
moment the reference formed — in a file whose stated principle is that
failures become status codes. New read_input_event() checks null, reads
the tag as a raw byte, validates through the same InputKind::from_u8 the
wire path uses, and only then forms the reference; bad tags return
InvalidArg. Every other field is a plain integer, valid for any pattern.

Test stages the event in MaybeUninit storage so the test itself never
holds a reference to the invalid value. 380 lib tests + the C harness
round-trip + clippy -D warnings green on .25; header regenerated.
2026-08-12 00:12:01 +02:00
enricobuehler e8c306b9c0 fix(punktfunk-host): WP3c/3d — align the TOKEN_USER buffer, make EqualSid fail closed
3c: forming &TOKEN_USER (align 8) out of a bare [u8; 256] (align 1) was UB
by the validity rule whenever the stack slot landed misaligned — shipped
codegen happened to 8-align it, which is luck, not a contract. Fixed with
a repr(align(8)) wrapper that keeps the buffer at 256 BYTES; the comment
records why [u64; 32] is the wrong shape (len() would silently become 32
and misclassify every hand-run host as SYSTEM via ERROR_INSUFFICIENT_BUFFER,
invisibly to a SYSTEM-side test). Length arg now size_of_val.

3d: EqualSid().is_ok() read BOTH 'SIDs differ' and 'EqualSid failed' as
Err, so a genuine failure yielded 'not SYSTEM' — the fail-OPEN direction,
contradicting the documented fail-closed contract. Now split three ways on
the last-error code, with SetLastError(0) cleared first so a stale value
cannot misclassify.

Gate: cargo check -p punktfunk-host + cargo clippy --release -D warnings
both green on .133 (real MSVC, fresh extraction, sentinel-verified).
2026-08-12 00:12:00 +02:00
enricobuehler c3b57438e1 chore(ci): c-abi-asan job in audit.yml — the harness under ASAN+LSAN, weekly + on demand
Same shape as the miri job (dated nightly, own san- cache prefixes,
non-blocking day one via a step-level ::warning::, a proved-it-ran grep).
run.sh gains PF_SAN_TOOLCHAIN so CI can pin its dated nightly — bare
+nightly would ask for the rolling channel the job never installs. Both
the pinned and vanilla paths re-verified green on .25.
2026-08-12 00:02:26 +02:00
enricobuehler e20b614059 chore(safety): PF_SAN sanitizer gate for the C ABI harness
PF_SAN=address builds the punktfunk-core staticlib on nightly with
-Zsanitizer/-Zbuild-std and the C harness with clang -fsanitize, so ASAN
instruments both sides of the boundary at once and LSAN (detect_leaks=1)
becomes the first automated check on abi.rs's Box::into_raw/from_raw leak
contract. Verified on the .25 box: green run passes byte-exact; deleting
one punktfunk_session_free() in the harness makes LSAN report the 308
Rust-side allocations behind the handle and the script exit 1.

The harness binary moves from mktemp to target/ — a debug+ASAN static
binary can exceed a tmpfs /tmp (it did, on .25's 3.6G tmpfs).
2026-08-11 23:58:06 +02:00
126 changed files with 4703 additions and 1311 deletions
+82
View File
@@ -21,6 +21,11 @@
# 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=[…]).
@@ -364,3 +369,80 @@ jobs:
-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."
+11 -1
View File
@@ -187,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)
+36
View File
@@ -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 25 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
View File
@@ -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)",
]
+2 -2
View File
@@ -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"
+4
View File
@@ -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."
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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>,
+1 -1
View File
@@ -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,
+10 -10
View File
@@ -36,7 +36,7 @@ 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>,
@@ -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>,
+18 -18
View File
@@ -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 (~250500 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,
+17 -17
View File
@@ -19,7 +19,7 @@ use super::{jni_guard, lock_recover, 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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+2 -2
View File
@@ -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>,
+1 -1
View File
@@ -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>,
@@ -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
@@ -936,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
@@ -120,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
+12 -4
View File
@@ -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}` });
}
}
+18 -6
View File
@@ -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 isnt installed"
: "Update the Punktfunk client"
: problem === "list-failed"
? "Couldnt scan for hosts"
: "Update the Punktfunk client"
}
description={
problem === "client-unavailable"
? "This panel launches the Punktfunk app, which isnt 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
View File
@@ -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. */
+18 -9
View File
@@ -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);
+4 -1
View File
@@ -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.
+6 -1
View File
@@ -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.
+15 -11
View File
@@ -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
View File
@@ -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))]
+1 -1
View File
@@ -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
+10 -10
View File
@@ -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
})
+40 -37
View File
@@ -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);
+5 -4
View File
@@ -82,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") {
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -64,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;
}
@@ -533,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 \
+4 -4
View File
@@ -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
+13
View File
@@ -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
@@ -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
}
}
}
+1 -1
View File
@@ -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."
+8 -4
View File
@@ -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
+7 -4
View File
@@ -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> {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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."
+82
View File
@@ -119,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.
+66 -69
View File
@@ -24,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
@@ -191,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
@@ -199,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,
@@ -226,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.
@@ -608,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
@@ -640,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,
@@ -654,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
@@ -678,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)
@@ -692,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,
@@ -838,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()
@@ -927,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
@@ -958,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})");
}
@@ -1001,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.
+74 -58
View File
@@ -1652,7 +1652,7 @@ impl NvencCudaEncoder {
return Err(nvenc_status::call_err(
"register_resource (CUDADEVICEPTR)",
e,
))
));
}
}
self.ring.push(RingSlot {
@@ -2779,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
@@ -3315,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(
@@ -3428,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
@@ -3468,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.
@@ -3482,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,
@@ -3554,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 \
@@ -3583,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);
}
@@ -3618,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(
@@ -3664,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;
}
@@ -3715,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
@@ -3755,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,
@@ -3841,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
@@ -3870,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,
@@ -3940,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!(
@@ -3952,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
@@ -3975,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();
@@ -4045,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).
@@ -4086,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,
@@ -4157,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
@@ -4190,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
@@ -4207,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,
@@ -4250,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).
@@ -4658,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");
@@ -4770,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(
@@ -4866,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,
@@ -4911,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");
@@ -4943,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");
+86 -116
View File
@@ -34,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
@@ -544,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,
@@ -600,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,
@@ -614,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
@@ -635,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)",
@@ -669,8 +663,8 @@ impl CpuInner {
Ok(CpuInner {
enc,
hw,
sws,
nv12,
sws,
src_format: format,
width,
height,
@@ -691,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})");
}
@@ -742,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.
@@ -1041,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());
@@ -1075,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) {
@@ -1100,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(),
@@ -1111,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})");
}
+4 -4
View File
@@ -425,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) };
}
}
+89 -117
View File
@@ -59,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
@@ -497,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>,
@@ -547,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)",
@@ -576,7 +572,7 @@ impl SystemInner {
Ok(SystemInner {
enc,
sw_frame,
sws: ptr::null_mut(),
sws: None,
staging: None,
ctx: None,
format,
@@ -632,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");
}
@@ -703,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);
@@ -746,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,
@@ -754,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 {
@@ -796,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,
@@ -804,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 {
@@ -842,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,
@@ -850,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");
@@ -870,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,
@@ -898,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
@@ -1212,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
@@ -1247,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(())
}
+14 -5
View File
@@ -1616,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
@@ -2882,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 {
@@ -3007,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
+1 -1
View File
@@ -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 -24
View File
@@ -155,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()
@@ -174,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),
}
}
@@ -275,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();
@@ -316,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
// 150800 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;
+76 -19
View File
@@ -8,24 +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.
#[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;
@@ -46,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;
@@ -114,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);
@@ -134,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
+1 -1
View File
@@ -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 -28
View File
@@ -169,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;
@@ -500,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()) {
@@ -560,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 {
@@ -578,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
@@ -620,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()
+1 -1
View File
@@ -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
+10 -10
View File
@@ -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;
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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
+13
View File
@@ -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
+11 -1
View File
@@ -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);
}
+4 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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."
+26 -3
View File
@@ -267,6 +267,16 @@ pub struct DisplayPolicy {
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub pnp_disable_monitors: bool,
/// **EXPERIMENTAL, AMD-only in effect: pin connector EDID emulation while streaming** — the
/// software equivalent of an HPD-holding dummy plug (`pf_win_display::adl_emul`). Locked at
/// the first Exclusive isolate BEFORE the physicals deactivate (an awake sink answers its
/// live-EDID read), unlocked at last-member teardown, crash-journaled so a dead host unlocks
/// on its next start. Targets the standby-sink stall class at its SOURCE: with emulation
/// pinned the KMD stops servicing the sleeping sink's HPD/DDC/link. Inert without an AMD
/// driver (`atiadlxx.dll` absent) and on non-Windows. Orthogonal to `preset` (like
/// `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub edid_lock: bool,
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
///
@@ -318,6 +328,7 @@ impl Default for DisplayPolicy {
game_session: GameSession::default(),
ddc_power_off: false,
pnp_disable_monitors: false,
edid_lock: false,
capture_monitor: None,
}
}
@@ -454,6 +465,7 @@ impl EffectivePolicy {
game_session: GameSession,
ddc_power_off: bool,
pnp_disable_monitors: bool,
edid_lock: bool,
capture_monitor: Option<String>,
) -> DisplayPolicy {
DisplayPolicy {
@@ -474,6 +486,7 @@ impl EffectivePolicy {
game_session,
ddc_power_off,
pnp_disable_monitors,
edid_lock,
capture_monitor,
}
}
@@ -739,6 +752,13 @@ impl DisplayPolicyStore {
self.get().pnp_disable_monitors
}
/// The experimental AMD connector-EDID-emulation axis — orthogonal to the preset (like
/// [`Self::game_session`]), read directly off the stored policy (default off when
/// unconfigured).
pub fn edid_lock(&self) -> bool {
self.get().edid_lock
}
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
/// write succeeds, so a full disk can't leave memory and file disagreeing — and the whole
/// transaction runs under [`Self::write`], so neither can two concurrent PUTs.
@@ -1318,13 +1338,16 @@ mod tests {
GameSession::Dedicated,
true,
true,
true,
Some("DP-2".into()),
);
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
// preserved through the transform — arranging displays must not clear an unrelated setting.
// The orthogonal axes (game-session, DDC power-off, PnP disable, EDID lock,
// capture-monitor pin) are preserved through the transform — arranging displays must not
// clear an unrelated setting.
assert_eq!(p.game_session, GameSession::Dedicated);
assert!(p.ddc_power_off);
assert!(p.pnp_disable_monitors);
assert!(p.edid_lock);
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom);
@@ -1405,7 +1428,7 @@ mod tests {
let keys: Vec<&String> = v.as_object().unwrap().keys().collect();
assert_eq!(
keys.len(),
12,
13,
"a display-policy axis was added or removed: {keys:?} — wire it into the mgmt PUT's \
per-axis merge (and into `EffectivePolicy` if it is a behavior axis) before bumping this"
);
+130 -118
View File
@@ -16,7 +16,7 @@
//! lands — a runtime gate on `remote_fd.is_some()`.
//!
//! The ownership split: the session's capturer no longer owns the real keepalive — the registry does.
//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, gen-stamped
//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, generation-stamped
//! `DisplayLease` (mirrors the Windows `MonitorLease`); dropping it releases the registry refcount,
//! and the lifecycle machine decides linger / teardown. `capture_virtual_output`'s signature is
//! unchanged — it just holds a lease instead of the real keepalive.
@@ -85,7 +85,7 @@ fn topology_str() -> String {
/// with the quit application code — a user "stop", not a network drop), the display is torn down
/// **immediately**, skipping the keep-alive linger. A bare disconnect leaves it `false` → normal linger.
///
/// `supersedes`: the pool gen of a display this acquire REPLACES (a mid-stream mode switch creates
/// `supersedes`: the pool generation of a display this acquire REPLACES (a mid-stream mode switch creates
/// the new display before retiring the old — create-before-drop). The replacement inherits group
/// topology ownership: without this, the dying predecessor counts as a live sibling and the new
/// display "extends" behind it, losing a Primary/Exclusive topology on every resize. `None`
@@ -151,7 +151,7 @@ pub fn snapshot() -> Snapshot {
.into_iter()
.enumerate()
.map(|(idx, i)| DisplayInfo {
slot: i.gen,
slot: i.generation,
backend: i.backend.to_string(),
mode: i.mode,
state: i.state.to_string(),
@@ -185,7 +185,7 @@ pub fn snapshot() -> Snapshot {
/// released.
pub fn release(slot: Option<u64>) -> usize {
#[cfg(target_os = "windows")]
// Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp
// Windows slots (Stage W1): `slot` selects one kept monitor by its generation stamp
// ([`DisplayInfo::slot`]); `None` releases every kept one.
let released = super::manager::force_release(slot);
#[cfg(target_os = "linux")]
@@ -211,12 +211,12 @@ pub fn release(slot: Option<u64>) -> usize {
/// Tear down a **reused-but-dead** pool entry by its generation stamp (A2). Called by the pipeline
/// builder when the first frame fails on a display [`acquire`] handed back as REUSED — so the retry
/// loop's next `acquire` creates fresh instead of re-wedging on the same corpse. No-op off Linux / if
/// the entry is already gone (idempotent — the subsequent stale-gen lease drop no-ops too).
pub fn mark_failed(gen: u64) {
/// the entry is already gone (idempotent — the subsequent stale-generation lease drop no-ops too).
pub fn mark_failed(generation: u64) {
#[cfg(target_os = "linux")]
linux::mark_failed(gen);
linux::mark_failed(generation);
#[cfg(not(target_os = "linux"))]
let _ = gen;
let _ = generation;
}
/// Force-release a **superseded** kept display by its generation stamp
@@ -225,13 +225,13 @@ pub fn mark_failed(gen: u64) {
/// keep-alive policy every resize would accumulate kept monitors at stale modes. The mode-switch
/// arm calls this once the new pipeline is up and the old capturer is dropped. Only a KEPT
/// (lingering/pinned) entry is released — an Active one is refused, like `/display/release` — and
/// a gen that's already gone (immediate teardown) is a no-op. No-op off Linux (Windows
/// a generation that's already gone (immediate teardown) is a no-op. No-op off Linux (Windows
/// reconfigures the same monitor in place — nothing is superseded).
pub fn retire(gen: u64) {
pub fn retire(generation: u64) {
#[cfg(target_os = "linux")]
linux::retire(gen);
linux::retire(generation);
#[cfg(not(target_os = "linux"))]
let _ = gen;
let _ = generation;
}
/// Invalidate every kept display of `backend` — its compositor instance is gone (a Game↔Desktop switch
@@ -320,9 +320,9 @@ mod pool {
/// The session epoch at creation (A4). Reuse requires an epoch match; the linger timer reaps
/// entries whose epoch is stale (their compositor instance was replaced under them).
pub(super) epoch: u64,
/// Generation stamp: a `DisplayLease` only releases if its gen still matches (a stale lease
/// Generation stamp: a `DisplayLease` only releases if its generation still matches (a stale lease
/// — its entry was reused + re-stamped — is a no-op).
pub(super) gen: u64,
pub(super) generation: u64,
/// The out-of-band-cursor mode this display was CREATED with (Phase B): metadata-pointer
/// (cursor-channel session) vs compositor-embedded. Reuse requires an exact match — a kept
/// embedded display has no cursor metadata for a channel session to forward, and a kept
@@ -344,15 +344,15 @@ mod pool {
/// gamescope **spawn** is an independent nested session per client (no shared desktop), so each
/// gamescope display is its OWN group — never auto-rowed against, or topology-/restore-grouped with,
/// another gamescope session.
pub(super) fn group_key(backend: &str, gen: u64) -> String {
pub(super) fn group_key(backend: &str, generation: u64) -> String {
if backend == "gamescope" {
format!("gamescope#{gen}")
format!("gamescope#{generation}")
} else {
backend.to_string()
}
}
/// Is the pooled entry `(e_backend, e_gen)` a member of the group the display `(backend, gen)`
/// Is the pooled entry `(e_backend, e_gen)` a member of the group the display `(backend, generation)`
/// belongs to — the ONE definition of membership, shared by the restore hand-off, the
/// first-in-group probe and the layout collection.
///
@@ -370,10 +370,10 @@ mod pool {
e_backend: &str,
e_gen: u64,
backend: &str,
gen: u64,
generation: u64,
supersedes: Option<u64>,
) -> bool {
Some(e_gen) != supersedes && group_key(e_backend, e_gen) == group_key(backend, gen)
Some(e_gen) != supersedes && group_key(e_backend, e_gen) == group_key(backend, generation)
}
/// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a
@@ -382,20 +382,20 @@ mod pool {
/// reclaimed display's keepalive, so the physical is re-enabled while our output still exists —
/// the compositor never sees zero outputs). `None` in → `None` out.
///
/// `backend`+`gen` identify the DEPARTING display, and both are needed: keyed on the backend name
/// `backend`+`generation` identify the DEPARTING display, and both are needed: keyed on the backend name
/// alone, one gamescope spawn's restore floated onto an unrelated client's spawn — where it would
/// run when THAT session ended and never when its own did.
pub(super) fn hand_off_restore(
remaining: &mut [Entry],
backend: &'static str,
gen: u64,
generation: u64,
restore: Option<Restore>,
) -> Option<Restore> {
let action = restore?;
// At most one restore per group, so any surviving sibling has `None` to receive it.
match remaining
.iter_mut()
.find(|e| in_group(e.backend, e.gen, backend, gen, None))
.find(|e| in_group(e.backend, e.generation, backend, generation, None))
{
Some(sibling) => {
sibling.topology_restore = Some(action);
@@ -441,8 +441,9 @@ mod pool {
&& !matches!(entries[i].life, lifecycle::State::Active { .. });
if entries[i].life.poll_expiry(now) || dead_epoch {
let mut e = entries.remove(i);
let (backend, gen) = (e.backend, e.gen);
if let Some(r) = hand_off_restore(entries, backend, gen, e.topology_restore.take())
let (backend, generation) = (e.backend, e.generation);
if let Some(r) =
hand_off_restore(entries, backend, generation, e.topology_restore.take())
{
restores.push(r);
}
@@ -470,7 +471,7 @@ mod pool {
/// One live/kept display, flattened out of the pool under the lock — so the group + arrangement
/// math (which calls the layout engine) runs OUTSIDE the lock.
pub(super) struct Row {
pub(super) gen: u64,
pub(super) generation: u64,
pub(super) backend: &'static str,
pub(super) mode: Mode,
pub(super) identity_slot: Option<u32>,
@@ -480,7 +481,7 @@ mod pool {
}
/// The desktop position for a display just appended to its group (design §6.2): the group's
/// `existing` members (each with its acquire `gen`) plus `new` last, ordered by `gen`, arranged by
/// `existing` members (each with its acquire `generation`) plus `new` last, ordered by `generation`, arranged by
/// the pure [`layout`](crate::layout) engine, taking the new member's placement. Pure — so the
/// append-in-acquire-order + auto-row/manual arrangement is unit-tested independent of the
/// pool/global.
@@ -503,10 +504,10 @@ mod pool {
/// is dropped. Pure over its `known`/`next` state — the caller owns the process-lifetime copy.
///
/// Ids used to be the index into the sorted key list, which meant a new group could RENUMBER an
/// untouched one: with one KWin desktop at group 1, a gamescope spawn at gen 3 sorts ahead of
/// untouched one: with one KWin desktop at group 1, a gamescope spawn at generation 3 sorts ahead of
/// `"kwin"` and silently moved the unchanged desktop to group 2 on the next `/display/state` poll.
/// A monotonic counter, remembered per key, cannot do that. Pruning to the live keys is what keeps
/// the map bounded: the per-spawn keys (`gamescope#<gen>`, one per dedicated session) would
/// the map bounded: the per-spawn keys (`gamescope#<generation>`, one per dedicated session) would
/// otherwise accumulate for the host's lifetime — an id is retired with its group.
pub(super) fn assign_group_ids(
known: &mut std::collections::BTreeMap<String, u32>,
@@ -523,7 +524,7 @@ mod pool {
}
/// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2) by
/// [`group_key`], ordered by acquire (`gen`), with each member's position from the pure
/// [`group_key`], ordered by acquire (`generation`), with each member's position from the pure
/// [`layout`](crate::layout) engine. `ids` maps each group key to its reported group id (see
/// [`group_ids`]). Pure — no I/O, no global — so the grouping / ordering / position assignment is
/// unit-tested against synthetic rows.
@@ -535,20 +536,23 @@ mod pool {
) -> Vec<DisplayInfo> {
use crate::layout::{self, Member};
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
let mut keys: Vec<String> = rows
.iter()
.map(|r| group_key(r.backend, r.generation))
.collect();
keys.sort();
keys.dedup();
let mut out: Vec<DisplayInfo> = Vec::new();
for key in keys.iter() {
// This group's members in acquire order (gen ascending) → display_index + arrangement.
// This group's members in acquire order (generation ascending) → display_index + arrangement.
let mut idx: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, row)| &group_key(row.backend, row.gen) == key)
.filter(|(_, row)| &group_key(row.backend, row.generation) == key)
.map(|(i, _)| i)
.collect();
idx.sort_by_key(|&i| rows[i].gen);
idx.sort_by_key(|&i| rows[i].generation);
let members: Vec<Member> = idx
.iter()
.map(|&i| Member {
@@ -561,7 +565,7 @@ mod pool {
let row = &rows[i];
let p = places[ord];
out.push(DisplayInfo {
slot: row.gen,
slot: row.generation,
backend: row.backend.to_string(),
mode: (row.mode.width, row.mode.height, row.mode.refresh_hz),
state: row.state.to_string(),
@@ -590,8 +594,8 @@ mod pool {
use std::sync::Arc;
/// A minimal pool entry for the pure teardown/restore tests (dummy keepalive; the
/// `hand_off_restore` logic only reads `backend` + `gen` + `topology_restore`).
fn test_entry(backend: &'static str, gen: u64, restore: Option<Restore>) -> Entry {
/// `hand_off_restore` logic only reads `backend` + `generation` + `topology_restore`).
fn test_entry(backend: &'static str, generation: u64, restore: Option<Restore>) -> Entry {
Entry {
life: lifecycle::State::default(),
keepalive: Box::new(()),
@@ -607,7 +611,7 @@ mod pool {
topology_restore: restore,
launch: None,
epoch: 0,
gen,
generation,
hw_cursor: false,
hdr: false,
}
@@ -631,7 +635,10 @@ mod pool {
/// `ids_for` against a CARRIED map — for the stability test, which needs the same state
/// across two assemblies.
fn ids_into(known: &mut BTreeMap<String, u32>, next: &mut u32, rows: &[Row]) {
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
let mut keys: Vec<String> = rows
.iter()
.map(|r| group_key(r.backend, r.generation))
.collect();
keys.sort();
keys.dedup();
assign_group_ids(known, next, &keys);
@@ -659,7 +666,7 @@ mod pool {
#[test]
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
let ran = Arc::new(AtomicBool::new(false));
// Two KWin displays in one group; the first (gen 1) carries the group's restore.
// Two KWin displays in one group; the first (generation 1) carries the group's restore.
let mut pool = vec![
test_entry("kwin", 1, Some(flag_restore(&ran))),
test_entry("kwin", 2, None),
@@ -697,7 +704,7 @@ mod pool {
#[test]
fn tearing_down_a_non_carrier_first_leaves_the_restore_for_last() {
let ran = Arc::new(AtomicBool::new(false));
// gen 2 carries the restore; gen 1 does not (a later exclusive session found the physical
// generation 2 carries the restore; generation 1 does not (a later exclusive session found the physical
// already disabled).
let mut pool = vec![
test_entry("kwin", 1, None),
@@ -706,7 +713,7 @@ mod pool {
// Tear down the non-carrier first → nothing to hand off, carrier untouched.
let mut e1 = pool.remove(0);
assert!(hand_off_restore(&mut pool, "kwin", 1, e1.topology_restore.take()).is_none());
// The carrier (gen 2) still holds the group's restore.
// The carrier (generation 2) still holds the group's restore.
assert!(pool[0].topology_restore.is_some());
// Now the carrier (last member) → run.
let mut e2 = pool.remove(0);
@@ -755,9 +762,9 @@ mod pool {
assert!(in_group("kwin", 3, "kwin", 2, Some(1)));
}
fn row(gen: u64, backend: &'static str, w: u32, slot: Option<u32>) -> Row {
fn row(generation: u64, backend: &'static str, w: u32, slot: Option<u32>) -> Row {
Row {
gen,
generation,
backend,
mode: Mode {
width: w,
@@ -773,7 +780,7 @@ mod pool {
#[test]
fn groups_by_backend_and_auto_rows_in_acquire_order() {
// Two KWin displays (acquired gen 5 then gen 2 — deliberately out of vec order) + a Mutter one.
// Two KWin displays (acquired generation 5 then generation 2 — deliberately out of vec order) + a Mutter one.
let rows = vec![
row(5, "kwin", 2560, Some(1)),
row(2, "kwin", 1920, Some(7)),
@@ -784,12 +791,12 @@ mod pool {
let kwin: Vec<&DisplayInfo> = out.iter().filter(|d| d.backend == "kwin").collect();
assert_eq!(kwin.len(), 2);
assert_eq!(kwin[0].slot, 2); // lower gen (earlier acquire) sorts to index 0
assert_eq!(kwin[0].slot, 2); // lower generation (earlier acquire) sorts to index 0
assert_eq!(kwin[0].display_index, 0);
assert_eq!(kwin[0].position, (0, 0));
assert_eq!(kwin[1].slot, 5);
assert_eq!(kwin[1].display_index, 1);
assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px gen-2 display
assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px generation-2 display
assert_eq!(kwin[0].topology, "exclusive");
// A distinct backend is a distinct group.
@@ -830,7 +837,7 @@ mod pool {
identity_slot: slot,
width: w,
};
// Existing group (given out of gen order): gen 8 @ 1920 acquired AFTER gen 3 @ 2560.
// Existing group (given out of generation order): generation 8 @ 1920 acquired AFTER generation 3 @ 2560.
let existing = vec![(8, m(Some(2), 1920)), (3, m(Some(1), 2560))];
// A new 1280-wide display appends to the right of 2560 + 1920.
let pos = position_for_new(existing, m(Some(5), 1280), &Layout::default());
@@ -900,30 +907,30 @@ mod pool {
use std::time::Duration;
let t0 = Instant::now();
let mut es = Vec::new();
// gen 1: lingering, deadline passed.
// generation 1: lingering, deadline passed.
let mut e1 = test_entry("kwin", 1, None);
e1.life = lifecycle::State::Lingering {
until: t0 - Duration::from_millis(1),
};
es.push(e1);
// gen 2: lingering, deadline in the future, current epoch → survives.
// generation 2: lingering, deadline in the future, current epoch → survives.
let mut e2 = test_entry("kwin", 2, None);
e2.life = lifecycle::State::Lingering {
until: t0 + Duration::from_secs(60),
};
e2.epoch = 5;
es.push(e2);
// gen 3: pinned, but from a DEAD epoch → reaped (its compositor is gone).
// generation 3: pinned, but from a DEAD epoch → reaped (its compositor is gone).
let mut e3 = test_entry("kwin", 3, None);
e3.life = lifecycle::State::Pinned;
e3.epoch = 4;
es.push(e3);
// gen 4: ACTIVE from a dead epoch → left to its own session's rebuild.
// generation 4: ACTIVE from a dead epoch → left to its own session's rebuild.
let mut e4 = test_entry("kwin", 4, None);
e4.life = lifecycle::State::Active { refs: 1 };
e4.epoch = 4;
es.push(e4);
// gen 5: a gamescope spawn from a "dead" epoch — exempt (independent nested session).
// generation 5: a gamescope spawn from a "dead" epoch — exempt (independent nested session).
let mut e5 = test_entry("gamescope", 5, None);
e5.life = lifecycle::State::Pinned;
e5.epoch = 1;
@@ -931,9 +938,9 @@ mod pool {
let (expired, restores) = take_expired(&mut es, t0, 5);
assert!(restores.is_empty());
let gone: Vec<u64> = expired.iter().map(|e| e.gen).collect();
let gone: Vec<u64> = expired.iter().map(|e| e.generation).collect();
assert_eq!(gone, vec![1, 3]);
let left: Vec<u64> = es.iter().map(|e| e.gen).collect();
let left: Vec<u64> = es.iter().map(|e| e.generation).collect();
assert_eq!(left, vec![2, 4, 5]);
}
}
@@ -974,7 +981,7 @@ mod linux {
struct Reg {
entries: Mutex<Vec<Entry>>,
gen: AtomicU64,
generation: AtomicU64,
}
static REG: OnceLock<Reg> = OnceLock::new();
@@ -982,7 +989,7 @@ mod linux {
fn reg() -> &'static Reg {
REG.get_or_init(|| Reg {
entries: Mutex::new(Vec::new()),
gen: AtomicU64::new(1),
generation: AtomicU64::new(1),
})
}
@@ -1029,25 +1036,27 @@ mod linux {
}
match std::thread::Builder::new()
.name("vdisplay-linger".into())
.spawn(|| loop {
std::thread::sleep(Duration::from_millis(500));
let (expired, restores) = {
let mut es = reg().entries.lock().unwrap();
take_expired(&mut es, Instant::now(), crate::session_epoch())
};
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
for restore in restores {
restore();
.spawn(|| {
loop {
std::thread::sleep(Duration::from_millis(500));
let (expired, restores) = {
let mut es = reg().entries.lock().unwrap();
take_expired(&mut es, Instant::now(), crate::session_epoch())
};
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
for restore in restores {
restore();
}
let reaped = expired.len();
for e in expired {
tracing::info!(
backend = e.backend,
"virtual display: linger expired — torn down"
);
drop(e); // outside the lock
}
emit_released(reaped);
}
let reaped = expired.len();
for e in expired {
tracing::info!(
backend = e.backend,
"virtual display: linger expired — torn down"
);
drop(e); // outside the lock
}
emit_released(reaped);
}) {
Ok(_) => *started = true,
Err(e) => tracing::error!(
@@ -1069,27 +1078,27 @@ mod linux {
}
}
/// Build the session-facing [`VirtualOutput`]: the kept node + a fresh gen-stamped lease. Only
/// Build the session-facing [`VirtualOutput`]: the kept node + a fresh generation-stamped lease. Only
/// the poolable (`remote_fd == None`) backends reach here, so `remote_fd` is always `None`.
fn output_for(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
gen: u64,
generation: u64,
quit: Arc<AtomicBool>,
reused: bool,
) -> VirtualOutput {
// The pooled display is registry-owned; the session holds a gen-stamped lease as its keepalive.
// The pooled display is registry-owned; the session holds a generation-stamped lease as its keepalive.
let mut out = VirtualOutput::owned(
node_id,
preferred_mode,
Box::new(DisplayLease { gen, quit }),
Box::new(DisplayLease { generation, quit }),
);
// A2: tell the pipeline builder this was a REUSED kept display, so a first-frame failure can
// `mark_failed(gen)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
out.reused_gen = reused.then_some(gen);
// H4: every pooled display carries its gen, so a mode-switch rebuild can `retire` the entry
// `mark_failed(generation)` (tear the corpse down) rather than re-wedge the retry loop on the same node.
out.reused_gen = reused.then_some(generation);
// H4: every pooled display carries its generation, so a mode-switch rebuild can `retire` the entry
// this output's successor supersedes.
out.pool_gen = Some(gen);
out.pool_gen = Some(generation);
out
}
@@ -1129,9 +1138,9 @@ mod linux {
// gamescope spawns are independent nested sessions, exempt from the active-session epoch —
// see `epoch_matches`). The liveness probe (`kept_display_alive`, which may shell `pw-dump`
// for gamescope) must NOT run under the pool lock (it can block / hang the daemon), so:
// 1. find the candidate + snapshot (gen, node_id) UNDER the lock, then release it;
// 1. find the candidate + snapshot (generation, node_id) UNDER the lock, then release it;
// 2. probe liveness OUTSIDE the lock;
// 3. re-lock and re-find the SAME entry by its gen (another thread may have reused/removed
// 3. re-lock and re-find the SAME entry by its generation (another thread may have reused/removed
// it meanwhile — then we just miss and create fresh).
let candidate = {
let es = r.entries.lock().unwrap();
@@ -1147,16 +1156,16 @@ mod linux {
&& e.hdr == vd.hdr()
&& epoch_matches(e.backend, e.epoch, cur_epoch)
})
.map(|e| (e.gen, e.node_id))
.map(|e| (e.generation, e.node_id))
};
if let Some((cand_gen, node_id)) = candidate {
let alive = vd.kept_display_alive(node_id); // OUTSIDE the lock (may block)
let reuse = {
let mut es = r.entries.lock().unwrap();
// Re-find the SAME entry by its snapshot gen; skip if it's gone or no longer kept
// Re-find the SAME entry by its snapshot generation; skip if it's gone or no longer kept
// (a concurrent reconnect adopted it) — we then miss and create fresh.
match es.iter().position(|e| {
e.gen == cand_gen
e.generation == cand_gen
&& matches!(
e.life,
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
@@ -1164,8 +1173,8 @@ mod linux {
}) {
Some(idx) if alive => {
es[idx].life.acquire();
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
es[idx].gen = gen;
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
es[idx].generation = generation;
let preferred_mode = es[idx].preferred_mode;
tracing::info!(
backend,
@@ -1175,7 +1184,7 @@ mod linux {
ReuseOutcome::Reused(output_for(
node_id,
preferred_mode,
gen,
generation,
quit.clone(),
true,
))
@@ -1183,7 +1192,7 @@ mod linux {
Some(idx) => {
// Dead kept display: remove it, hand off its group restore, create fresh.
let mut dead = es.remove(idx);
let (b, g) = (dead.backend, dead.gen);
let (b, g) = (dead.backend, dead.generation);
let restore =
hand_off_restore(&mut es, b, g, dead.topology_restore.take());
ReuseOutcome::Dead(dead, restore)
@@ -1213,9 +1222,9 @@ mod linux {
// The new display's generation stamp, taken BEFORE the group questions below: it is this
// display's identity for the rest of the acquire, and `group_key` needs it — a gamescope
// spawn's group IS its gen. A gen burned by a failed create is harmless (they are opaque
// spawn's group IS its generation. A generation burned by a failed create is harmless (they are opaque
// and monotonic, never an index).
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
let generation = r.generation.fetch_add(1, Ordering::Relaxed);
// NOTE: the operator's `max_displays` ceiling is NOT enforced here. It belongs at
// admission (`admission::admit`), which is where Windows has always applied it, and the
@@ -1240,7 +1249,7 @@ mod linux {
let first_in_group = {
let es = r.entries.lock().unwrap();
!es.iter().any(|e| {
in_group(e.backend, e.gen, backend, gen, supersedes)
in_group(e.backend, e.generation, backend, generation, supersedes)
&& matches!(e.life, lifecycle::State::Active { .. })
})
};
@@ -1292,7 +1301,7 @@ mod linux {
topology_restore,
launch: launch.clone(),
epoch: cur_epoch,
gen,
generation,
hw_cursor: vd.hw_cursor(),
hdr: vd.hdr(),
};
@@ -1314,10 +1323,10 @@ mod linux {
// width to the right on every mode switch.
let existing: Vec<(u64, Member)> = es
.iter()
.filter(|e| in_group(e.backend, e.gen, backend, gen, supersedes))
.filter(|e| in_group(e.backend, e.generation, backend, generation, supersedes))
.map(|e| {
(
e.gen,
e.generation,
Member {
identity_slot: e.identity_slot,
width: e.mode.width as i32,
@@ -1340,7 +1349,7 @@ mod linux {
if (position.x, position.y) != (0, 0) {
vd.apply_position(position.x, position.y);
}
let mut out = output_for(node_id, preferred_mode, gen, quit, false);
let mut out = output_for(node_id, preferred_mode, generation, quit, false);
out.expect_exact_dims = expect_exact_dims;
Ok(out)
}
@@ -1348,18 +1357,18 @@ mod linux {
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
/// the lock is released.
fn release(gen: u64, force_immediate: bool) {
fn release(generation: u64, force_immediate: bool) {
let Some(r) = REG.get() else { return };
let linger = effective_linger(force_immediate, linger());
let (torn_down, restore) = {
let mut es = r.entries.lock().unwrap();
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
let Some(idx) = es.iter().position(|e| e.generation == generation) else {
return; // stale lease (entry reused + re-stamped, or already gone) — no-op
};
match es[idx].life.release(Instant::now(), linger) {
Release::Teardown => {
let mut e = es.remove(idx);
let (backend, g) = (e.backend, e.gen);
let (backend, g) = (e.backend, e.generation);
// Per-group restore (§6.1): hand the physical re-enable to a surviving sibling, or run
// it now if this was the group's last member.
let restore = hand_off_restore(&mut es, backend, g, e.topology_restore.take());
@@ -1368,8 +1377,8 @@ mod linux {
// A release against a slot with NO live hold — a stale or duplicate lease drop. The
// machine's contract for it is "do nothing", and it was wired to the teardown arm:
// the one outcome that means the caller has no claim on this display would have torn
// the display down. Unreachable today (a lease's gen is unique per acquire and the
// lookup above is by gen, so a stale lease misses the pool entirely and returns
// the display down. Unreachable today (a lease's generation is unique per acquire and the
// lookup above is by generation, so a stale lease misses the pool entirely and returns
// early), which is exactly why it has to be right by construction rather than by
// luck — matching the Windows manager's own Noop arm.
Release::Noop => (None, None),
@@ -1434,7 +1443,7 @@ mod linux {
lifecycle::State::Idle => return None,
};
Some(Row {
gen: e.gen,
generation: e.generation,
backend: e.backend,
mode: e.mode,
identity_slot: e.identity_slot,
@@ -1455,7 +1464,10 @@ mod linux {
// Stable per-group ids, carried across polls (see `assign_group_ids`) — a new group must
// never renumber an existing one under the console. The state is process-lifetime and this
// is the only thing that touches it, so it lives here rather than in the pure core.
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
let mut keys: Vec<String> = rows
.iter()
.map(|r| group_key(r.backend, r.generation))
.collect();
keys.sort();
keys.dedup();
static GROUP_IDS: Mutex<Option<(std::collections::BTreeMap<String, u32>, u32)>> =
@@ -1475,13 +1487,13 @@ mod linux {
}
/// H4 — force-release a display superseded by a mid-stream mode switch. Same machinery as
/// [`force_release`] (kept entries only — an Active entry is refused, and a gen already torn
/// [`force_release`] (kept entries only — an Active entry is refused, and a generation already torn
/// down under `immediate` is a no-op), distinct log line.
pub(super) fn retire(gen: u64) {
release_kept(Some(gen), "retired (superseded by a mode switch)");
pub(super) fn retire(generation: u64) {
release_kept(Some(generation), "retired (superseded by a mode switch)");
}
/// Remove + tear down KEPT (lingering/pinned) entries — all of them, or one by gen — running /
/// Remove + tear down KEPT (lingering/pinned) entries — all of them, or one by generation — running /
/// handing off group topology restores, with keepalive drops outside the lock. The shared core
/// of [`force_release`] (mgmt) and [`retire`] (mode-switch supersede).
fn release_kept(slot: Option<u64>, why: &'static str) -> usize {
@@ -1492,10 +1504,10 @@ mod linux {
let mut restores = Vec::new();
let mut i = 0;
while i < es.len() {
let selected = slot.is_none_or(|s| es[i].gen == s);
let selected = slot.is_none_or(|s| es[i].generation == s);
if selected && es[i].life.force_release() {
let mut e = es.remove(i);
let (backend, g) = (e.backend, e.gen);
let (backend, g) = (e.backend, e.generation);
let restore = e.topology_restore.take();
if let Some(rst) = hand_off_restore(&mut es, backend, g, restore) {
restores.push(rst);
@@ -1522,15 +1534,15 @@ mod linux {
/// A2 — tear down a reused-but-dead pool entry by its generation stamp. Removes it (hand off /
/// run its group restore), drops the keepalive outside the lock. Idempotent (already gone → no-op).
pub(super) fn mark_failed(gen: u64) {
pub(super) fn mark_failed(generation: u64) {
let Some(r) = REG.get() else { return };
let (torn, restore) = {
let mut es = r.entries.lock().unwrap();
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
return; // already gone — the subsequent stale-gen lease drop no-ops too
let Some(idx) = es.iter().position(|e| e.generation == generation) else {
return; // already gone — the subsequent stale-generation lease drop no-ops too
};
let mut e = es.remove(idx);
let (backend, g) = (e.backend, e.gen);
let (backend, g) = (e.backend, e.generation);
let restore = hand_off_restore(&mut es, backend, g, e.topology_restore.take());
(e, restore)
};
@@ -1559,7 +1571,7 @@ mod linux {
while i < es.len() {
if es[i].backend == backend {
let mut e = es.remove(i);
let (b, g) = (e.backend, e.gen);
let (b, g) = (e.backend, e.generation);
if let Some(rst) = hand_off_restore(&mut es, b, g, e.topology_restore.take()) {
restores.push(rst);
}
@@ -1591,7 +1603,7 @@ mod linux {
/// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
struct DisplayLease {
gen: u64,
generation: u64,
/// The session's deliberate-quit flag: set when the client closes with the quit application
/// code (a user "stop", not a network drop), so this lease's `Drop` tears the display down
/// immediately instead of lingering. `false` on a bare disconnect → normal keep-alive.
@@ -1600,7 +1612,7 @@ mod linux {
impl Drop for DisplayLease {
fn drop(&mut self) {
release(self.gen, self.quit.load(Ordering::SeqCst));
release(self.generation, self.quit.load(Ordering::SeqCst));
}
}
}
+11 -4
View File
@@ -161,7 +161,10 @@ pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) -> Option<Gam
// injector as sway/river, no code change.
Compositor::Wlroots | Compositor::Hyprland => "wlr",
};
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (lib.rs) serializing
// every process-env writer on the session-setup path; steady-state threads read cached
// config, not the environment.
unsafe { std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend) };
drop(_env_guard);
resolve_gamescope_route(chosen, dedicated_launch)
}
@@ -464,11 +467,15 @@ mod tests {
use super::operator_gamescope;
let first = operator_gamescope();
let restore = crate::with_env_lock(|| std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE"));
crate::with_env_lock(|| std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto"));
// SAFETY: both mutations run under `with_env_lock` — the crate's env-writer
// serialization (ENV_LOCK, lib.rs); the readers under test sample once at startup.
crate::with_env_lock(|| unsafe { std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto") });
let second = operator_gamescope();
crate::with_env_lock(|| match &restore {
Some(v) => std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", v),
None => std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE"),
// SAFETY: as above — the restore also runs under the same env-writer lock.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE") },
});
assert_eq!(
second.node, first.node,
+43 -35
View File
@@ -585,41 +585,49 @@ fn find_wayland_socket(env: &EnvProbe, runtime: &str, uid: u32) -> Option<String
pub fn apply_session_env(active: &ActiveSession) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let e = &active.env;
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
if let Some(w) = &e.wayland_display {
std::env::set_var("WAYLAND_DISPLAY", w);
}
if let Some(d) = &e.xdg_current_desktop {
std::env::set_var("XDG_CURRENT_DESKTOP", d);
}
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live compositor
// (fixes G4 for the systemd `--user` host, which never inherited it). Only set when detection
// found a Hyprland session; a stale value from a previous connect is cleared otherwise so a
// Hyprland→sway switch can't leave `hyprctl` pointed at a dead instance.
match &e.hyprland_signature {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
}
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the capture
// chooser) is unreachable without it, so a systemd `--user` host that never inherited the login
// environment had no sway backend at all. Cleared when nothing sway-shaped is live, so a
// sway→Hyprland switch can't leave `swaymsg` aimed at a dead socket. `wlroots::is_available()`
// keys off this variable, so setting it here is also what makes the backend visible at all.
match &e.sway_socket {
Some(sock) => std::env::set_var("SWAYSOCK", sock),
None => std::env::remove_var("SWAYSOCK"),
}
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
// `mutter::is_available()` true, so a client's explicit backend request routed into the dead
// session — 45 s create timeouts and a libei error loop instead of the crisp "no live
// graphical session" handshake error. Clear them so `available()` reports the truth and the
// client fails fast (and, when configured, `try_recover_session` can bring the desktop back).
if active.kind == ActiveKind::None {
std::env::remove_var("XDG_CURRENT_DESKTOP");
std::env::remove_var("WAYLAND_DISPLAY");
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (see its doc in lib.rs)
// that serializes every process-env writer on the session-setup path; steady-state streaming
// threads read cached config, not the environment (security-review 2026-06-28 #7).
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
if let Some(w) = &e.wayland_display {
std::env::set_var("WAYLAND_DISPLAY", w);
}
if let Some(d) = &e.xdg_current_desktop {
std::env::set_var("XDG_CURRENT_DESKTOP", d);
}
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live
// compositor (fixes G4 for the systemd `--user` host, which never inherited it). Only set
// when detection found a Hyprland session; a stale value from a previous connect is
// cleared otherwise so a Hyprland→sway switch can't leave `hyprctl` pointed at a dead
// instance.
match &e.hyprland_signature {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
}
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the
// capture chooser) is unreachable without it, so a systemd `--user` host that never
// inherited the login environment had no sway backend at all. Cleared when nothing
// sway-shaped is live, so a sway→Hyprland switch can't leave `swaymsg` aimed at a dead
// socket. `wlroots::is_available()` keys off this variable, so setting it here is also
// what makes the backend visible at all.
match &e.sway_socket {
Some(sock) => std::env::set_var("SWAYSOCK", sock),
None => std::env::remove_var("SWAYSOCK"),
}
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
// `mutter::is_available()` true, so a client's explicit backend request routed into the
// dead session — 45 s create timeouts and a libei error loop instead of the crisp "no
// live graphical session" handshake error. Clear them so `available()` reports the truth
// and the client fails fast (and, when configured, `try_recover_session` can bring the
// desktop back).
if active.kind == ActiveKind::None {
std::env::remove_var("XDG_CURRENT_DESKTOP");
std::env::remove_var("WAYLAND_DISPLAY");
}
}
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// [`effective_topology`] directly at create time — the console policy, else the legacy
@@ -112,8 +112,8 @@ struct Monitor {
/// This monitor's desktop-space origin from the group layout (`(0,0)` until a multi-slot
/// arrangement places it) — reported via [`ManagedInfo`].
position: (i32, i32),
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
gen: u64,
/// Generation stamp; a [`MonitorLease`] only releases if its generation still matches (stale-lease no-op).
generation: u64,
}
impl Monitor {
@@ -178,6 +178,10 @@ struct GroupState {
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at
/// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore.
pnp_disabled: Vec<String>,
/// Whether the EXPERIMENTAL `edid_lock` axis pinned AMD connector emulation at the group's
/// first isolate (`pf_win_display::adl_emul::lock_for_stream`) — last-member teardown owes the
/// unlock (pinned emulation outlives the process, so a crash journal backs this flag up).
edid_locked: bool,
/// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also
/// snapshots but deliberately keeps the physical displays active) — gates the re-assert
/// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore.
@@ -367,10 +371,10 @@ struct MgrInner {
}
impl MgrInner {
/// Live target ids in acquire (gen) order — the CCD isolate keep-set + the layout member order.
/// Live target ids in acquire (generation) order — the CCD isolate keep-set + the layout member order.
fn target_ids(&self) -> Vec<u32> {
let mut mons: Vec<&Monitor> = self.slots.values().map(SlotState::mon).collect();
mons.sort_by_key(|m| m.gen);
mons.sort_by_key(|m| m.generation);
mons.iter().map(|m| m.target_id).collect()
}
}
@@ -425,7 +429,7 @@ pub struct VirtualDisplayManager {
/// fallback. One attempt per process, in case a future OS build honors the refresh.
update_modes_futile: AtomicBool,
/// Monotonic lease-generation counter (was the `MON_GEN` global).
gen: AtomicU64,
generation: AtomicU64,
state: Mutex<MgrInner>,
/// Serializes IDD-push session SETUP (preempt + monitor create) — MANAGER-WIDE even with slots:
/// monitor create/teardown stays serialized (the 400 ms async-departure settle and the IddCx
@@ -473,7 +477,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
watchdog_s: AtomicU32::new(3),
driver_proto: AtomicU32::new(0),
update_modes_futile: AtomicBool::new(false),
gen: AtomicU64::new(1),
generation: AtomicU64::new(1),
state: Mutex::new(MgrInner::default()),
setup_lock: Mutex::new(()),
idd_session_stops: Mutex::new(std::collections::HashMap::new()),
@@ -700,7 +704,7 @@ impl VirtualDisplayManager {
// IDD-push: a new connection while THIS SLOT's monitor is kept (LINGERING or PINNED) is a
// single-client RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is
// DEAD, so reusing it hands a black screen — PREEMPT: tear the kept monitor down and create a
// fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. A SIBLING
// fresh one. The old session's lease is generation-stamped, so its later drop is a no-op. A SIBLING
// slot's kept monitor is never touched — that's another client's display.
//
// ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the
@@ -746,7 +750,7 @@ impl VirtualDisplayManager {
// hand the rebuild the dead monitor's target (stale wudf_pid) and starve it to the rebuild
// budget. Preempt instead: best-effort teardown (REMOVE fails harmlessly on a dead/retired
// device) and fall through to a fresh create on the auto-restarted device. Held leases are
// gen-stamped, so their eventual release is a no-op. ONE WUDFHost hosts every slot's
// generation-stamped, so their eventual release is a no-op. ONE WUDFHost hosts every slot's
// publisher, so its death is ALL-slot shared fate — but each sibling's session fails through
// its own capturer watch and rebuilds through this same path; no cross-slot teardown here.
if matches!(inner.slots.get(&slot), Some(SlotState::Active { mon, .. }) if !wudf_alive(mon.wudf_pid))
@@ -807,7 +811,7 @@ impl VirtualDisplayManager {
match unsafe { self.resize_in_place(dev_raw(&dev), mon, mode) } {
Ok(()) => {
// Same join semantics as the re-arrival: +1 ref for the new
// (build-then-drop overlap) lease; `gen` untouched, so the old
// (build-then-drop overlap) lease; `generation` untouched, so the old
// session's lease stays valid.
*refs += 1;
let refs = *refs;
@@ -858,7 +862,7 @@ impl VirtualDisplayManager {
// acked the switch; Fix 2's corrective ack tells the client the resolution
// did not change). Store the RECOVERED monitor, not the one handed in:
// that one's driver monitor was REMOVEd before the failed ADD, so its
// `key`/`target_id`/`gdi_name` are all dead. `gen`/`refs` are preserved,
// `key`/`target_id`/`gdi_name` are all dead. `generation`/`refs` are preserved,
// so leases stay valid either way.
inner.slots.insert(
slot,
@@ -875,7 +879,7 @@ impl VirtualDisplayManager {
return Err(err).context("mid-stream resize re-arrival (slot left empty)");
}
};
// `re_add` preserved `gen`, so both the old session's lease and this new one match on
// `re_add` preserved `generation`, so both the old session's lease and this new one match on
// release. +1 ref for the new (build-then-drop overlap) lease.
let out = self.output_for(slot, &new_mon, quit);
inner.slots.insert(
@@ -965,7 +969,7 @@ impl VirtualDisplayManager {
Ok(out)
}
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for
/// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh generation-stamped lease) for
/// `mon` in `slot`. `quit` is the session's deliberate-quit flag, read by the lease `Drop` (see
/// [`Self::release`]).
fn output_for(
@@ -981,7 +985,7 @@ impl VirtualDisplayManager {
keepalive: Box::new(MonitorLease {
mgr: self,
slot,
gen: mon.gen,
generation: mon.generation,
quit,
}),
// The Windows manager owns the monitor lifecycle (refcount/linger/pin), so the registry
@@ -1211,18 +1215,18 @@ impl VirtualDisplayManager {
return;
}
let layout_policy = crate::policy::prefs().get().effective().layout;
// Members in acquire (gen) order — the auto-row order; identity slot 0 = anonymous (no
// manual pin can address it, so it always auto-rows). `(slot, gen, target_id, width)`
// Members in acquire (generation) order — the auto-row order; identity slot 0 = anonymous (no
// manual pin can address it, so it always auto-rows). `(slot, generation, target_id, width)`
// copied out so the arrangement below can write back through `get_mut`.
let mut ordered: Vec<(u32, u64, u32, i32)> = inner
.slots
.iter()
.map(|(slot, s)| {
let m = s.mon();
(*slot, m.gen, m.target_id, m.mode.width as i32)
(*slot, m.generation, m.target_id, m.mode.width as i32)
})
.collect();
ordered.sort_by_key(|&(_, gen, _, _)| gen);
ordered.sort_by_key(|&(_, generation, _, _)| generation);
let members: Vec<Member> = ordered
.iter()
.map(|&(slot, _, _, width)| Member {
@@ -1400,6 +1404,18 @@ impl VirtualDisplayManager {
if crate::policy::prefs().ddc_power_off() {
inner.group.ddc_panels_off = crate::ddc::panel_off_except(n);
}
// EXPERIMENTAL `edid_lock` policy axis (AMD only): pin connector EDID
// emulation BEFORE the isolate deactivates the physicals — an awake
// sink still answers the live-EDID read the lock pins (asleep sinks
// fall back to the driver's stored emulation data). With emulation at
// ADL_EMUL_MODE_ALWAYS the KMD stops servicing the sleeping sink's
// HPD/DDC/link — the standby-sink stall class at its source
// (rationale + crash journal in `pf_win_display::adl_emul`). First
// member only, like the DDC leg: the connectors are host-wide.
if crate::policy::prefs().edid_lock() {
inner.group.edid_locked =
pf_win_display::adl_emul::lock_for_stream();
}
inner.group.ccd_saved = isolate_displays_ccd_seam(&keep);
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
// additionally disable the deactivated monitors' PnP devnodes (persistent
@@ -1553,7 +1569,7 @@ impl VirtualDisplayManager {
requested_mode,
resolved_monitor_id: added.resolved_monitor_id,
position: (0, 0),
gen: self.gen.fetch_add(1, Ordering::Relaxed),
generation: self.generation.fetch_add(1, Ordering::Relaxed),
hw_cursor,
cursor_excluded: added.cursor_excluded,
})
@@ -1677,7 +1693,7 @@ impl VirtualDisplayManager {
/// / ContainerId) so the OS keeps the monitor's identity + saved per-monitor DPI. The visible cost
/// is one monitor hotplug per switch (the design's accepted "re-arrival for everything").
///
/// Refcount/lease continuity: the rebuilt `Monitor` PRESERVES the old `gen`, so the outstanding
/// Refcount/lease continuity: the rebuilt `Monitor` PRESERVES the old `generation`, so the outstanding
/// session lease(s) still match on release — the linger/refcount machine is untouched. The group
/// restore snapshot (`group.ccd_saved` / DDC / PnP) is likewise PRESERVED (a mid-session swap, not
/// a first-member create): [`reisolate_after_swap`](Self::reisolate_after_swap) re-isolates the new
@@ -1821,8 +1837,8 @@ impl VirtualDisplayManager {
added.target_id
),
}
// 5. Rebuild the Monitor from the ADD reply, PRESERVING `gen` (lease/refcount continuity) and
// the group-layout `position`. A fresh `gen` would strand the old session's lease release.
// 5. Rebuild the Monitor from the ADD reply, PRESERVING `generation` (lease/refcount continuity) and
// the group-layout `position`. A fresh `generation` would strand the old session's lease release.
let mon = Box::new(Monitor {
key: added.key,
target_id: added.target_id,
@@ -1834,7 +1850,7 @@ impl VirtualDisplayManager {
requested_mode,
resolved_monitor_id: added.resolved_monitor_id,
position: old.position,
gen: old.gen,
generation: old.generation,
hw_cursor: old.hw_cursor,
// Fresh from THIS reply, not `old`: the driver's per-target declare registry is the
// ground truth (this session may itself have declared since the original ADD).
@@ -1958,6 +1974,15 @@ impl VirtualDisplayManager {
);
inner.group.ddc_panels_off = 0;
}
// EXPERIMENTAL `edid_lock` unlock. AFTER the CCD restore + DDC wake: the re-activated
// physical paths do not depend on it (the pinned emulation IS the real monitor's
// EDID), and unlocking last keeps the driver from re-probing the sinks mid-restore.
// OUTSIDE the `ccd_saved` gate for the same reason as the DDC wake above — the lock
// was applied BEFORE the isolate, whose snapshot capture can have failed.
if inner.group.edid_locked {
pf_win_display::adl_emul::unlock_after_stream();
inner.group.edid_locked = false;
}
} else {
match shrink_action(inner.group.ccd_exclusive, inner.group.ccd_saved.is_some()) {
// Re-issue the isolate over the shrunk set (defensive — the departing monitor's
@@ -2023,10 +2048,10 @@ impl VirtualDisplayManager {
/// deliberate quit still pins — only `/display/release` frees a pinned monitor. A STALE lease
/// (its monitor was preempted + recreated under it) is a no-op, so it can't tear down the
/// CURRENT monitor.
fn release(&self, slot: u32, gen: u64, quit_now: bool) {
fn release(&self, slot: u32, generation: u64, quit_now: bool) {
let mut inner = self.state.lock().unwrap();
let stale = match inner.slots.get(&slot) {
Some(s) => s.mon().gen != gen,
Some(s) => s.mon().generation != generation,
None => true,
};
if stale {
@@ -2135,7 +2160,7 @@ impl VirtualDisplayManager {
// HERE instead. This runs at most ONCE per session (we hold `setup_lock`), so —
// unlike preempting inside `acquire` — it does not reintroduce the per-retry churn.
// The next `acquire` then sees the slot empty and creates a fresh monitor; the stale
// session's gen-stamped lease release is a no-op.
// session's generation-stamped lease release is a no-op.
if let Some(dev) = self.device_handle() {
let mut inner = self.state.lock().unwrap();
let taken = match inner.slots.get(&slot) {
@@ -2195,38 +2220,40 @@ impl VirtualDisplayManager {
TIMER.call_once(|| {
thread::Builder::new()
.name("vdisplay-linger".into())
.spawn(move || loop {
thread::sleep(Duration::from_millis(500));
let Some(dev) = self.device_handle() else {
continue;
};
let mut g = self.state.lock().unwrap();
let now = Instant::now();
let expired: Vec<u32> = g
.slots
.iter()
.filter_map(|(slot, s)| {
matches!(s, SlotState::Lingering { until, .. } if now >= *until)
.then_some(*slot)
})
.collect();
for slot in expired {
if let Some(SlotState::Lingering { mon, .. }) = g.slots.remove(&slot) {
// Teardown UNDER the state lock. Dropping the lock first (the old shape)
// let a concurrent `acquire` see the slot empty and run its ADD + CCD
// isolate while this monitor's CCD-restore / REMOVE were still in flight
// — the late restore then de-isolated (or the REMOVE churn-rejected) the
// fresh session at the linger-expiry boundary. Holding the lock makes
// the racing acquire WAIT the few teardown seconds instead of failing
// its session. Lock order stays state → device (teardown's invalidate
// path), same as every other holder; the pinger takes only the device
// lock — no inversion.
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
// Arc from `self.device_handle()` is held across this call, so the
// handle stays open (a concurrent retire drops only the manager's
// reference; see `DeviceSlot`). `mon` was moved out of the map under
// the lock, so it is exclusively owned here.
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
.spawn(move || {
loop {
thread::sleep(Duration::from_millis(500));
let Some(dev) = self.device_handle() else {
continue;
};
let mut g = self.state.lock().unwrap();
let now = Instant::now();
let expired: Vec<u32> = g
.slots
.iter()
.filter_map(|(slot, s)| {
matches!(s, SlotState::Lingering { until, .. } if now >= *until)
.then_some(*slot)
})
.collect();
for slot in expired {
if let Some(SlotState::Lingering { mon, .. }) = g.slots.remove(&slot) {
// Teardown UNDER the state lock. Dropping the lock first (the old shape)
// let a concurrent `acquire` see the slot empty and run its ADD + CCD
// isolate while this monitor's CCD-restore / REMOVE were still in flight
// — the late restore then de-isolated (or the REMOVE churn-rejected) the
// fresh session at the linger-expiry boundary. Holding the lock makes
// the racing acquire WAIT the few teardown seconds instead of failing
// its session. Lock order stays state → device (teardown's invalidate
// path), same as every other holder; the pinger takes only the device
// lock — no inversion.
// SAFETY: `teardown_removed` requires a valid control handle; the `dev`
// Arc from `self.device_handle()` is held across this call, so the
// handle stays open (a concurrent retire drops only the manager's
// reference; see `DeviceSlot`). `mon` was moved out of the map under
// the lock, so it is exclusively owned here.
unsafe { self.teardown_removed(dev_raw(&dev), &mut g, mon) };
}
}
}
})
@@ -2240,7 +2267,7 @@ impl VirtualDisplayManager {
struct MonitorLease {
mgr: &'static VirtualDisplayManager,
slot: u32,
gen: u64,
generation: u64,
/// The session's deliberate-quit flag (the client closed with the QUIT application code — a user
/// "stop", not a network drop). Read at drop time: a quit release tears the monitor down NOW
/// instead of lingering, mirroring the Linux registry's `Linger::Immediate`. `None` = no signal
@@ -2251,7 +2278,7 @@ struct MonitorLease {
impl Drop for MonitorLease {
fn drop(&mut self) {
let quit_now = self.quit.as_ref().is_some_and(|q| q.load(Ordering::SeqCst));
self.mgr.release(self.slot, self.gen, quit_now);
self.mgr.release(self.slot, self.generation, quit_now);
}
}
@@ -2319,7 +2346,7 @@ pub(crate) struct ManagedInfo {
/// Live sessions holding the monitor.
pub sessions: u32,
/// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg.
pub gen: u64,
pub generation: u64,
/// The slot key: the client's stable identity slot (`1..=15`), or `0` = anonymous/auto.
pub slot_id: u32,
/// Desktop-space origin from the group layout (`(0,0)` for a single display).
@@ -2327,7 +2354,7 @@ pub(crate) struct ManagedInfo {
}
impl VirtualDisplayManager {
/// Snapshot the managed slots for the mgmt `/display/state` endpoint, in acquire (gen) order.
/// Snapshot the managed slots for the mgmt `/display/state` endpoint, in acquire (generation) order.
/// Empty when no slot lives.
pub(crate) fn snapshot(&self) -> Vec<ManagedInfo> {
let inner = self.state.lock().unwrap();
@@ -2351,20 +2378,20 @@ impl VirtualDisplayManager {
state,
expires_in_ms,
sessions,
gen: mon.gen,
generation: mon.generation,
slot_id: *slot,
position: mon.position,
}
})
.collect();
out.sort_by_key(|i| i.gen);
out.sort_by_key(|i| i.generation);
out
}
/// Force-tear-down kept (LINGERING **or** PINNED) monitors now (the `/display/release` endpoint) —
/// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8
/// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. `slot` selects one kept monitor
/// by its [`ManagedInfo::gen`] stamp; `None` releases every kept one. Active monitors are refused
/// by its [`ManagedInfo::generation`] stamp; `None` releases every kept one. Active monitors are refused
/// (stopping a live session is session management, not display management). Returns the number
/// released.
pub(crate) fn force_release(&self, slot: Option<u64>) -> usize {
@@ -2377,7 +2404,7 @@ impl VirtualDisplayManager {
.iter()
.filter_map(|(k, s)| match s {
SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }
if slot.is_none_or(|g| g == mon.gen) =>
if slot.is_none_or(|g| g == mon.generation) =>
{
Some(*k)
}
@@ -2409,7 +2436,7 @@ pub(crate) fn snapshot() -> Vec<ManagedInfo> {
.unwrap_or_default()
}
/// Force-release kept monitors now (`slot` = a [`ManagedInfo::gen`] stamp, `None` = all kept); `0`
/// Force-release kept monitors now (`slot` = a [`ManagedInfo::generation`] stamp, `None` = all kept); `0`
/// if nothing was kept (or the manager is uninitialised).
pub(crate) fn force_release(slot: Option<u64>) -> usize {
VDM.get().map(|m| m.force_release(slot)).unwrap_or(0)
+24 -10
View File
@@ -46,6 +46,7 @@ use pf_bitstream::h264::PlanError;
use pf_bitstream::h264::PlanWarning;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::derive_caps;
use crate::caps::query_h264_caps;
@@ -685,6 +686,9 @@ pub struct VkH264Decoder {
/// Session generation: bumped on every rebuild, stamped into frames.
generation: u64,
device_lost: bool,
/// The over-declared-level warning has fired (once per decoder — the condition
/// is a property of the stream's SPS, so repeating it per AU is noise).
level_clamp_warned: bool,
}
impl VkH264Decoder {
@@ -723,6 +727,7 @@ impl VkH264Decoder {
decoded: 0,
generation: 0,
device_lost: false,
level_clamp_warned: false,
})
}
@@ -1365,18 +1370,26 @@ impl VkH264Decoder {
unsafe { query_h264_caps(&self.dev, std_profile) }.map_err(VkDecodeError::from)?;
self.caps = Some((std_profile, derive_caps(&raw)?));
}
// The level gate: a stream above the device's maxLevelIdc is refused up
// front (within one codec the Std code points ascend with the level, so
// the comparison is numeric), never submitted on a hope. The ceiling came
// from an H.264 caps query, so it is compared against an H.264 code point
// — the pairing MaxLevelIdc's tag exists to keep honest.
// The declared level vs the device ceiling: a DECLARED level above
// `maxLevelIdc` is NOT a refusal — encoders over-claim levels in the wild
// (the H.265 twin carries the field evidence: AMF stamps the codec
// maximum). The stream's REAL demands are enforced where they are
// physical facts — coded extent and DPB depth, checked in
// `rebuild_state` — and the session's parameter sets are clamped to the
// ceiling (`SessionConfig::max_level_idc`) so the driver is never handed
// a level above its caps. The comparison stays within one codec's Std
// code space (`MaxLevelIdc`'s tag carries that argument).
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = level_to_std(plan.picture.level_idc);
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (Std code point {stream_level}) above the device's \
maxLevelIdc ({caps_max_level})"
)));
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
self.level_clamp_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an H.264 level above the device ceiling — the \
declared level is advisory (over-declared by some encoders); \
proceeding with the parameter sets clamped to the ceiling"
);
}
let coded = vk::Extent2D {
width: plan.picture.coded_width,
@@ -1488,6 +1501,7 @@ impl VkH264Decoder {
max_dpb_slots: required_slots,
max_active_references: (required_slots - 1).min(caps.max_active_references),
std_profile_idc: std_profile,
max_level_idc: caps.max_level_idc.code_point(),
};
let mut pool_plan = plan_pools(caps, required_slots);
// TEST-ONLY readback hook: the GPU parity test (tests/gpu_parity.rs)
+26 -10
View File
@@ -57,6 +57,7 @@ use pf_bitstream::h265::PlanError;
use pf_bitstream::h265::PlanWarning;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use crate::caps::DecodeCaps;
use crate::caps::DecodeProfile;
@@ -219,6 +220,9 @@ pub struct VkH265Decoder {
/// Recovery owed after a failed AU whose planning had already advanced
/// ([`RecoveryLatch`] docs for the whole argument).
recovery: RecoveryLatch,
/// The over-declared-level warning has fired (once per decoder — the condition
/// is a property of the stream's SPS, so repeating it per AU is noise).
level_clamp_warned: bool,
}
impl VkH265Decoder {
@@ -266,6 +270,7 @@ impl VkH265Decoder {
generation: 0,
device_lost: false,
recovery: RecoveryLatch::default(),
level_clamp_warned: false,
})
}
@@ -1004,18 +1009,28 @@ impl VkH265Decoder {
let raw = unsafe { query_h265_caps(&self.dev, key) }.map_err(VkDecodeError::from)?;
self.caps = Some((key, derive_caps_h265(&raw, wanted)?));
}
// The level gate: a stream above the device's maxLevelIdc is refused up
// front (within one codec the Std code points ascend with the level, so
// the comparison is numeric), never submitted on a hope. The ceiling came
// from an H.265 caps query, so it is compared against an H.265 code point
// — the pairing MaxLevelIdc's tag exists to keep honest.
// The declared level vs the device ceiling: a DECLARED level above
// `maxLevelIdc` is NOT a refusal. The level in an SPS is a claim, and
// encoders over-claim in the wild — AMF stamps 6.2 (the codec maximum)
// on 4K120 streams that need 5.2, which on an RTX 5060 (ceiling 6.1)
// demoted every HEVC session to D3D11VA (2026-08-12 field report). The
// stream's REAL demands are enforced where they are physical facts:
// coded extent and DPB depth, checked in `rebuild_state`. The session's
// parameter sets are clamped to the ceiling (`SessionConfigH265::
// max_level_idc`) so the driver is never handed a level above its caps,
// and the comparison stays within one codec's Std code space
// (`MaxLevelIdc`'s tag carries that argument).
let caps_max_level = self.caps.as_ref().expect("queried above").1.max_level_idc;
let stream_level = level_to_std_h265(plan.picture.level_idc);
if stream_level > caps_max_level.code_point() {
return Err(VkDecodeError::Unsupported(format!(
"stream level (Std code point {stream_level}) above the device's \
maxLevelIdc ({caps_max_level})"
)));
if stream_level > caps_max_level.code_point() && !self.level_clamp_warned {
self.level_clamp_warned = true;
warn!(
stream_level,
ceiling = %caps_max_level,
"stream declares an H.265 level above the device ceiling — the \
declared level is advisory (over-declared by some encoders); \
proceeding with the parameter sets clamped to the ceiling"
);
}
let coded = vk::Extent2D {
width: plan.picture.coded_width,
@@ -1108,6 +1123,7 @@ impl VkH265Decoder {
max_dpb_slots: required_slots,
max_active_references: (required_slots - 1).min(caps.max_active_references),
profile: key,
max_level_idc: caps.max_level_idc.code_point(),
};
let mut pool_plan = plan_pools(caps, required_slots);
// TEST-ONLY readback hook, exactly as the H.264 decoder's: the parity
+31
View File
@@ -115,6 +115,17 @@ impl OwnedStdSps {
pub fn std(&self) -> &hh::StdVideoH264SequenceParameterSet {
&self.std
}
/// Lower `level_idc` to `max` when the stream declares a higher one. The
/// declared level is a claim encoders over-state in the wild, and a set above
/// the device's `maxLevelIdc` is invalid usage; the stream's real demands are
/// enforced by the session's coded extent and DPB depth. The "no mutation"
/// contract above is about a LIVE object's blocks — this runs before handover.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH264LevelIdc) {
if self.std.level_idc > max {
self.std.level_idc = max;
}
}
}
/// The converted PPS plus the scaling-list allocation its `pScalingLists` targets.
@@ -831,4 +842,24 @@ mod tests {
ParamsError::InvalidWeightedBipredIdc(3)
);
}
/// The over-declared-level clamp ([`OwnedStdSps::clamp_level`]): lowering
/// writes the ceiling into the Std SPS; a ceiling at or above the declared
/// level changes nothing.
#[test]
fn clamp_level_lowers_and_only_lowers() {
let sps = full_sps();
let declared = level_to_std(sps.level_idc);
let mut owned = sps_to_std(&sps).unwrap();
assert_eq!(owned.std().level_idc, declared);
// A ceiling above the declared level is a no-op.
owned.clamp_level(hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2);
assert_eq!(owned.std().level_idc, declared);
// A ceiling below it is written through.
let ceiling = hh::StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1;
assert!(ceiling < declared, "fixture declares above 3.1");
owned.clamp_level(ceiling);
assert_eq!(owned.std().level_idc, ceiling);
}
}
+54
View File
@@ -202,6 +202,19 @@ impl OwnedStdH265Vps {
pub fn std(&self) -> &hh::StdVideoH265VideoParameterSet {
&self.std
}
/// Lower the profile/tier/level block's `general_level_idc` to `max` when the
/// stream declares a higher one. The declared level is a CLAIM, and encoders
/// over-claim in the wild (AMF stamps 6.2 — the codec maximum — on streams that
/// need 5.2); handing the driver a level above its `maxLevelIdc` is invalid
/// usage, while the stream's real demands are enforced by the session's coded
/// extent and DPB depth. The "no mutation" ownership contract is about blocks a
/// LIVE parameters object points at; this runs before the set is handed over.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
if self._ptl_backing.general_level_idc > max {
self._ptl_backing.general_level_idc = max;
}
}
}
/// The converted SPS plus the heap allocations its embedded pointers target.
@@ -229,6 +242,14 @@ impl OwnedStdH265Sps {
pub fn std(&self) -> &hh::StdVideoH265SequenceParameterSet {
&self.std
}
/// Lower `general_level_idc` to the device ceiling — [`OwnedStdH265Vps::clamp_level`]
/// carries the argument.
pub(crate) fn clamp_level(&mut self, max: hh::StdVideoH265LevelIdc) {
if self._ptl_backing.general_level_idc > max {
self._ptl_backing.general_level_idc = max;
}
}
}
/// The converted PPS plus the scaling-list allocation its `pScalingLists`
@@ -2000,4 +2021,37 @@ mod tests {
"the vector opens with VPS + SPS + PPS"
);
}
/// The over-declared-level clamp (the AMF 6.2-on-everything field case):
/// lowering writes the ceiling into the PTL backing the driver will read;
/// a ceiling at or above the declared level changes nothing.
#[test]
fn clamp_level_lowers_the_ptl_and_only_lowers() {
let sps = full_sps();
let declared = level_to_std(sps.profile_tier_level.general_level_idc);
let mut owned = sps_to_std_h265(&sps).unwrap();
// SAFETY: pProfileTierLevel targets `owned`'s boxed backing.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, declared);
// A ceiling above the declared level is a no-op.
owned.clamp_level(hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2);
// SAFETY: as above.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, declared);
// A ceiling below it is written through — and the pointer still targets
// the wrapper's own backing (the clamp mutates in place, never re-points).
let ceiling = hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1;
assert!(ceiling < declared, "fixture declares above 3.1");
owned.clamp_level(ceiling);
// SAFETY: as above.
let level = unsafe { (*owned.std().pProfileTierLevel).general_level_idc };
assert_eq!(level, ceiling);
let mut owned_vps = fallback_vps_from_sps(&sps).unwrap();
owned_vps.clamp_level(ceiling);
// SAFETY: as above, the VPS wrapper's own backing.
let vps_level = unsafe { (*owned_vps.std().pProfileTierLevel).general_level_idc };
assert!(vps_level <= ceiling);
}
}
+10 -2
View File
@@ -168,6 +168,10 @@ pub struct SessionConfig {
/// The Std profile the session was created against (a profile change is a
/// renegotiation too).
pub std_profile_idc: hh::StdVideoH264ProfileIdc,
/// The device's `maxLevelIdc` for this profile (Std code point). Every SPS
/// handed to the parameters object has its declared level clamped to this —
/// see `SessionConfigH265::max_level_idc` for the whole argument.
pub max_level_idc: hh::StdVideoH264LevelIdc,
}
/// Session creation/parameter failures the decoder maps into its error type.
@@ -593,11 +597,14 @@ impl VideoSession {
match action {
ParamsAction::Current => Ok(()),
ParamsAction::Add { add_sps, add_pps } => {
let owned_sps = if add_sps {
let mut owned_sps = if add_sps {
Some(sps_to_std(sps)?)
} else {
None
};
if let Some(s) = owned_sps.as_mut() {
s.clamp_level(self.config.max_level_idc);
}
let owned_pps = if add_pps {
Some(pps_to_std(pps)?)
} else {
@@ -643,7 +650,8 @@ impl VideoSession {
pps_id = pps.pic_parameter_set_id,
"recreating session parameters (content change or capacity)"
);
let owned_sps = sps_to_std(sps)?;
let mut owned_sps = sps_to_std(sps)?;
owned_sps.clamp_level(self.config.max_level_idc);
let owned_pps = pps_to_std(pps)?;
// SAFETY: fn contract — live device + live session. The wrappers
// are MOVED IN and come back owned by the fresh object, so they
+18 -4
View File
@@ -260,6 +260,12 @@ pub struct SessionConfigH265 {
/// format / bit depths, all four of which a stream can renegotiate (an SPS
/// switching Main→Main 10 mid-stream is a session rebuild, not an update).
pub profile: H265ProfileKey,
/// The device's `maxLevelIdc` for this profile (Std code point). Every VPS/SPS
/// handed to the parameters object has its declared level clamped to this —
/// over-declared levels are common (AMF stamps 6.2 on 4K streams) and a set
/// above the ceiling is invalid usage, while the stream's real demands are
/// already enforced by `max_coded_extent` / `max_dpb_slots`.
pub max_level_idc: hh::StdVideoH265LevelIdc,
}
/// A live parameters object **and every Std parameter set it was given**, in one
@@ -525,12 +531,18 @@ impl VideoSessionH265 {
} => {
// Every owned wrapper below stays alive until after the update
// call: the Std structs embed pointers into their heap blocks.
let owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
let owned_sps = if add_sps {
let mut owned_vps = if add_vps { Some(vps.to_std()?) } else { None };
let mut owned_sps = if add_sps {
Some(sps_to_std_h265(sps)?)
} else {
None
};
if let Some(v) = owned_vps.as_mut() {
v.clamp_level(self.config.max_level_idc);
}
if let Some(s) = owned_sps.as_mut() {
s.clamp_level(self.config.max_level_idc);
}
let owned_pps = if add_pps {
Some(pps_to_std_h265(pps)?)
} else {
@@ -582,8 +594,10 @@ impl VideoSessionH265 {
pps_id = pps.pic_parameter_set_id,
"recreating H.265 session parameters (content change or capacity)"
);
let owned_vps = vps.to_std()?;
let owned_sps = sps_to_std_h265(sps)?;
let mut owned_vps = vps.to_std()?;
let mut owned_sps = sps_to_std_h265(sps)?;
owned_vps.clamp_level(self.config.max_level_idc);
owned_sps.clamp_level(self.config.max_level_idc);
let owned_pps = pps_to_std_h265(pps)?;
// SAFETY: fn contract — live device + live session. The wrappers
// are MOVED IN and come back owned by the fresh object, so they
+1 -1
View File
@@ -268,7 +268,7 @@ fn flagged(flags: &[bool]) -> Vec<usize> {
flags
.iter()
.enumerate()
.filter(|(_, &d)| d)
.filter(|&(_, &d)| d)
.map(|(i, _)| i)
.collect()
}
+15 -5
View File
@@ -830,7 +830,9 @@ fn h264_parity_run_against(
// The decoder reads this at session creation (first decode call): pool
// images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal.
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let goldens = golden_hashes(goldens);
assert_eq!(
@@ -940,7 +942,9 @@ fn h265_parity_run(
// As the H.264 leg: one codec at a time, `set_var` under the lock.
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let goldens = golden_hashes(goldens_file);
assert_eq!(
@@ -1124,7 +1128,9 @@ fn av1_parity_run_against(
// happens only under this lock (see `common::gpu_lock`).
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let goldens = golden_hashes(goldens_file);
// Non-vacuity, before any hardware is touched: the right number of entries, all
@@ -1265,7 +1271,9 @@ fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() {
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
fn av1_frame0_pixels_say_which_plane_and_how_badly() {
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
assert_eq!(
@@ -1338,7 +1346,9 @@ const MAX_LOOP_FILTER_LEVEL: u8 = 63;
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
fn av1_frame0_probes_whether_the_driver_reads_the_chroma_deblocking_levels() {
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
// SAFETY: `_gpu` holds the binary-wide GPU lock (`common::gpu_lock`); the parity legs are
// this variable's only writers and readers, and they run one at a time under that lock.
unsafe { std::env::set_var("PF_VKD_TEST_READBACK", "1") };
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
assert_eq!(aus.len(), FRAME_COUNT);
+1 -1
View File
@@ -7,7 +7,7 @@
[package]
name = "pf-win-display"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk host Windows display-topology helpers: CCD/GDI mode-set + path activation, HDR advanced colour, PnP monitor devnodes, and the display-change event watch."
+692
View File
@@ -0,0 +1,692 @@
//! AMD ADL connector/EDID emulation — the shared implementation behind the `display-disturb
//! adl-emul` probe AND the `edid_lock` display-policy axis (the software equivalent of an
//! HPD-holding dummy plug).
//!
//! Three field cases (ASUS VG32VQ1B/DP, Odyssey G60SD/DP, LG UltraGear 32GS95UE/HDMI — all
//! RX 9070 XT hosts) share one mechanism: a connected-but-asleep sink whose standby HPD/DDC/link
//! servicing the KMD performs below every OS lever (CCD deactivation, devnode disable and CRU
//! EDID overrides are confirmed no-ops — `design/vdisplay-disturbance-immunity.md` §2a/§3). The
//! one software lever that can stop the servicing at its SOURCE is the driver's own connector
//! emulation: pin the live EDID with `ADL2_Adapter_ConnectionData_Set`, then
//! `ADL2_Adapter_EmulationMode_Set(ADL_EMUL_MODE_ALWAYS)` so the driver stops caring what the
//! physical pins report.
//!
//! [`run`] performs one action across every AMD adapter's connectors and returns the per-op
//! [`OpRecord`]s — the probe tool prints them as bench lines, the host tracing-logs them. The
//! `edid_lock` axis drives [`lock_for_stream`]/[`unlock_after_stream`] at the Exclusive isolate
//! (`pf-vdisplay`'s Windows manager), with a crash journal ([`startup_recover`]) because pinned
//! emulation persists across host restarts — and can persist across REBOOTS, so every lock ships
//! with its unlock (driver reinstall = the escape hatch of last resort).
//!
//! Everything is best-effort by design: no AMD driver (`atiadlxx.dll` absent) means the axis is
//! inert, and each rc is preserved so a field log answers the consumer-vs-Pro gating question
//! (`ADL_ERR_NOT_SUPPORTED(-8)` vs `ADL_OK`).
// FFI mirrors of ADL's C structs — keep AMD's field names verbatim so the header diff is
// mechanical.
#![allow(non_snake_case)]
use std::ffi::c_void;
use std::time::Instant;
use windows::core::{s, PCSTR};
use windows::Win32::Foundation::HMODULE;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryA};
// ---- ADL constants (adl_defines.h, GPUOpen display-library) ----
const ADL_OK: i32 = 0;
const ADL_MAX_PATH: usize = 256;
const ADL_MAX_DISPLAY_EDID_DATA_SIZE: usize = 1024;
const ADL_MAX_RAD_LINK_COUNT: usize = 15;
const ADL_EMUL_MODE_OFF: i32 = 0;
const ADL_EMUL_MODE_ALWAYS: i32 = 3;
const ADL_QUERY_REAL_DATA: i32 = 0;
const ADL_QUERY_EMULATED_DATA: i32 = 1;
const ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED: i32 = 0x1;
const ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT: i32 = 0x2;
const ADL_EMUL_STATUS_EMULATED_DEVICE_USED: i32 = 0x4;
const AMD_VENDOR_ID: i32 = 1002;
/// Decode the rc values a field log will actually contain (adl_defines.h) — `-8` vs `-1` is the
/// whole consumer-vs-Pro question, so spell them out.
pub fn rc_str(rc: i32) -> &'static str {
match rc {
0 => "ADL_OK",
1..=4 => "ADL_OK_(warning-class)",
-1 => "ADL_ERR",
-2 => "ADL_ERR_NOT_INIT",
-3 => "ADL_ERR_INVALID_PARAM",
-5 => "ADL_ERR_INVALID_ADL_IDX",
-8 => "ADL_ERR_NOT_SUPPORTED",
-9 => "ADL_ERR_NULL_POINTER",
-10 => "ADL_ERR_DISABLED_ADAPTER",
-22 => "ADL_ERR_CALL_TO_INCOMPATIABLE_DRIVER",
-23 => "ADL_ERR_NO_ADMINISTRATOR_PRIVILEGES",
_ => "?",
}
}
fn connector_type_str(t: i32) -> &'static str {
match t {
1 => "VGA",
2 => "DVI-D",
3 => "DVI-I",
8 => "HDMI-A",
9 => "HDMI-B",
10 => "DP",
11 => "eDP",
12 => "miniDP",
13 => "VIRTUAL",
14 => "USB-C",
_ => "unknown",
}
}
// ---- ADL structs (adl_structures.h, verbatim layouts) ----
#[repr(C)]
struct AdapterInfo {
iSize: i32,
iAdapterIndex: i32,
strUDID: [u8; ADL_MAX_PATH],
iBusNumber: i32,
iDeviceNumber: i32,
iFunctionNumber: i32,
iVendorID: i32,
strAdapterName: [u8; ADL_MAX_PATH],
strDisplayName: [u8; ADL_MAX_PATH],
iPresent: i32,
// _WIN32 tail — this tool only builds for Windows.
iExist: i32,
strDriverPath: [u8; ADL_MAX_PATH],
strDriverPathExt: [u8; ADL_MAX_PATH],
strPNPString: [u8; ADL_MAX_PATH],
iOSDisplayIndex: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLMSTRad {
iLinkNumber: i32,
rad: [u8; ADL_MAX_RAD_LINK_COUNT],
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLDevicePort {
iConnectorIndex: i32,
aMSTRad: ADLMSTRad,
}
impl ADLDevicePort {
/// A non-MST port at `connector` (MST RAD all-zero = "DP root / non-DP ignored" per header).
fn root(connector: i32) -> Self {
Self {
iConnectorIndex: connector,
aMSTRad: ADLMSTRad {
iLinkNumber: 0,
rad: [0; ADL_MAX_RAD_LINK_COUNT],
},
}
}
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectionProperties {
iValidProperties: i32,
iBitrate: i32,
iNumberOfLanes: i32,
iColorDepth: i32,
iStereo3DCaps: i32,
iOutputBandwidth: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectionData {
iConnectionType: i32,
aConnectionProperties: ADLConnectionProperties,
iNumberofPorts: i32,
iActiveConnections: i32,
iDataSize: i32,
EdidData: [u8; ADL_MAX_DISPLAY_EDID_DATA_SIZE],
}
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ADLConnectionState {
iEmulationStatus: i32,
iEmulationMode: i32,
iDisplayIndex: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct ADLConnectorInfo {
iConnectorIndex: i32,
iConnectorId: i32,
iSlotIndex: i32,
iType: i32,
iOffset: i32,
iLength: i32,
}
// ---- dynamic binding (atiadlxx.dll ships with every AMD driver; absent elsewhere) ----
type AdlContext = *mut c_void;
type MallocCb = unsafe extern "C" fn(i32) -> *mut c_void;
type FnMainCreate = unsafe extern "C" fn(MallocCb, i32, *mut AdlContext) -> i32;
type FnMainDestroy = unsafe extern "C" fn(AdlContext) -> i32;
type FnNumAdapters = unsafe extern "C" fn(AdlContext, *mut i32) -> i32;
type FnAdapterInfoGet = unsafe extern "C" fn(AdlContext, *mut AdapterInfo, i32) -> i32;
type FnEdidMgmtCaps = unsafe extern "C" fn(AdlContext, i32, *mut i32) -> i32;
type FnBoardLayoutGet = unsafe extern "C" fn(
AdlContext,
i32,
*mut i32,
*mut i32,
*mut *mut c_void,
*mut i32,
*mut *mut ADLConnectorInfo,
) -> i32;
type FnConnStateGet =
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, *mut ADLConnectionState) -> i32;
type FnConnDataGet =
unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32, *mut ADLConnectionData) -> i32;
type FnConnDataSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, ADLConnectionData) -> i32;
type FnConnDataRemove = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort) -> i32;
type FnEmulModeSet = unsafe extern "C" fn(AdlContext, i32, ADLDevicePort, i32) -> i32;
struct Adl {
create: FnMainCreate,
destroy: FnMainDestroy,
num_adapters: FnNumAdapters,
adapter_info: FnAdapterInfoGet,
edid_caps: FnEdidMgmtCaps,
board_layout: FnBoardLayoutGet,
conn_state: FnConnStateGet,
conn_data_get: FnConnDataGet,
conn_data_set: FnConnDataSet,
conn_data_remove: FnConnDataRemove,
emul_mode_set: FnEmulModeSet,
}
/// ADL's application-provided allocator: it hands buffers (board-layout arrays) back through
/// out-pointers and expects the app to own them.
unsafe extern "C" fn adl_malloc(size: i32) -> *mut c_void {
let size = size.max(1) as usize;
// Panic-free: a panic here would cross the extern boundary and abort the host. The Err arm
// is unreachable in practice (size ≤ i32::MAX can't overflow the layout), and ADL treats a
// null from its allocator as an ordinary failure.
let Ok(layout) = std::alloc::Layout::from_size_align(size, 16) else {
return std::ptr::null_mut();
};
// SAFETY: non-zero size with a fixed valid alignment; the resulting buffers are deliberately
// never freed — ADL's contract wants an ADL_Main_Memory_Free symmetry, and leaking the <1 KiB
// of board-layout arrays in a one-shot probe is simpler than proving allocator parity.
unsafe { std::alloc::alloc(layout) as *mut c_void }
}
impl Adl {
fn load() -> Option<Self> {
// SAFETY: plain LoadLibrary of the AMD-driver-installed ADL runtime by its well-known
// name; a foreign-DLL search-path attack would require writing to System32.
let lib: HMODULE = unsafe { LoadLibraryA(s!("atiadlxx.dll")) }.ok()?;
// One unsafe helper: resolve `name` or bail. Every Fn* type above matches the ADL
// header's C signature (x64 has a single calling convention, so `extern "C"` is exact).
unsafe fn sym<T: Copy>(lib: HMODULE, name: PCSTR) -> Option<T> {
debug_assert_eq!(std::mem::size_of::<T>(), std::mem::size_of::<usize>());
// SAFETY: caller passes a fn-pointer type T of pointer size (asserted above);
// GetProcAddress yields the export's address or None.
let f = unsafe { GetProcAddress(lib, name) }?;
// SAFETY: reinterpreting one non-null fn pointer as the export's true C signature.
Some(unsafe { std::mem::transmute_copy::<_, T>(&f) })
}
// SAFETY: `lib` is the live module handle from the successful load above.
unsafe {
Some(Self {
create: sym(lib, s!("ADL2_Main_Control_Create"))?,
destroy: sym(lib, s!("ADL2_Main_Control_Destroy"))?,
num_adapters: sym(lib, s!("ADL2_Adapter_NumberOfAdapters_Get"))?,
adapter_info: sym(lib, s!("ADL2_Adapter_AdapterInfo_Get"))?,
edid_caps: sym(lib, s!("ADL2_Adapter_EDIDManagement_Caps"))?,
board_layout: sym(lib, s!("ADL2_Adapter_BoardLayout_Get"))?,
conn_state: sym(lib, s!("ADL2_Adapter_ConnectionState_Get"))?,
conn_data_get: sym(lib, s!("ADL2_Adapter_ConnectionData_Get"))?,
conn_data_set: sym(lib, s!("ADL2_Adapter_ConnectionData_Set"))?,
conn_data_remove: sym(lib, s!("ADL2_Adapter_ConnectionData_Remove"))?,
emul_mode_set: sym(lib, s!("ADL2_Adapter_EmulationMode_Set"))?,
})
}
}
}
// ---- the library surface ----
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum EmulAction {
/// Read-only: caps + layout + per-connector state. Always safe.
Probe,
/// Pin live EDID + `ADL_EMUL_MODE_ALWAYS` on occupied (or named) connectors.
Lock,
/// `ADL_EMUL_MODE_OFF` + remove pinned EDID on all (or named) connectors.
Unlock,
}
/// One ADL call's outcome — op name, target, duration, rc (decoded via [`rc_str`]) and the
/// op-specific fields. The probe tool prints these as its bench correlation lines; the host
/// tracing-logs them. The rc IS the deliverable of a field run.
pub struct OpRecord {
pub op: &'static str,
pub target: String,
pub took_ms: u128,
pub rc: i32,
pub extra: String,
}
impl OpRecord {
pub fn ok(&self) -> bool {
self.rc == ADL_OK
}
}
impl std::fmt::Display for OpRecord {
/// The bench line minus the caller's epoch prefix: `op target took_ms ok rc=N(STR) extra`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let sep = if self.extra.is_empty() { "" } else { " " };
write!(
f,
"{} {} took_ms={} ok={} rc={}({}){sep}{}",
self.op,
self.target,
self.took_ms,
self.ok(),
self.rc,
rc_str(self.rc),
self.extra
)
}
}
/// How a [`run`] ended: the AMD runtime was absent entirely, died at init (nothing was touched),
/// or walked the connectors (each op's rc in the records — a NOT_SUPPORTED driver still `Done`s).
pub enum RunOutcome {
/// `atiadlxx.dll` not loadable (or an export missing) — not an AMD driver install; the
/// emulation lever does not exist on this box.
NoAdl,
/// `ADL2_Main_Control_Create` / adapter enumeration failed — records hold the failing rc.
InitFailed(Vec<OpRecord>),
/// The connector walk ran; every op's outcome is in the records.
Done(Vec<OpRecord>),
}
impl RunOutcome {
pub fn records(&self) -> &[OpRecord] {
match self {
RunOutcome::NoAdl => &[],
RunOutcome::InitFailed(r) | RunOutcome::Done(r) => r,
}
}
}
fn c_str(buf: &[u8]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf8_lossy(&buf[..len]).into_owned()
}
/// Perform `action` on every AMD adapter's connectors (or only `connector_filter`), returning the
/// per-op records. Read-only for [`EmulAction::Probe`]; [`EmulAction::Lock`] pins occupied
/// connectors only (unless the filter names one), matching an HPD dummy on the cables that exist.
pub fn run(action: EmulAction, connector_filter: Option<i32>) -> RunOutcome {
let mut recs: Vec<OpRecord> = Vec::new();
let mut rec = |op: &'static str, target: &str, took_ms: u128, rc: i32, extra: String| {
recs.push(OpRecord {
op,
target: target.to_owned(),
took_ms,
rc,
extra,
});
};
let Some(adl) = Adl::load() else {
return RunOutcome::NoAdl;
};
let mut ctx: AdlContext = std::ptr::null_mut();
let t = Instant::now();
// SAFETY: documented init call — our allocator callback, iEnumConnectedAdapters=0 (ALL
// adapters: an exclusively-isolated streaming host may report no "connected" display on the
// physical GPU), and a valid out-slot for the context.
let rc = unsafe { (adl.create)(adl_malloc, 0, &mut ctx) };
rec(
"adl-init",
"atiadlxx",
t.elapsed().as_millis(),
rc,
String::new(),
);
if rc != ADL_OK {
return RunOutcome::InitFailed(recs);
}
let mut count = 0i32;
// SAFETY: live context; valid out-param.
let rc = unsafe { (adl.num_adapters)(ctx, &mut count) };
if rc != ADL_OK || count <= 0 {
rec("adl-num-adapters", "all", 0, rc, format!("count={count}"));
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
let _ = unsafe { (adl.destroy)(ctx) };
return RunOutcome::InitFailed(recs);
}
let mut infos: Vec<AdapterInfo> = (0..count)
.map(|_| {
// SAFETY: AdapterInfo is plain ints + byte arrays — the all-zero pattern is valid,
// and ADL fills the array in place.
let mut a: AdapterInfo = unsafe { std::mem::zeroed() };
a.iSize = std::mem::size_of::<AdapterInfo>() as i32;
a
})
.collect();
let bytes = std::mem::size_of_val(infos.as_slice()) as i32;
// SAFETY: caller-allocated array of exactly `count` stamped entries, byte size passed as the
// API's iInputSize contract requires.
let rc = unsafe { (adl.adapter_info)(ctx, infos.as_mut_ptr(), bytes) };
rec("adl-adapters", "all", 0, rc, format!("count={count}"));
if rc != ADL_OK {
// SAFETY: as above — context teardown, nothing ADL-owned used afterwards.
let _ = unsafe { (adl.destroy)(ctx) };
return RunOutcome::InitFailed(recs);
}
// One GPU surfaces as many logical adapters — probe each bus once, AMD-present only.
let mut seen_buses: Vec<i32> = Vec::new();
for info in &infos {
if info.iPresent == 0
|| info.iVendorID != AMD_VENDOR_ID
|| seen_buses.contains(&info.iBusNumber)
{
continue;
}
seen_buses.push(info.iBusNumber);
let idx = info.iAdapterIndex;
let name = c_str(&info.strAdapterName);
let target = format!("adapter{idx}[{}]", name.trim());
let mut supported = 0i32;
let t = Instant::now();
// SAFETY: live context, adapter index from this enumeration, valid out-param.
let rc = unsafe { (adl.edid_caps)(ctx, idx, &mut supported) };
rec(
"adl-edid-caps",
&target,
t.elapsed().as_millis(),
rc,
format!("supported={supported}"),
);
let (mut valid, mut n_slots, mut n_conn) = (0i32, 0i32, 0i32);
let mut slots: *mut c_void = std::ptr::null_mut();
let mut connectors: *mut ADLConnectorInfo = std::ptr::null_mut();
let t = Instant::now();
// SAFETY: live context + adapter index; out-pointers valid; ADL allocates the two arrays
// through `adl_malloc` (deliberately leaked, see there).
let rc = unsafe {
(adl.board_layout)(
ctx,
idx,
&mut valid,
&mut n_slots,
&mut slots,
&mut n_conn,
&mut connectors,
)
};
rec(
"adl-board-layout",
&target,
t.elapsed().as_millis(),
rc,
format!("connectors={n_conn} valid_flags={valid:#x}"),
);
let connector_list: &[ADLConnectorInfo] =
if rc == ADL_OK && !connectors.is_null() && n_conn > 0 {
// SAFETY: ADL just filled `connectors` with `n_conn` entries via our allocator; the
// (leaked) buffer outlives this borrow.
unsafe { std::slice::from_raw_parts(connectors, n_conn as usize) }
} else {
&[]
};
for c in connector_list {
if connector_filter.is_some_and(|want| want != c.iConnectorIndex) {
continue;
}
let port = ADLDevicePort::root(c.iConnectorIndex);
let ctarget = format!(
"adapter{idx}.connector{}[{}]",
c.iConnectorIndex,
connector_type_str(c.iType)
);
let mut state = ADLConnectionState::default();
let t = Instant::now();
// SAFETY: live context; port is a by-value POD naming a connector this adapter just
// enumerated; valid out-param.
let rc = unsafe { (adl.conn_state)(ctx, idx, port, &mut state) };
let real = state.iEmulationStatus & ADL_EMUL_STATUS_REAL_DEVICE_CONNECTED != 0;
rec(
"adl-conn-state",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"status={:#x} real_connected={} emulated_present={} emulated_used={} mode={} display={}",
state.iEmulationStatus,
real,
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_PRESENT != 0,
state.iEmulationStatus & ADL_EMUL_STATUS_EMULATED_DEVICE_USED != 0,
state.iEmulationMode,
state.iDisplayIndex,
),
);
if rc != ADL_OK {
continue;
}
match action {
EmulAction::Probe => {
// SAFETY: ADLConnectionData is plain ints + a byte array; all-zero is valid
// and ADL overwrites it.
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
let t = Instant::now();
// SAFETY: live context/port as above; REAL query fills `data` in place.
let rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
};
rec(
"adl-conn-data",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"type={} edid_bytes={}",
data.iConnectionType, data.iDataSize
),
);
}
EmulAction::Lock => {
if !real && connector_filter.is_none() {
continue; // nothing to pin — and pinning an EMPTY connector is a different experiment
}
// SAFETY: as in Probe — zeroed then driver-filled.
let mut data: ADLConnectionData = unsafe { std::mem::zeroed() };
let t = Instant::now();
// SAFETY: live context/port; REAL query first — we pin exactly what the
// sink reports today, so the emulated display IS the user's monitor.
let mut rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_REAL_DATA, &mut data)
};
if rc != ADL_OK {
// Asleep sinks can refuse a live EDID read — fall back to whatever the
// driver already has as emulation data (Radeon-Pro-UI parity).
// SAFETY: same contract, emulated-data query.
rc = unsafe {
(adl.conn_data_get)(ctx, idx, port, ADL_QUERY_EMULATED_DATA, &mut data)
};
}
rec(
"adl-lock-read",
&ctarget,
t.elapsed().as_millis(),
rc,
format!(
"type={} edid_bytes={}",
data.iConnectionType, data.iDataSize
),
);
if rc != ADL_OK || data.iDataSize <= 0 {
continue;
}
let t = Instant::now();
// SAFETY: live context/port; `data` passed by value per the ADL signature.
let rc = unsafe { (adl.conn_data_set)(ctx, idx, port, data) };
rec(
"adl-lock-set",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
let t = Instant::now();
// SAFETY: live context/port; mode constant from the header.
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_ALWAYS) };
rec(
"adl-lock-mode-always",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
}
EmulAction::Unlock => {
let t = Instant::now();
// SAFETY: live context/port; mode constant from the header.
let rc = unsafe { (adl.emul_mode_set)(ctx, idx, port, ADL_EMUL_MODE_OFF) };
rec(
"adl-unlock-mode-off",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
let t = Instant::now();
// SAFETY: live context/port; removes emulation data set earlier (harmless
// where none exists — the rc says so).
let rc = unsafe { (adl.conn_data_remove)(ctx, idx, port) };
rec(
"adl-unlock-remove",
&ctarget,
t.elapsed().as_millis(),
rc,
String::new(),
);
}
}
}
}
// SAFETY: destroying the context created above; nothing ADL-owned is used past this point.
let _ = unsafe { (adl.destroy)(ctx) };
RunOutcome::Done(recs)
}
// ---- the `edid_lock` display-policy axis (host-side) ----
/// The crash-recovery journal: a marker that a lock was applied and not yet unlocked. Pinned
/// emulation outlives the process (and can outlive a reboot), so a host that died mid-stream
/// must unlock on its next start ([`startup_recover`]).
fn journal_path() -> std::path::PathBuf {
pf_paths::config_dir().join("edid-lock-active.json")
}
fn tracing_log(prefix: &str, outcome: &RunOutcome) {
match outcome {
RunOutcome::NoAdl => tracing::info!(
"{prefix}: atiadlxx.dll not loadable — not an AMD driver install; the ADL \
emulation lever does not exist on this box"
),
RunOutcome::InitFailed(recs) | RunOutcome::Done(recs) => {
for r in recs {
if r.ok() {
tracing::info!("{prefix}: {r}");
} else {
tracing::warn!("{prefix}: {r}");
}
}
}
}
}
/// Apply the `edid_lock` axis at stream bring-up: pin the live EDID + `ADL_EMUL_MODE_ALWAYS` on
/// every occupied AMD connector (the software HPD dummy), journaling first so a crash still
/// unlocks on the next host start. Returns whether a later [`unlock_after_stream`] is owed —
/// true whenever the ADL runtime exists, because even a partially-failed lock may have pinned
/// some connectors (each rc is in the log).
pub fn lock_for_stream() -> bool {
// Journal BEFORE touching the driver: a crash between the first `ConnectionData_Set` and the
// journal write would otherwise leave pinned connectors with no startup unlock owed.
if let Err(e) = std::fs::write(journal_path(), b"{\"locked\":true}") {
tracing::warn!(
error = %format!("{e:#}"),
"edid_lock: crash journal write failed — continuing (the feature degrades to \
no-crash-journal)"
);
}
let outcome = run(EmulAction::Lock, None);
tracing_log("edid_lock", &outcome);
if matches!(outcome, RunOutcome::NoAdl) {
tracing::info!("edid_lock: enabled but this is not an AMD driver install — axis inert");
let _ = std::fs::remove_file(journal_path());
return false;
}
let locked = outcome
.records()
.iter()
.filter(|r| r.op == "adl-lock-mode-always" && r.ok())
.count();
tracing::info!(
connectors = locked,
"edid_lock: connector emulation pinned (software HPD dummy) — unlocked at stream teardown"
);
true
}
/// Undo [`lock_for_stream`] at teardown: `ADL_EMUL_MODE_OFF` + remove the pinned EDID on every
/// AMD connector, then clear the crash journal. Idempotent and harmless where nothing is pinned.
pub fn unlock_after_stream() {
let outcome = run(EmulAction::Unlock, None);
tracing_log("edid_lock", &outcome);
let _ = std::fs::remove_file(journal_path());
}
/// Host-startup crash recovery: a previous host that died holding the lock left connector
/// emulation pinned (it persists past the process — and can persist past a reboot). If the
/// journal marker exists, unlock everything and clear it — before any new session touches the
/// topology, mirroring `monitor_devnode::startup_recover`.
pub fn startup_recover() {
if !journal_path().exists() {
return;
}
tracing::warn!(
"edid_lock: a previous host left connector emulation pinned (crash/kill) — unlocking"
);
unlock_after_stream();
}
+2
View File
@@ -13,6 +13,8 @@
// hoist that to the crate root so the smaller modules (`input_desktop`, `monitor_devnode`,
// `display_events`) and any future one are covered by default rather than by remembering to opt in.
#[cfg(target_os = "windows")]
pub mod adl_emul;
#[cfg(target_os = "windows")]
pub mod display_events;
/// Bind display-config writes to the input desktop so a UAC / lock screen can't refuse them.
+16 -2
View File
@@ -217,18 +217,32 @@ fn journal_and_disable(targets: Vec<(String, String)>) -> Vec<String> {
disabled
}
/// Re-enable `ids` (teardown / recovery) and clear them from the journal.
/// Re-enable `ids` (teardown / recovery) and clear the ones that actually re-enabled from the
/// journal. A FAILED re-enable must keep its journal entry: it is the only record that the
/// devnode is still disabled, and the next host start's [`startup_recover`] is the only thing
/// left that will retry it. (The old behavior cleared every requested id unconditionally — a
/// mid-life re-enable failure erased its own crash-recovery entry, leaving the operator's
/// monitor invisible to Windows AND to every display listing until they re-enabled it by hand
/// in Device Manager: the "my displays are gone until I restart everything" field class.)
pub fn enable_instances(ids: &[String]) -> u32 {
let mut ok = 0u32;
let mut reenabled: Vec<&String> = Vec::with_capacity(ids.len());
for id in ids {
if set_devnode(id, false) {
tracing::info!(id, "PnP-disable: monitor devnode re-enabled");
reenabled.push(id);
ok += 1;
} else {
tracing::warn!(
id,
"PnP-disable: monitor devnode re-enable FAILED — keeping its crash-journal \
entry so the next host start retries (until then this monitor stays disabled)"
);
}
}
let journal: Vec<String> = read_journal()
.into_iter()
.filter(|j| !ids.contains(j))
.filter(|j| !reenabled.contains(&j))
.collect();
write_journal(&journal);
ok
+298 -24
View File
@@ -1317,11 +1317,14 @@ pub mod isolate_journal {
let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir);
// SAFETY: `_g` holds this module's ENV mutex, which serializes every test that
// writes or reads `PUNKTFUNK_CONFIG_DIR` in this binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir) };
clear(); // reset the LAST cache + any leftover marker from a previous run
f(&dir);
clear();
std::env::remove_var("PUNKTFUNK_CONFIG_DIR");
// SAFETY: still under `_g` — the same serialization as the set above.
unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") };
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1529,10 +1532,14 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
// and confirm no non-keep display survived. Only then is the virtual set truly the sole desktop.
let survivors = count_other_active(keep_target_ids).unwrap_or(0);
if survivors == 0 {
tracing::info!("display isolate (CCD): target set {keep_target_ids:?} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})");
tracing::info!(
"display isolate (CCD): target set {keep_target_ids:?} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})"
);
return Some(saved);
}
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
tracing::warn!(
"display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying"
);
std::thread::sleep(std::time::Duration::from_millis(250));
}
// Name the survivors instead of assuming their kind — the field logs showed this path fire
@@ -1932,7 +1939,9 @@ pub fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
)
});
if rc == 0 {
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
tracing::info!(
"display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right"
);
} else {
tracing::warn!(
"display primary (CCD): SetDisplayConfig failed rc={rc:#x}{} (virtual {keep_target_id} primary, physicals kept)",
@@ -1962,29 +1971,173 @@ pub fn restore_displays_ccd(saved: &SavedConfig) {
isolate_journal::clear();
}
/// Every display target that still EXISTS right now — `(adapter LUID low, high, target id)` keys
/// from a full `QDC_ALL_PATHS` sweep, counting a target present when the OS says a monitor is
/// attached (`targetAvailable`) OR an active path drives it (the flag reads FALSE transiently
/// right after a removal — same rule as [`target_inventory`]). `None` when the CCD query itself
/// fails, so the caller can fall back to trusting its snapshot verbatim.
fn available_target_keys() -> Option<Vec<(u32, i32, u32)>> {
let mut np = 0u32;
let mut nm = 0u32;
// SAFETY: the CCD contract at the top of this file — `&mut np`/`&mut nm` are live locals the
// OS fills with the counts it wants for these flags.
if unsafe { GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm) }.is_err() {
return None;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
// SAFETY: the CCD contract — `paths`/`modes` were just allocated with exactly `np`/`nm`
// elements from the sizing call above, and are handed over with those same counts.
if unsafe {
QueryDisplayConfig(
QDC_ALL_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
}
.is_err()
{
return None;
}
paths.truncate(np as usize);
let mut keys: Vec<(u32, i32, u32)> = Vec::new();
for p in &paths {
let t = &p.targetInfo;
let key = (t.adapterId.LowPart, t.adapterId.HighPart, t.id);
let present = t.targetAvailable.as_bool() || p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0;
if present && !keys.contains(&key) {
keys.push(key);
}
}
Some(keys)
}
/// Drop every snapshot path whose TARGET no longer exists (`avail` — the live
/// [`available_target_keys`] sweep) and rebuild the mode table with only the entries the
/// survivors reference, remapping their `modeInfoIdx` slots. Both halves matter:
/// `SetDisplayConfig(SDC_USE_SUPPLIED_DISPLAY_CONFIG)` validates the WHOLE submission, so one
/// stale path — or one orphaned mode entry left behind by a dropped path — fails the entire
/// restore with 0x57 ERROR_INVALID_PARAMETER. Returns `(paths, modes, dropped_path_count)`;
/// pure over its inputs so the remap arithmetic is unit-testable without a live CCD.
fn prune_saved_config_for_targets(
paths: &[DISPLAYCONFIG_PATH_INFO],
modes: &[DISPLAYCONFIG_MODE_INFO],
avail: &[(u32, i32, u32)],
) -> (
Vec<DISPLAYCONFIG_PATH_INFO>,
Vec<DISPLAYCONFIG_MODE_INFO>,
usize,
) {
let mut kept: Vec<DISPLAYCONFIG_PATH_INFO> = Vec::with_capacity(paths.len());
let mut new_modes: Vec<DISPLAYCONFIG_MODE_INFO> = Vec::with_capacity(modes.len());
// old mode index → new mode index, memoized: clone configs legitimately share a source mode
// entry between paths, and it must land in the rebuilt table exactly once.
let mut remap: Vec<Option<u32>> = vec![None; modes.len()];
let take =
|idx: u32, new_modes: &mut Vec<DISPLAYCONFIG_MODE_INFO>, remap: &mut Vec<Option<u32>>| {
if idx == DISPLAYCONFIG_PATH_MODE_IDX_INVALID {
return DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
}
match modes.get(idx as usize) {
// An out-of-range index could never have applied — un-pin the mode rather than
// shipping a table the whole submission fails on.
None => DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
Some(m) => match remap[idx as usize] {
Some(n) => n,
None => {
let n = new_modes.len() as u32;
new_modes.push(*m);
remap[idx as usize] = Some(n);
n
}
},
}
};
let mut dropped = 0usize;
for p in paths {
let t = &p.targetInfo;
if !avail.contains(&(t.adapterId.LowPart, t.adapterId.HighPart, t.id)) {
dropped += 1;
continue;
}
let mut p = *p;
// SAFETY: POD union reads (CCD header contract) — `modeInfoIdx` overlays a same-sized
// bitfield struct, both valid for every bit pattern; used only as bounds-checked indices.
let (src_idx, tgt_idx) = unsafe {
(
p.sourceInfo.Anonymous.modeInfoIdx,
p.targetInfo.Anonymous.modeInfoIdx,
)
};
p.sourceInfo.Anonymous.modeInfoIdx = take(src_idx, &mut new_modes, &mut remap);
p.targetInfo.Anonymous.modeInfoIdx = take(tgt_idx, &mut new_modes, &mut remap);
kept.push(p);
}
(kept, new_modes, dropped)
}
fn restore_displays_ccd_inner(saved: &SavedConfig) {
let (paths, modes) = saved;
if paths.is_empty() {
let (saved_paths, saved_modes) = saved;
if saved_paths.is_empty() {
return;
}
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
// slices, so pointer and length cannot disagree, and both outlive this synchronous
// call. `retry_set_display_config` binds it to the input desktop, which is the one
// precondition a caller of this global-state write could otherwise get wrong.
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
)
});
if rc == 0 {
tracing::info!("display isolate (CCD): restored original topology");
} else {
// Prune the snapshot against what is STILL ATTACHED before replaying it. A monitor unplugged
// mid-session leaves the snapshot referencing an absent target, and SetDisplayConfig rejects
// the WHOLE array with 0x57 ERROR_INVALID_PARAMETER — nothing restores, the desk stays dark,
// and the next session snapshots that wreckage (the poisoned-snapshot chain's first link;
// field 2026-08-12: rc=0x57 across a mid-session unplug, then sessions flipping between
// black/working at random). Dropping the stale paths lets the surviving displays restore
// normally; when NOTHING survives there is nothing to replay and the dark-desk backstop
// below is the whole answer.
let (kept, pruned_modes, dropped);
let (paths, modes): (&Vec<_>, &Vec<_>) = match available_target_keys() {
Some(avail) => {
(kept, pruned_modes, dropped) =
prune_saved_config_for_targets(saved_paths, saved_modes, &avail);
if dropped > 0 {
tracing::warn!(
dropped,
kept = kept.len(),
"display isolate (CCD): snapshot references target(s) that are no longer \
attached (unplugged mid-session?) pruned them so the survivors can restore \
(a verbatim replay fails whole with rc=0x57)"
);
}
(&kept, &pruned_modes)
}
// The availability query itself failed — replay verbatim, exactly the old behavior.
None => (saved_paths, saved_modes),
};
let mut apply_rc = 0i32; // 0 also when the replay was skipped (nothing left to apply)
if paths.is_empty() {
tracing::warn!(
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
sdc_access_denied_hint(rc)
"display isolate (CCD): nothing from the topology snapshot is still attached — \
skipping the replay (the dark-desk backstop decides what lights up)"
);
} else {
// SAFETY: the CCD contract at the top of this file — the path/mode arrays go over as
// slices, so pointer and length cannot disagree, and both outlive this synchronous
// call. `retry_set_display_config` binds it to the input desktop, which is the one
// precondition a caller of this global-state write could otherwise get wrong.
let rc = crate::input_desktop::retry_set_display_config(|| unsafe {
SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES,
)
});
apply_rc = rc;
if rc == 0 {
tracing::info!("display isolate (CCD): restored original topology");
} else {
tracing::warn!(
"display isolate (CCD): topology restore failed rc={rc:#x}{} — physical displays may be left deactivated",
sdc_access_denied_hint(rc)
);
}
}
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
@@ -2020,7 +2173,7 @@ fn restore_displays_ccd_inner(saved: &SavedConfig) {
return;
}
tracing::warn!(
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
"display isolate (CCD): no external physical display active after the restore (rc={apply_rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
);
force_extend_topology();
// Measure what the force achieved: a sink still dark AFTER the EXTEND preset can never
@@ -2128,3 +2281,124 @@ mod live_tests {
tracing::info!("live CCD query: {n} active display path(s)");
}
}
#[cfg(test)]
mod prune_saved_config_tests {
//! The snapshot-prune remap arithmetic (`prune_saved_config_for_targets`) — pure over its
//! inputs, so the 0x57-poisoned-restore fix is testable without a live CCD: a stale target's
//! path must vanish, its modes must not orphan (an orphaned entry fails the whole
//! SetDisplayConfig exactly like the stale path did), and clone-shared modes must land once.
use super::*;
fn path(
luid_low: u32,
target_id: u32,
src_mode: u32,
tgt_mode: u32,
) -> DISPLAYCONFIG_PATH_INFO {
let mut p = DISPLAYCONFIG_PATH_INFO::default();
p.targetInfo.adapterId.LowPart = luid_low;
p.targetInfo.id = target_id;
p.sourceInfo.adapterId.LowPart = luid_low;
p.sourceInfo.Anonymous.modeInfoIdx = src_mode;
p.targetInfo.Anonymous.modeInfoIdx = tgt_mode;
p
}
fn mode(marker: u32) -> DISPLAYCONFIG_MODE_INFO {
DISPLAYCONFIG_MODE_INFO {
id: marker,
..Default::default()
}
}
fn indices(p: &DISPLAYCONFIG_PATH_INFO) -> (u32, u32) {
// SAFETY: POD union reads — `modeInfoIdx` overlays a same-sized bitfield struct, both
// valid for every bit pattern (the same contract the production reads rely on).
unsafe {
(
p.sourceInfo.Anonymous.modeInfoIdx,
p.targetInfo.Anonymous.modeInfoIdx,
)
}
}
#[test]
fn everything_attached_survives_with_dense_indices() {
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
let avail = vec![(1, 0, 100), (1, 0, 200)];
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!(dropped, 0);
assert_eq!(kept.len(), 2);
assert_eq!(new_modes.len(), 4);
assert_eq!(indices(&kept[0]), (0, 1));
assert_eq!(indices(&kept[1]), (2, 3));
assert_eq!(new_modes[3].id, 13, "mode entries follow their paths");
}
#[test]
fn a_gone_target_drops_its_path_and_modes() {
// Target 200 was unplugged mid-session (the field rc=0x57 case): its path AND its two
// mode entries must vanish, and the survivor's indices must be remapped dense.
let paths = vec![path(1, 100, 0, 1), path(1, 200, 2, 3)];
let modes = vec![mode(10), mode(11), mode(12), mode(13)];
let avail = vec![(1, 0, 100)];
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!(dropped, 1);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].targetInfo.id, 100);
assert_eq!(
new_modes.len(),
2,
"the dropped path's modes must not orphan"
);
assert_eq!((new_modes[0].id, new_modes[1].id), (10, 11));
assert_eq!(indices(&kept[0]), (0, 1));
}
#[test]
fn a_clone_shared_source_mode_lands_exactly_once() {
// Clone configs share one source mode entry between paths — the rebuilt table must
// contain it once, referenced by both survivors.
let paths = vec![path(1, 100, 0, 1), path(1, 200, 0, 2)];
let modes = vec![mode(10), mode(11), mode(12)];
let avail = vec![(1, 0, 100), (1, 0, 200)];
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!(dropped, 0);
assert_eq!(new_modes.len(), 3);
let (a_src, _) = indices(&kept[0]);
let (b_src, _) = indices(&kept[1]);
assert_eq!(a_src, b_src, "shared source mode keeps one table entry");
}
#[test]
fn unpinned_and_corrupt_indices_stay_unpinned() {
// The INVALID sentinel must pass through, and an out-of-range index (a corrupt snapshot)
// must degrade to unpinned rather than shipping a table the whole apply fails on.
let paths = vec![path(1, 100, DISPLAYCONFIG_PATH_MODE_IDX_INVALID, 99)];
let modes = vec![mode(10)];
let avail = vec![(1, 0, 100)];
let (kept, new_modes, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!(dropped, 0);
assert!(new_modes.is_empty());
assert_eq!(
indices(&kept[0]),
(
DISPLAYCONFIG_PATH_MODE_IDX_INVALID,
DISPLAYCONFIG_PATH_MODE_IDX_INVALID
)
);
}
#[test]
fn different_adapters_do_not_alias_the_same_target_id() {
// Target ids are only unique per adapter LUID — a survivor on adapter 2 must not keep a
// stale path alive on adapter 1 just because the ids match.
let paths = vec![path(1, 100, 0, 1)];
let modes = vec![mode(10), mode(11)];
let avail = vec![(2, 0, 100)];
let (kept, _, dropped) = prune_saved_config_for_targets(&paths, &modes, &avail);
assert_eq!((kept.len(), dropped), (0, 1));
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
[package]
name = "pf-zerocopy"
version.workspace = true
edition = "2021"
edition.workspace = true
# Inherit the workspace MSRV: clippy keys MSRV-gated lints off it (without this, e.g.
# `manual_is_multiple_of` fires on code the host crate compiles clean).
rust-version.workspace = true
+2 -2
View File
@@ -34,7 +34,7 @@ pub(crate) const GL_LINK_STATUS: u32 = 0x8B82;
// libglvnd's libGL dispatches these to the NVIDIA driver based on the current EGL/GL context.
#[link(name = "GL")]
extern "C" {
unsafe extern "C" {
pub(crate) fn glGenTextures(n: c_int, textures: *mut u32);
pub(crate) fn glBindTexture(target: u32, texture: u32);
pub(crate) fn glTexParameteri(target: u32, pname: u32, param: c_int);
@@ -97,7 +97,7 @@ extern "C" {
}
#[link(name = "gbm")]
extern "C" {
unsafe extern "C" {
pub(crate) fn gbm_create_device(fd: c_int) -> *mut c_void;
pub(crate) fn gbm_device_destroy(device: *mut c_void);
}
+150 -99
View File
@@ -253,7 +253,7 @@ fn new_handle(session: Session) -> *mut PunktfunkSession {
}
/// Current ABI version. Mismatch with [`crate::ABI_VERSION`] means incompatible core.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn punktfunk_abi_version() -> u32 {
crate::ABI_VERSION
}
@@ -271,7 +271,7 @@ pub extern "C" fn punktfunk_abi_version() -> u32 {
/// # Safety
/// `macs` must point to at least `mac_count * 6` readable bytes. `last_known_ip`, if non-NULL,
/// must be a NUL-terminated string.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_wake_on_lan(
macs: *const u8,
mac_count: usize,
@@ -321,7 +321,7 @@ pub unsafe extern "C" fn punktfunk_wake_on_lan(
///
/// # Safety
/// `cfg`, `local`, `peer` must be valid pointers; the strings must be NUL-terminated.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_session_new(
cfg: *const PunktfunkConfig,
local: *const c_char,
@@ -366,7 +366,7 @@ pub unsafe extern "C" fn punktfunk_session_new(
///
/// # Safety
/// All four pointers must be valid; the two out-params receive owned handles.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_test_loopback_pair(
host_cfg: *const PunktfunkConfig,
client_cfg: *const PunktfunkConfig,
@@ -413,7 +413,7 @@ pub unsafe extern "C" fn punktfunk_test_loopback_pair(
///
/// # Safety
/// `s` must be a handle from `punktfunk_session_new`/`punktfunk_test_loopback_pair`, freed once.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_session_free(s: *mut PunktfunkSession) {
guard_void(|| {
if !s.is_null() {
@@ -428,7 +428,7 @@ pub unsafe extern "C" fn punktfunk_session_free(s: *mut PunktfunkSession) {
///
/// # Safety
/// `s` is a valid host handle; `data` points to `len` readable bytes (or `len == 0`).
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_host_submit_frame(
s: *mut PunktfunkSession,
data: *const u8,
@@ -466,7 +466,7 @@ pub unsafe extern "C" fn punktfunk_host_submit_frame(
///
/// # Safety
/// `s` is a valid client handle; `out` points to a writable `PunktfunkFrame`.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_client_poll_frame(
s: *mut PunktfunkSession,
out: *mut PunktfunkFrame,
@@ -506,9 +506,11 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame(
/// Client: serialize and send one input event to the host.
///
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
///
/// # Safety
/// `s` is a valid client handle; `ev` points to a valid [`InputEvent`].
#[no_mangle]
/// `s` is a valid client handle; `ev` points to a readable `InputEvent`-sized allocation.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_send_input(
s: *mut PunktfunkSession,
ev: *const InputEvent,
@@ -521,12 +523,11 @@ pub unsafe extern "C" fn punktfunk_send_input(
Some(s) => s,
None => return PunktfunkStatus::NullPointer,
};
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let ev = match unsafe { ev.as_ref() } {
Some(e) => e,
None => return PunktfunkStatus::NullPointer,
// SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
// for the one field where a reference formed too early would be UB instead.
let ev = match unsafe { read_input_event(ev) } {
Ok(e) => e,
Err(status) => return status,
};
match s.inner.send_input(ev) {
Ok(()) => PunktfunkStatus::Ok,
@@ -535,12 +536,37 @@ pub unsafe extern "C" fn punktfunk_send_input(
})
}
/// Validate caller memory as an [`InputEvent`] WITHOUT forming the reference first.
///
/// `InputEvent.kind` is a `#[repr(u8)]` enum with 16 valid discriminants, and a C embedder
/// writing `ev->kind = 42` is not a decodable error once `&InputEvent` exists — forming the
/// reference IS the UB, by the language's validity rule. So the tag is read as a raw byte and
/// validated through the same `InputKind::from_u8` the wire path uses (`input.rs::decode`),
/// and the typed reference comes into existence only afterwards. Every other field is a plain
/// integer (or the `[u8; 3]` pad), valid for any bit pattern.
///
/// # Safety
/// `ev` is null (reported as a status) or readable for `size_of::<InputEvent>()` bytes.
unsafe fn read_input_event<'a>(ev: *const InputEvent) -> Result<&'a InputEvent, PunktfunkStatus> {
if ev.is_null() {
return Err(PunktfunkStatus::NullPointer);
}
// SAFETY: non-null per the check above, readable per this fn's contract; a one-byte read
// at offset 0 (the `kind` tag — repr(C) puts it first) cannot itself be UB for any value.
if crate::input::InputKind::from_u8(unsafe { ev.cast::<u8>().read() }).is_none() {
return Err(PunktfunkStatus::InvalidArg);
}
// SAFETY: non-null, readable, and the discriminant byte was just validated — every field
// of the repr(C) struct now holds a valid bit pattern for its type.
Ok(unsafe { &*ev })
}
/// Register the host-side input callback (pass a NULL fn pointer to clear). The callback
/// fires from within [`punktfunk_host_poll_input`], on the calling thread.
///
/// # Safety
/// `s` is a valid host handle; `user` is passed back verbatim to `cb`.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_set_input_callback(
s: *mut PunktfunkSession,
// Written as an explicit `Option<fn>` (not the `PunktfunkInputCb` alias) so cbindgen
@@ -566,7 +592,7 @@ pub unsafe extern "C" fn punktfunk_set_input_callback(
///
/// # Safety
/// `s` is a valid host handle.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) -> i32 {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
let mut count = 0i32;
@@ -607,7 +633,7 @@ pub unsafe extern "C" fn punktfunk_host_poll_input(s: *mut PunktfunkSession) ->
///
/// # Safety
/// `s` is a valid handle; `out` points to a writable `PunktfunkStats`.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_get_stats(
s: *mut PunktfunkSession,
out: *mut PunktfunkStats,
@@ -1401,7 +1427,7 @@ const _: () = {
/// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes;
/// `client_cert_pem`/`client_key_pem` are each NULL or NUL-terminated UTF-8.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect(
host: *const std::os::raw::c_char,
port: u16,
@@ -1442,7 +1468,7 @@ pub unsafe extern "C" fn punktfunk_connect(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex(
host: *const std::os::raw::c_char,
port: u16,
@@ -1486,7 +1512,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex2(
host: *const std::os::raw::c_char,
port: u16,
@@ -1531,7 +1557,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex2(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex3(
host: *const std::os::raw::c_char,
port: u16,
@@ -1579,7 +1605,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex3(
/// # Safety
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_ex4(
host: *const std::os::raw::c_char,
port: u16,
@@ -1632,7 +1658,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex4(
/// # Safety
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex5(
host: *const std::os::raw::c_char,
@@ -1685,7 +1711,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex6(
host: *const std::os::raw::c_char,
@@ -1741,7 +1767,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex6(
/// # Safety
/// Same as [`punktfunk_connect`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex7(
host: *const std::os::raw::c_char,
@@ -1802,7 +1828,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
/// # Safety
/// Same as [`punktfunk_connect`]; `status_out`, when non-null, must point to a writable `i32`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex8(
host: *const std::os::raw::c_char,
@@ -1863,7 +1889,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex8(
/// # Safety
/// Same as [`punktfunk_connect_ex8`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn punktfunk_connect_ex9(
host: *const std::os::raw::c_char,
@@ -2096,7 +2122,7 @@ unsafe fn connect_ex_impl(
/// # Safety
/// `cert_pem_out` is writable for `cert_cap` bytes; `key_pem_out` for `key_cap`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_generate_identity(
cert_pem_out: *mut std::os::raw::c_char,
cert_cap: usize,
@@ -2139,7 +2165,7 @@ pub unsafe extern "C" fn punktfunk_generate_identity(
/// # Safety
/// `host` must be a NUL-terminated UTF-8 string.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_probe(
host: *const std::os::raw::c_char,
port: u16,
@@ -2174,7 +2200,7 @@ pub unsafe extern "C" fn punktfunk_probe(
/// `host`/`client_cert_pem`/`client_key_pem`/`pin`/`name` are NUL-terminated UTF-8;
/// `host_sha256_out` is writable for 32 bytes.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_pair(
host: *const std::os::raw::c_char,
port: u16,
@@ -2238,7 +2264,7 @@ pub unsafe extern "C" fn punktfunk_pair(
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls video —
/// it may run concurrently with one audio-pulling and one rumble-pulling thread.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_au(
c: *mut PunktfunkConnection,
out: *mut PunktfunkFrame,
@@ -2303,7 +2329,7 @@ pub struct PunktfunkAudioPacket {
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio —
/// it may run concurrently with the video/rumble pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_audio(
c: *mut PunktfunkConnection,
out: *mut PunktfunkAudioPacket,
@@ -2354,7 +2380,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_audio_channels(
c: *mut PunktfunkConnection,
out: *mut u8,
@@ -2394,7 +2420,7 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_end_reason(
c: *mut PunktfunkConnection,
out: *mut u8,
@@ -2452,7 +2478,7 @@ pub struct PunktfunkAudioPcm {
/// # Safety
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
c: *mut PunktfunkConnection,
out: *mut PunktfunkAudioPcm,
@@ -2518,7 +2544,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
/// `c` is a valid connection handle; the `out_*` pointers are writable (NULLs are skipped);
/// `buf` is writable for `buf_len` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
c: *mut PunktfunkConnection,
out_pad: *mut u8,
@@ -2592,7 +2618,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_pad_audio(
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
c: *mut PunktfunkConnection,
pad: u8,
@@ -2623,7 +2649,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_pad_audio_caps(
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
/// most one thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble(
c: *mut PunktfunkConnection,
pad: *mut u16,
@@ -2680,7 +2706,7 @@ pub const PUNKTFUNK_RUMBLE_NO_TTL: u32 = 0xFFFF_FFFF;
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
c: *mut PunktfunkConnection,
pad: *mut u16,
@@ -2760,7 +2786,7 @@ pub const PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER: u32 = 1;
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
c: *mut PunktfunkConnection,
pad: *mut u16,
@@ -2841,7 +2867,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd(
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2(
c: *mut PunktfunkConnection,
pad: *mut u16,
@@ -2907,7 +2933,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd2(
/// # Safety
/// `c` is a valid connection handle. Callable from any thread.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
c: *mut PunktfunkConnection,
pad: u16,
@@ -2944,7 +2970,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHidOutput`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_hidout(
c: *mut PunktfunkConnection,
out: *mut PunktfunkHidOutput,
@@ -2992,7 +3018,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHdrMeta`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_hdr_meta(
c: *mut PunktfunkConnection,
out: *mut PunktfunkHdrMeta,
@@ -3060,7 +3086,7 @@ pub struct PunktfunkCursorState {
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor
/// shapes; it may run concurrently with every other plane's puller.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
c: *mut PunktfunkConnection,
out: *mut PunktfunkCursorShape,
@@ -3112,7 +3138,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape(
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls cursor
/// state; it may run concurrently with every other plane's puller.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_cursor_state(
c: *mut PunktfunkConnection,
out: *mut PunktfunkCursorState,
@@ -3161,7 +3187,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_state(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_set_cursor_render(
c: *mut PunktfunkConnection,
client_draws: bool,
@@ -3192,7 +3218,7 @@ pub unsafe extern "C" fn punktfunk_connection_set_cursor_render(
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHostTiming`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_host_timing(
c: *mut PunktfunkConnection,
out: *mut PunktfunkHostTiming,
@@ -3239,7 +3265,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_host_timing(
/// # Safety
/// `c` is a valid connection handle; each out pointer is NULL or writable for its scalar.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_color_info(
c: *mut PunktfunkConnection,
primaries: *mut u8,
@@ -3289,7 +3315,7 @@ pub unsafe extern "C" fn punktfunk_connection_color_info(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_chroma_format(
c: *mut PunktfunkConnection,
out: *mut u8,
@@ -3319,7 +3345,7 @@ pub unsafe extern "C" fn punktfunk_connection_chroma_format(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_codec(
c: *mut PunktfunkConnection,
out: *mut u8,
@@ -3349,7 +3375,7 @@ pub unsafe extern "C" fn punktfunk_connection_codec(
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u32`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_shard_payload(
c: *mut PunktfunkConnection,
out: *mut u32,
@@ -3372,10 +3398,12 @@ pub unsafe extern "C" fn punktfunk_connection_shard_payload(
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
///
/// Returns `InvalidArg` if `ev->kind` is not a recognized event kind.
///
/// # Safety
/// `c` is a valid connection handle; `ev` points to a valid [`InputEvent`].
/// `c` is a valid connection handle; `ev` points to a readable `InputEvent`-sized allocation.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_input(
c: *mut PunktfunkConnection,
ev: *const InputEvent,
@@ -3388,12 +3416,11 @@ pub unsafe extern "C" fn punktfunk_connection_send_input(
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_mut`/`as_ref` reports as `None` and the `match`
// here handles.
let ev = match unsafe { ev.as_ref() } {
Some(e) => e,
None => return PunktfunkStatus::NullPointer,
// SAFETY: `read_input_event` upholds this file's failures-become-status-codes principle
// for the one field where a reference formed too early would be UB instead.
let ev = match unsafe { read_input_event(ev) } {
Ok(e) => e,
Err(status) => return status,
};
match c.inner.send_input(ev) {
Ok(()) => PunktfunkStatus::Ok,
@@ -3410,7 +3437,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_input(
/// # Safety
/// `c` is a valid connection handle; `opus_data` is valid for `len` bytes (or `len == 0`).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_mic(
c: *mut PunktfunkConnection,
opus_data: *const u8,
@@ -3451,7 +3478,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_mic(
/// # Safety
/// `c` is a valid connection handle; `rich` points to a valid [`PunktfunkRichInput`].
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
c: *mut PunktfunkConnection,
rich: *const PunktfunkRichInput,
@@ -3489,7 +3516,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
/// `c` is a valid connection handle; `rich` is null or points to at least its declared
/// `struct_size` bytes.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
c: *mut PunktfunkConnection,
rich: *const PunktfunkRichInputEx,
@@ -3539,7 +3566,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_rich_input2(
/// `c` is a valid connection handle; `samples` is null or points to `count` valid
/// [`PunktfunkPenSample`]s.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_send_pen(
c: *mut PunktfunkConnection,
samples: *const PunktfunkPenSample,
@@ -3582,7 +3609,7 @@ pub unsafe extern "C" fn punktfunk_connection_send_pen(
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_mode(
c: *const PunktfunkConnection,
width: *mut u32,
@@ -3623,7 +3650,7 @@ pub unsafe extern "C" fn punktfunk_connection_mode(
/// # Safety
/// `c` is a valid connection handle; `gamepad` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_gamepad(
c: *const PunktfunkConnection,
gamepad: *mut u32,
@@ -3808,7 +3835,7 @@ fn build_clip_event(
/// # Safety
/// `c` is a valid connection handle; `caps` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_host_caps(
c: *const PunktfunkConnection,
caps: *mut u8,
@@ -3839,7 +3866,7 @@ pub unsafe extern "C" fn punktfunk_connection_host_caps(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
c: *const PunktfunkConnection,
enabled: bool,
@@ -3868,7 +3895,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_control(
/// `c` is a valid connection handle; `kinds` points to `n` `PunktfunkClipKind`s (NULL allowed only
/// when `n == 0`), each `mime` a valid NUL-terminated UTF-8 string.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
c: *const PunktfunkConnection,
seq: u32,
@@ -3924,7 +3951,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_offer(
/// `c` is a valid connection handle; `mime` is a valid NUL-terminated UTF-8 string; `xfer_id_out`
/// is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
c: *const PunktfunkConnection,
seq: u32,
@@ -3973,7 +4000,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_fetch(
/// `c` is a valid connection handle; `data` points to `len` bytes (NULL allowed only when
/// `len == 0`).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
c: *const PunktfunkConnection,
req_id: u32,
@@ -4012,7 +4039,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_serve(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
c: *const PunktfunkConnection,
id: u32,
@@ -4040,7 +4067,7 @@ pub unsafe extern "C" fn punktfunk_connection_clipboard_cancel(
/// # Safety
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkClipEvent`.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
c: *mut PunktfunkConnection,
out: *mut PunktfunkClipEvent,
@@ -4091,7 +4118,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard(
/// # Safety
/// `c` is a valid connection handle; `compositor` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_compositor(
c: *const PunktfunkConnection,
compositor: *mut u32,
@@ -4122,7 +4149,7 @@ pub unsafe extern "C" fn punktfunk_connection_compositor(
/// # Safety
/// `c` is a valid connection handle; `bitrate_kbps` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_bitrate(
c: *const PunktfunkConnection,
bitrate_kbps: *mut u32,
@@ -4157,7 +4184,7 @@ pub unsafe extern "C" fn punktfunk_connection_bitrate(
/// # Safety
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
c: *const PunktfunkConnection,
offset_ns: *mut i64,
@@ -4191,7 +4218,7 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_ns(
/// # Safety
/// `c` is a valid connection handle; `offset_ns` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
c: *const PunktfunkConnection,
offset_ns: *mut i64,
@@ -4225,7 +4252,7 @@ pub unsafe extern "C" fn punktfunk_connection_clock_offset_now_ns(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_mode(
c: *const PunktfunkConnection,
width: u32,
@@ -4261,7 +4288,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_mode(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
c: *const PunktfunkConnection,
) -> PunktfunkStatus {
@@ -4293,7 +4320,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_keyframe(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_request_rfi(
c: *const PunktfunkConnection,
first_frame: u32,
@@ -4328,7 +4355,7 @@ pub unsafe extern "C" fn punktfunk_connection_request_rfi(
/// # Safety
/// `c` is a valid connection handle; `gap_out` is writable or NULL.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
c: *const PunktfunkConnection,
frame_index: u32,
@@ -4362,7 +4389,7 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index(
/// # Safety
/// `c` is a valid connection handle; `gap_width_out` is writable or NULL.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
c: *const PunktfunkConnection,
frame_index: u32,
@@ -4396,7 +4423,7 @@ pub unsafe extern "C" fn punktfunk_connection_note_frame_index_ex(
/// # Safety
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
c: *const PunktfunkConnection,
out: *mut u64,
@@ -4441,7 +4468,7 @@ pub unsafe extern "C" fn punktfunk_connection_frames_dropped(
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
c: *const PunktfunkConnection,
us: u32,
@@ -4468,7 +4495,7 @@ pub unsafe extern "C" fn punktfunk_connection_report_decode_us(
/// `c` is an opaque handle from a `*_new`/`*_pair` the caller has not yet freed, or null (an
/// error, not UB).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_report_phase(
c: *const PunktfunkConnection,
next_latch_host_ns: u64,
@@ -4504,7 +4531,7 @@ pub unsafe extern "C" fn punktfunk_connection_report_phase(
/// # Safety
/// `c` is a valid connection handle; `out` is writable (NULL is skipped).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_wants_decode_latency(
c: *const PunktfunkConnection,
out: *mut bool,
@@ -4576,7 +4603,7 @@ pub struct PunktfunkProbeResult {
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_speed_test(
c: *const PunktfunkConnection,
target_kbps: u32,
@@ -4604,7 +4631,7 @@ pub unsafe extern "C" fn punktfunk_connection_speed_test(
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkProbeResult` (NULL is an
/// error).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_probe_result(
c: *const PunktfunkConnection,
out: *mut PunktfunkProbeResult,
@@ -4651,7 +4678,7 @@ pub unsafe extern "C" fn punktfunk_connection_probe_result(
/// # Safety
/// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`).
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkConnection) {
guard_void(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
@@ -4668,7 +4695,7 @@ pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkC
/// # Safety
/// `c` was returned by [`punktfunk_connect`] and is not used after this call.
#[cfg(feature = "quic")]
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connection_close(c: *mut PunktfunkConnection) {
guard_void(|| {
if !c.is_null() {
@@ -4694,7 +4721,7 @@ pub unsafe extern "C" fn punktfunk_connection_close(c: *mut PunktfunkConnection)
/// Create a re-anchor gate seeded with the session's current `frames_dropped` (so the first
/// [`punktfunk_reanchor_gate_poll`] doesn't read the baseline as a loss). Free with
/// [`punktfunk_reanchor_gate_free`]. Never returns NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub extern "C" fn punktfunk_reanchor_gate_new(frames_dropped: u64) -> *mut ReanchorGate {
Box::into_raw(Box::new(ReanchorGate::new(frames_dropped)))
}
@@ -4703,7 +4730,7 @@ pub extern "C" fn punktfunk_reanchor_gate_new(frames_dropped: u64) -> *mut Reanc
///
/// # Safety
/// `g` was returned by [`punktfunk_reanchor_gate_new`] and is not used after this call.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_free(g: *mut ReanchorGate) {
guard_void(|| {
if !g.is_null() {
@@ -4719,7 +4746,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_free(g: *mut ReanchorGate) {
///
/// # Safety
/// `g` is a valid gate handle.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
guard_void(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller has
@@ -4741,7 +4768,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm(g: *mut ReanchorGate) {
///
/// # Safety
/// `g` is a valid gate handle.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
g: *mut ReanchorGate,
expected_drops: u64,
@@ -4764,7 +4791,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
///
/// # Safety
/// `g` is a valid gate handle; `out_present` is writable or NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_decoded(
g: *mut ReanchorGate,
flags: u32,
@@ -4796,7 +4823,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_on_decoded(
///
/// # Safety
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_on_no_output(
g: *mut ReanchorGate,
out_request_kf: *mut bool,
@@ -4825,7 +4852,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_on_no_output(
///
/// # Safety
/// `g` is a valid gate handle; `out_request_kf` is writable or NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_poll(
g: *mut ReanchorGate,
frames_dropped: u64,
@@ -4854,7 +4881,7 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_poll(
///
/// # Safety
/// `g` is a valid gate handle; `out_holding` is writable or NULL.
#[no_mangle]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
g: *const ReanchorGate,
out_holding: *mut bool,
@@ -4877,6 +4904,30 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_is_holding(
mod tests {
use super::*;
/// A C embedder writing `ev->kind = 42` must come back as a status code, not UB. The test
/// stages the event in `MaybeUninit` storage so no `&InputEvent` to an invalid value ever
/// exists on the test's own side either.
#[test]
fn read_input_event_rejects_null_and_bad_discriminant() {
// SAFETY: null is the documented reported-not-UB case.
let null_result = unsafe { read_input_event(std::ptr::null()) };
assert_eq!(null_result.unwrap_err(), PunktfunkStatus::NullPointer);
let mut slot = core::mem::MaybeUninit::<InputEvent>::zeroed();
let p = slot.as_mut_ptr();
// SAFETY: writing one byte at offset 0 of aligned, sized storage.
unsafe { p.cast::<u8>().write(42) };
// SAFETY: `p` is aligned and readable for the full struct.
let bad_tag = unsafe { read_input_event(p) };
assert_eq!(bad_tag.unwrap_err(), PunktfunkStatus::InvalidArg);
// SAFETY: as above; tag 0 (KeyDown) + zeroed fields is a fully valid event.
unsafe { p.cast::<u8>().write(0) };
// SAFETY: as above.
let ev = unsafe { read_input_event(p) }.expect("valid tag must pass");
assert_eq!(ev.kind, crate::input::InputKind::KeyDown);
}
/// The `AudioCtl` → `PunktfunkHidOutput` mapping: kind 5, pad narrowed, `which` carries the
/// flags byte, `effect[0..6]` the raw audio region with `effect_len = 6` (the TrackpadHaptic
/// packing idiom — no struct growth, so the size guard above stays at 19).
@@ -176,9 +176,9 @@ impl DataPump {
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
// An applied re-sync invalidates the staleness run measured under the OLD offset:
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
let gen = pump_clock_gen.load(Ordering::Relaxed);
if gen != seen_clock_gen {
seen_clock_gen = gen;
let clock_gen = pump_clock_gen.load(Ordering::Relaxed);
if clock_gen != seen_clock_gen {
seen_clock_gen = clock_gen;
stale_since = None;
noop_clock_flushes = 0;
// Every OWD reading shifted with the offset — the standing-latency floor and
+1 -1
View File
@@ -162,7 +162,7 @@ impl ErasureCoder for Gf16Coder {
.iter()
.zip(have)
.enumerate()
.filter(|(_, (_, &h))| h)
.filter(|&(_, (_, &h))| h)
.map(|(i, (s, _))| (i, &**s))
.collect();
let restored = reed_solomon_simd::decode(
+12 -4
View File
@@ -417,13 +417,21 @@ mod tests {
/// --nocapture --test-threads=1`.
#[tokio::test]
#[ignore = "measurement: sets process env and takes ~15 s of wall clock"]
// Test-only exemption from the crate's `deny(unsafe_code)`: mutating the process
// environment is `unsafe` in edition 2024, and this measurement's env knob is the
// documented single-threaded kind.
#[allow(unsafe_code)]
async fn mtu_discovery_climbs_only_as_high_as_the_peer_advertises() {
async fn climb(server_jumbo: bool, client_jumbo: bool) -> (u16, u128) {
let set = |on: bool| {
if on {
std::env::set_var("PUNKTFUNK_JUMBO", "1");
} else {
std::env::remove_var("PUNKTFUNK_JUMBO");
// SAFETY: this `#[ignore]`d measurement is documented above to run alone with
// `--test-threads=1`, so no concurrent thread reads or writes the environment.
unsafe {
if on {
std::env::set_var("PUNKTFUNK_JUMBO", "1");
} else {
std::env::remove_var("PUNKTFUNK_JUMBO");
}
}
};
set(server_jumbo);
@@ -72,7 +72,7 @@ const _: () = {
};
#[cfg(target_vendor = "apple")]
extern "C" {
unsafe extern "C" {
/// Darwin batched receive: up to `cnt` datagrams in one syscall; returns the count received and
/// sets each `msg_datalen` to its byte length. Present in libSystem on all macOS/iOS.
fn recvmsg_x(
@@ -16,7 +16,7 @@ mod android_mmsg {
pub msg_hdr: libc::msghdr,
pub msg_len: libc::c_uint,
}
extern "C" {
unsafe extern "C" {
pub fn sendmmsg(
sockfd: libc::c_int,
msgvec: *mut mmsghdr,
+131 -42
View File
@@ -7,14 +7,22 @@
//!
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
//! known host MAC we send the 102-byte packet to:
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
//! * the **limited broadcast** `255.255.255.255`, and
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
//! known host MAC we send the 102-byte packet:
//! * **out of every non-loopback IPv4 interface**, from a socket bound to that interface's own
//! address, to both that NIC's **subnet-directed broadcast** and the **limited broadcast**
//! `255.255.255.255` — binding the source is what forces the datagram onto that segment
//! instead of whatever the default route happens to be (a VPN/mesh interface, typically), and
//! * from an unbound socket to `255.255.255.255` and, when known, a **unicast** to the host's
//! last-known IP (covers the brief window where the host is reachable but hasn't
//! re-advertised, and NICs that wake on a directed unicast),
//!
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
//!
//! **Wi-Fi hosts (WoWLAN) ride the same path**, and the per-interface egress above is what makes
//! them work: a station in WoWLAN sleep stays associated, and the AP buffers broadcast frames for
//! its sleeping stations and flushes them on the next DTIM beacon — so the broadcast does reach
//! the sleeping NIC, but only if the datagram actually leaves via the wireless interface. The
//! host end of it (arming the NIC's magic-packet trigger) is `punktfunk-host`'s `wol` module.
use std::io;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
@@ -64,41 +72,63 @@ pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
/// could be opened or nothing could be sent at all.
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
send_magic_packet_on(macs, last_known_ip, &WOL_PORTS)
}
/// [`send_magic_packet`] with the destination ports spelled out. Private because the ports are
/// not a caller's business — it exists so the tests can aim a real send at a port they're allowed
/// to bind (9 and 7 are privileged) and assert the bytes that come off the wire.
fn send_magic_packet_on(
macs: &[Mac],
last_known_ip: Option<Ipv4Addr>,
ports: &[u16],
) -> io::Result<()> {
if macs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"no MAC addresses",
));
}
let packets: Vec<[u8; 102]> = macs.iter().map(|m| build_magic_packet(*m)).collect();
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
let mut targets = broadcast_addrs();
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
// Targets that go out the default route (or wherever the routing table sends them): the
// limited broadcast as a baseline, plus the optional unicast — destination routing picks the
// right NIC for a unicast, so it doesn't need per-interface treatment.
let mut routed: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
if let Some(ip) = last_known_ip {
targets.push(ip);
routed.push(ip);
}
targets.sort_unstable();
targets.dedup();
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
// matching NIC via the routing table; the limited broadcast leaves via the default route.
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
sock.set_broadcast(true)?;
let mut sent_any = false;
for _ in 0..BURST {
for mac in macs {
let pkt = build_magic_packet(*mac);
for ip in &targets {
for port in WOL_PORTS {
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
if sock.send_to(&pkt, dst).is_ok() {
sent_any = true;
}
}
}
// Per-interface pass. One socket per non-loopback IPv4 address, bound to that address so the
// datagram leaves on THAT segment: without this, `255.255.255.255` follows the default route
// only (a VPN/mesh NIC on most of these machines) and never touches the LAN — or the Wi-Fi
// segment the sleeping WoWLAN station is associated to.
for (local, bcast) in local_v4_segments() {
let Ok(sock) = UdpSocket::bind(SocketAddrV4::new(local, 0)) else {
// Bind failed (address just went away, or the OS refuses it) — fall back to the
// routed socket below, which still reaches this segment's directed broadcast.
routed.push(bcast);
continue;
};
if sock.set_broadcast(true).is_err() {
routed.push(bcast);
continue;
}
sent_any |= blast(&sock, &packets, &[bcast, Ipv4Addr::BROADCAST], ports);
}
// Routed pass, and the only pass on a machine whose interfaces can't be enumerated.
if let Ok(sock) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
// A refused SO_BROADCAST doesn't abort the pass: the unicast target still goes out, and
// the per-interface sockets above may already have carried the broadcast.
let _ = sock.set_broadcast(true);
routed.sort_unstable();
routed.dedup();
sent_any |= blast(&sock, &packets, &routed, ports);
} else if !sent_any {
return Err(io::Error::other("no socket could be opened for the wake"));
}
if sent_any {
@@ -108,10 +138,33 @@ pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::R
}
}
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
fn broadcast_addrs() -> Vec<Ipv4Addr> {
/// Send every packet to every target, on every port, [`BURST`] times. Returns whether any
/// single datagram made it out — an unroutable target is expected and never fails the wake.
fn blast(sock: &UdpSocket, packets: &[[u8; 102]], targets: &[Ipv4Addr], ports: &[u16]) -> bool {
let mut sent_any = false;
for _ in 0..BURST {
for pkt in packets {
for ip in targets {
// A degenerate 0.0.0.0 (unconfigured NIC) is not a destination.
if ip.is_unspecified() {
continue;
}
for port in ports {
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, *port));
if sock.send_to(pkt, dst).is_ok() {
sent_any = true;
}
}
}
}
}
sent_any
}
/// Every non-loopback IPv4 interface as `(its own address, its subnet-directed broadcast)`. The
/// broadcast is the OS-provided one where present, else `ip | !netmask`. Best-effort: enumeration
/// failing (permissions, exotic platform) yields an empty list and the routed pass still fires.
fn local_v4_segments() -> Vec<(Ipv4Addr, Ipv4Addr)> {
let mut out = Vec::new();
let ifaces = match if_addrs::get_if_addrs() {
Ok(i) => i,
@@ -122,14 +175,13 @@ fn broadcast_addrs() -> Vec<Ipv4Addr> {
continue;
}
if let if_addrs::IfAddr::V4(v4) = iface.addr {
if v4.ip.is_unspecified() {
continue; // nothing to bind to
}
let bcast = v4
.broadcast
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
// already add unconditionally.
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
out.push(bcast);
}
out.push((v4.ip, bcast));
}
}
out
@@ -183,10 +235,47 @@ mod tests {
}
#[test]
fn broadcast_addrs_never_contains_limited_or_unspecified() {
for b in broadcast_addrs() {
assert_ne!(b, Ipv4Addr::BROADCAST);
assert!(!b.is_unspecified());
fn local_segments_are_bindable_and_have_a_broadcast() {
for (local, bcast) in local_v4_segments() {
// The local address is what we bind the per-interface socket to, so it must be a
// real address — and it must never be the loopback (filtered) or unspecified.
assert!(!local.is_unspecified());
assert!(!local.is_loopback());
assert!(!bcast.is_unspecified());
// Binding to an address the OS just reported must work; a failure here would mean
// the per-interface pass silently degrades to the routed one.
assert!(UdpSocket::bind(SocketAddrV4::new(local, 0)).is_ok());
}
}
#[test]
fn blast_reports_nothing_sent_for_an_empty_target_list() {
let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback");
let pkt = [build_magic_packet([1, 2, 3, 4, 5, 6])];
assert!(!blast(&sock, &pkt, &[], &WOL_PORTS));
// An unconfigured 0.0.0.0 target is skipped rather than sent to.
assert!(!blast(&sock, &pkt, &[Ipv4Addr::UNSPECIFIED], &WOL_PORTS));
// Loopback is a real destination — this one must go out.
assert!(blast(&sock, &pkt, &[Ipv4Addr::LOCALHOST], &[9999]));
}
/// The whole send path, end to end: a real receiver gets a real magic packet with the right
/// bytes. Aimed at loopback on an unprivileged port (WoL's own 9 and 7 need root to bind),
/// which exercises the routed pass's unicast leg — the one a WoWLAN host is woken by when
/// the AP filters broadcast to sleeping stations.
#[test]
fn send_delivers_the_magic_packet_to_a_listener() {
let rx = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind receiver");
let port = rx.local_addr().expect("local addr").port();
rx.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.expect("read timeout");
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
send_magic_packet_on(&[mac], Some(Ipv4Addr::LOCALHOST), &[port]).expect("send");
let mut buf = [0u8; 256];
let (n, _from) = rx.recv_from(&mut buf).expect("a magic packet must arrive");
assert_eq!(n, 102);
assert_eq!(buf[..102], build_magic_packet(mac));
}
}
+35 -7
View File
@@ -11,24 +11,52 @@ profile="${1:-debug}"
build_flag=""
[ "$profile" = "release" ] && build_flag="--release"
echo ">> building punktfunk-core staticlib ($profile)"
cargo build -p punktfunk-core $build_flag >/dev/null
# PF_SAN=address instruments BOTH sides of the C boundary at once: the staticlib via
# -Zsanitizer (nightly + -Zbuild-std, so std itself is instrumented) and the harness via
# clang -fsanitize. LSAN rides along (detect_leaks=1) and is the only automated check on
# the Box::into_raw/from_raw leak contract in abi.rs. Linux x86_64 only; -Zbuild-std
# defeats sccache, so this belongs on a cron/dispatch job, not the per-push leg.
san="${PF_SAN:-}"
toolchain=""
target_args=""
target_sub=""
if [ -n "$san" ]; then
san_target="x86_64-unknown-linux-gnu"
# -Zsanitizer/-Zbuild-std need a nightly; PF_SAN_TOOLCHAIN pins a dated one (CI does).
toolchain="+${PF_SAN_TOOLCHAIN:-nightly}"
target_args="-Z build-std --target $san_target"
target_sub="$san_target/"
export RUSTFLAGS="-Zsanitizer=$san${RUSTFLAGS:+ $RUSTFLAGS}"
fi
staticlib="$ws/target/$profile/libpunktfunk_core.a"
echo ">> building punktfunk-core staticlib ($profile${san:+, sanitizer=$san})"
cargo $toolchain build $target_args -p punktfunk-core $build_flag >/dev/null
staticlib="$ws/target/${target_sub}$profile/libpunktfunk_core.a"
header_dir="$ws/include"
[ -f "$staticlib" ] || { echo "missing $staticlib"; exit 1; }
[ -f "$header_dir/punktfunk_core.h" ] || { echo "missing generated header"; exit 1; }
# Ask rustc what native libs the staticlib needs to link into a C program.
native_libs="$(cargo rustc -p punktfunk-core --lib --crate-type staticlib $build_flag -- \
native_libs="$(cargo $toolchain rustc $target_args -p punktfunk-core --lib --crate-type staticlib $build_flag -- \
--print native-static-libs 2>&1 | sed -n 's/.*native-static-libs: //p' | tail -1)"
echo ">> native libs: ${native_libs:-<none>}"
out="$(mktemp -d)/punktfunk_harness"
# Not mktemp: a debug+ASAN static binary can exceed a tmpfs /tmp; target/ is real disk.
out="$ws/target/${target_sub}$profile/punktfunk_harness"
cc="${CC:-cc}"
cflags=""
if [ -n "$san" ]; then
cc="${CC:-clang}"
cflags="-fsanitize=$san -fno-omit-frame-pointer"
fi
echo ">> compiling + linking harness"
$cc -std=c11 -Wall -Wextra -O2 -I "$header_dir" \
$cc -std=c11 -Wall -Wextra -O2 $cflags ${CFLAGS:-} -I "$header_dir" \
"$here/harness.c" "$staticlib" $native_libs -o "$out"
echo ">> running"
"$out"
if [ -n "$san" ]; then
ASAN_OPTIONS="detect_leaks=1${ASAN_OPTIONS:+:$ASAN_OPTIONS}" "$out"
else
"$out"
fi
+1 -1
View File
@@ -12,7 +12,7 @@
[package]
name = "punktfunk-encode-worker"
version.workspace = true
edition = "2021"
edition.workspace = true
rust-version.workspace = true
license = "MIT OR Apache-2.0"
description = "punktfunk PyroWave encode worker: owns the priority-elevated Vulkan device so the host never carries a capability."
+4
View File
@@ -183,6 +183,10 @@ pub fn open_virtual_mic(_channels: u32) -> Result<Box<dyn VirtualMic>> {
mod audio_control;
#[cfg(target_os = "linux")]
mod linux;
// DualSense pad-audio sink + capture, the Linux analogue of `pad_endpoint` below: the session
// layer mints per-pad sinks and the CLI exposes the `pad-sink-test` devtest.
#[cfg(target_os = "linux")]
pub(crate) use linux::pad_sink;
// DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio).
// pub(crate): the session layer queries endpoints by pad index and the CLI exposes the
// `pad-endpoint` devtest.
@@ -27,6 +27,7 @@
//! surround session can replace a stereo capturer without leaking a PipeWire consumer (see
//! CLAUDE.md: a wedged link head-blocks the daemon).
pub(crate) mod pad_sink;
mod stream_sink;
use super::{AudioCapturer, MicBackendStats, VirtualMic, SAMPLE_RATE};
@@ -0,0 +1,452 @@
//! Per-pad DualSense audio sink (Linux): one PipeWire `Audio/Sink` stream node per
//! DualSense-family pad, wearing the identity DS5-native titles and GE-Proton's
//! controller-audio routing match on — so a game that renders voice-coil haptics or pad-speaker
//! audio finds "the controller's audio device" and plays into us. We own the sink, so the
//! `process()` callback IS the capture: 4-ch F32 48 kHz (FL FR RL RR — front pair = speaker,
//! back pair = voice coils, the same quad layout the Windows endpoint is stamped with) lands
//! directly in the chunk channel that feeds the 0xD1 lanes (`native/pad_audio.rs`).
//!
//! Modeled on the stream-sink mode of [`super::PwAudioCapturer`] (same MainLoop-on-a-thread,
//! Terminate channel, ready handshake, bounded lossy chunk hand-off) with two deliberate
//! differences: **no default-sink claim** (nothing may auto-route here — games target it BY
//! IDENTITY) and a low `priority.session` so WirePlumber never elects it against real hardware.
//!
//! **Identity** (design `dualsense-audio-haptics-and-speaker.md` §3/§5): GE-Proton 11-2+
//! matches layered — pulse proplist (`device.bus == "usb"`, `device.vendor.id == 0x054c`,
//! `device.product.id ∈ {0x0ce6, 0x0df2}`), then name substrings
//! (`Sony_Interactive_Entertainment…Wireless_Controller`, `DualSense`); the community
//! WirePlumber rule keys on the node-name substring and sets `node.description =
//! "Wireless Controller"` (we mint it that way from the start). A pure PipeWire node cannot
//! satisfy wine's ContainerId derivation (udev walk to a `usb_device` parent → `GUID_NULL`)
//! nor GE's raw-ALSA fast path — both fall back to the Pulse-routed leg, which winepulse
//! serves from exactly this node (it enumerates sinks). Every identity string has an env
//! override for field debugging (`PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC`, with
//! `{pad}` / `{mac}` placeholders).
use anyhow::{anyhow, Context, Result};
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
use std::thread;
use std::time::Duration;
/// Message asking the PipeWire loop thread to quit (sent from `Drop`).
struct Terminate;
/// The pad sink's fixed channel count — quad, mirroring the Windows endpoint stamp
/// (`native/pad_audio.rs::CAP_CHANNELS` splits on the same layout).
const PAD_CHANNELS: u32 = 4;
/// How many pad slots may carry a sink (`PUNKTFUNK_PAD_AUDIO_SLOTS`, default all 4 — a PipeWire
/// stream node is cheap, unlike the Windows devnode mint whose default is 1).
pub(crate) fn pad_audio_slots() -> u8 {
std::env::var("PUNKTFUNK_PAD_AUDIO_SLOTS")
.ok()
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(4)
.clamp(1, 4)
}
/// Whether a PipeWire daemon is plausibly reachable from this process — the Linux analogue of
/// "startup provisioning published at least one endpoint" for [`host_cap`]'s existence leg
/// (`native/pad_audio.rs`). A stat, not a connect: the handshake path runs per-Hello and must
/// not block. `PIPEWIRE_REMOTE` names a non-default socket — trust it (the session capturer
/// honors it via libpipewire, and a wrong value degrades to spawn-time failure, pad kept).
pub(crate) fn pipewire_reachable() -> bool {
if std::env::var_os("PIPEWIRE_REMOTE").is_some() {
return true;
}
std::env::var_os("XDG_RUNTIME_DIR")
.map(|dir| std::path::Path::new(&dir).join("pipewire-0").exists())
.unwrap_or(false)
}
/// The pad's virtual MAC as colon-separated display hex — [`ds_pairing_reply`]'s bytes 1..7
/// are LSB-first (the report layout `hid-playstation` adopts as the HID `uniq` via `%pMR`,
/// i.e. printed reversed), so the display form reverses them. Unique per pad (the low octet
/// carries the pad index), which keeps multi-pad sinks distinct for the same reason the MAC
/// itself must be: SDL/Steam and the matchers dedup by serial.
///
/// [`ds_pairing_reply`]: pf_inject::dualsense_proto::ds_pairing_reply
fn pad_mac(pad: u8) -> String {
let reply = crate::inject::dualsense_proto::ds_pairing_reply(pad);
let m = &reply[1..7];
format!(
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
m[5], m[4], m[3], m[2], m[1], m[0]
)
}
/// Expand the `{pad}` / `{mac}` placeholders of an identity template. Callers pass the MAC in
/// the form the surrounding string wants: colon display form for proplist values, bare hex for
/// the ALSA-style node name (udev serials carry no colons).
fn expand(template: &str, pad: u8, mac: &str) -> String {
template
.replace("{pad}", &pad.to_string())
.replace("{mac}", mac)
}
/// The full identity a pad sink wears, resolved once at open.
struct PadSinkIdentity {
node_name: String,
description: String,
serial: String,
product_id: &'static str,
product_name: &'static str,
}
impl PadSinkIdentity {
fn new(pad: u8, edge: bool) -> PadSinkIdentity {
let mac = pad_mac(pad);
let mac_bare: String = mac.chars().filter(|c| *c != ':').collect();
let (model, product_id, product_name) = if edge {
(
"DualSense_Edge",
"0df2",
"DualSense Edge Wireless Controller",
)
} else {
("DualSense", "0ce6", "DualSense Wireless Controller")
};
// The ALSA-style name a REAL pad's card gets from udev (vendor_product_serial), which
// is what every known name-substring matcher was written against. `-00.analog-surround-40`
// = card profile suffix for the quad layout.
let node_name = match std::env::var("PUNKTFUNK_PAD_SINK_NAME") {
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac_bare),
_ => format!(
"alsa_output.usb-Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}-00.analog-surround-40"
),
};
// What the community WirePlumber rule renames real pads TO — minted that way directly.
let description = match std::env::var("PUNKTFUNK_PAD_SINK_DESC") {
Ok(t) if !t.trim().is_empty() => expand(&t, pad, &mac),
_ => "Wireless Controller".to_string(),
};
PadSinkIdentity {
node_name,
description,
serial: format!(
"Sony_Interactive_Entertainment_{model}_Wireless_Controller_{mac_bare}"
),
product_id,
product_name,
}
}
}
/// A live per-pad sink + its capture. Same next-chunk contract as every
/// [`AudioCapturer`](crate::audio::AudioCapturer): empty chunk = quiet sink (keep me), `Err` =
/// dead loop thread (reopen me). Dropping tears the sink node down promptly via the Terminate
/// channel (a wedged PipeWire link head-blocks the daemon — see the session capturer's docs).
pub struct PadSinkCapturer {
chunks: Receiver<Vec<f32>>,
quit: pipewire::channel::Sender<Terminate>,
/// The minted node name, for logs and the devtest.
pub node_name: String,
}
impl PadSinkCapturer {
/// Mint the sink for wire pad `pad` (`edge` = DualSense Edge identity) and start capturing.
/// Fails if PipeWire is unreachable — the caller's reopen-with-backoff owns the retry.
pub fn open(pad: u8, edge: bool) -> Result<PadSinkCapturer> {
let identity = PadSinkIdentity::new(pad, edge);
let node_name = identity.node_name.clone();
let (tx, rx) = sync_channel::<Vec<f32>>(64);
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
// Bring-up handshake (the session capturer's discipline): a PipeWire that isn't running
// must surface as an open ERROR, engaging the caller's backoff — not a zombie thread.
let (ready_tx, ready_rx) = sync_channel::<Result<()>>(1);
thread::Builder::new()
.name(format!("punktfunk-pw-pad{pad}"))
.spawn(move || {
if let Err(e) = pad_sink_thread(tx, quit_rx, identity, ready_tx) {
tracing::warn!(pad, error = %format!("{e:#}"), "pipewire pad-sink thread failed");
}
})
.context("spawn pipewire pad-sink thread")?;
match ready_rx.recv_timeout(Duration::from_secs(5)) {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => return Err(anyhow!("pipewire pad-sink init timed out")),
}
Ok(PadSinkCapturer {
chunks: rx,
quit: quit_tx,
node_name,
})
}
}
impl Drop for PadSinkCapturer {
fn drop(&mut self) {
// A failed send means the loop thread already exited — nothing to tear down.
let _ = self.quit.send(Terminate);
}
}
impl crate::audio::AudioCapturer for PadSinkCapturer {
fn next_chunk(&mut self) -> Result<Vec<f32>> {
match self.chunks.recv_timeout(Duration::from_secs(5)) {
Ok(c) => Ok(c),
// A quiet pad sink (no game rendering pad audio — the common case) is NOT a
// failure; the per-pad streamer keeps us and its silence gate stays closed.
Err(RecvTimeoutError::Timeout) => Ok(Vec::new()),
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pipewire pad-sink thread ended")),
}
}
fn channels(&self) -> u32 {
PAD_CHANNELS
}
}
/// SPA channel positions for the pad quad: AUX0..AUX3 (`enum spa_audio_channel`:
/// `SPA_AUDIO_CHANNEL_START_Aux` = 0x1000), NOT a positioned FL FR RL RR layout. This is the
/// shape a REAL DualSense exposes on the PipeWire path GE-Proton's haptics were built and
/// field-validated against: its `open_dualsense_haptic_pcm` targets the node through the
/// bundled pipewire-alsa plugin with `aux_channels=1` — "the hidden PipeWire parent for a
/// DualSense output exposes AUX0 through AUX3" (proton-ds5-haptic patch 0115) — and its pulse
/// fallback forces a `PA_CHANNEL_POSITION_AUX0..3` map. On a real pad that shape is the card's
/// Pro Audio profile (the community-reported requirement for GE ≥11-4). Aux positions carry no
/// spatial meaning, so nothing in the graph position-remixes into (or out of) the sink —
/// writers land by INDEX, exactly the raw quad the pad speaks: ch0/1 = speaker, ch2/3 = voice
/// coils (the same order the Windows endpoint is stamped with and `split_quad` assumes).
fn pad_positions() -> [u32; 64] {
const AUX0: u32 = 0x1000;
let mut pos = [0u32; 64];
pos[..4].copy_from_slice(&[AUX0, AUX0 + 1, AUX0 + 2, AUX0 + 3]);
pos
}
/// The `!Send` MainLoop/Stream thread: mint the sink, hand capture chunks over, run until
/// Terminate / daemon death. Mirrors the session capturer's `pw_thread` stream-sink arm minus
/// the default-sink claim and the desktop-plane stats (the pad plane's observability lives in
/// the streamer's gate/encode logs).
fn pad_sink_thread(
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
quit_rx: pipewire::channel::Receiver<Terminate>,
identity: PadSinkIdentity,
ready: std::sync::mpsc::SyncSender<Result<()>>,
) -> Result<()> {
use pipewire as pw;
use pw::{properties::properties, spa};
use spa::param::audio::{AudioFormat, AudioInfoRaw};
use spa::pod::Pod;
let result = (|| -> Result<()> {
pf_capture::pwinit::ensure_init();
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw pad-sink MainLoop")?;
let context =
pw::context::ContextRc::new(&mainloop, None).context("pw pad-sink Context")?;
let core = context
.connect_rc(None)
.context("pw pad-sink connect (is PipeWire running in this session?)")?;
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
move |_| mainloop.quit()
});
// Daemon death ends this thread → the chunk channel disconnects → `next_chunk` errors →
// the per-pad streamer reopens with backoff (the session capturer's zombie-thread fix).
let _core_listener = core
.add_listener_local()
.error({
let mainloop = mainloop.clone();
move |id, _seq, res, message| {
tracing::warn!(id, res, message, "pipewire core error — pad sink ends");
mainloop.quit();
}
})
.register();
let mut props = properties! {
*pw::keys::MEDIA_TYPE => "Audio",
*pw::keys::MEDIA_CLASS => "Audio/Sink",
// One Opus-haptics frame (~5 ms) per quantum, like the session sink — haptics are
// felt latency; bursty delivery would ride through to the client's jitter buffer.
*pw::keys::NODE_LATENCY => "240/48000",
// Must NEVER win WirePlumber's default election against real hardware — games reach
// this sink BY IDENTITY, nothing auto-routes here (no stream_sink claim either).
"priority.session" => "50",
// The pulse-proplist leg of GE-Proton's match (§3): bus + vendor/product ids, plus
// the human-readable pair pavucontrol and the game view show.
"device.bus" => "usb",
"device.vendor.id" => "054c",
"device.vendor.name" => "Sony Interactive Entertainment",
"device.form_factor" => "gamepad",
};
props.insert(*pw::keys::NODE_NAME, identity.node_name.as_str());
props.insert(*pw::keys::NODE_DESCRIPTION, identity.description.as_str());
props.insert(*pw::keys::NODE_NICK, identity.description.as_str());
props.insert("device.serial", identity.serial.as_str());
props.insert("device.product.id", identity.product_id);
props.insert("device.product.name", identity.product_name);
let stream = pw::stream::StreamBox::new(&core, "punktfunk-pad-audio", props)
.context("pw pad-sink Stream")?;
// Lossy-drop counter: a full channel means the 0xD1 encode thread stalled. Invisible
// drops cost a field investigation on the desktop plane once — count and warn here too,
// power-of-two throttled (this callback runs at the graph quantum).
struct PadUd {
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
dropped: u64,
}
let ud = PadUd { tx, dropped: 0 };
let _listener = stream
.add_local_listener_with_user_data(ud)
.state_changed({
let mainloop = mainloop.clone();
move |_s, _ud, old, new| {
tracing::debug!(?old, ?new, "pipewire pad-sink stream state");
if matches!(new, pw::stream::StreamState::Error(_)) {
mainloop.quit();
}
}
})
.param_changed(move |_stream, _ud, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let mut info = AudioInfoRaw::default();
if info.parse(param).is_ok() {
// We own the sink, so this IS the format games render into (nothing can
// have narrowed it upstream — the same guarantee as stream-sink mode).
tracing::info!(
format = ?info.format(),
rate = info.rate(),
channels = info.channels(),
"pad-sink format negotiated"
);
}
})
.process(|stream, ud| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let Some(mut buffer) = stream.dequeue_buffer() else {
return;
};
let datas = buffer.datas_mut();
if datas.is_empty() {
return;
}
let d = &mut datas[0];
let (offset, size) = {
let c = d.chunk();
(c.offset() as usize, c.size() as usize)
};
let Some(buf) = d.data() else { return };
if offset > buf.len() {
return;
}
let region = &buf[offset..(offset + size).min(buf.len())];
// Negotiated as F32LE; reinterpret the byte region as interleaved f32.
let n = region.len() / 4;
let mut samples = Vec::with_capacity(n);
for i in 0..n {
let b = [
region[i * 4],
region[i * 4 + 1],
region[i * 4 + 2],
region[i * 4 + 3],
];
samples.push(f32::from_le_bytes(b));
}
if ud.tx.try_send(samples).is_err() {
ud.dropped += 1;
if ud.dropped.is_power_of_two() {
tracing::warn!(
dropped = ud.dropped,
"pad-audio encode thread not keeping up — captured pad audio \
dropped (haptics will click)"
);
}
}
}));
if outcome.is_err() {
tracing::error!("panic in pipewire pad-sink callback — chunk dropped");
}
})
.register()
.context("register pad-sink stream listener")?;
let mut info = AudioInfoRaw::new();
info.set_format(AudioFormat::F32LE);
info.set_rate(crate::audio::SAMPLE_RATE);
info.set_channels(PAD_CHANNELS);
info.set_position(pad_positions());
let obj = pw::spa::pod::Object {
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
properties: info.into(),
};
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
std::io::Cursor::new(Vec::new()),
&pw::spa::pod::Value::Object(obj),
)
.context("serialize pad-sink format pod")?
.0
.into_inner();
let mut params = [Pod::from_bytes(&values).context("pad-sink pod from bytes")?];
// RT_PROCESS for the same reason as every host-owned stream node here: the sink must be
// a synchronous graph member that joins its producers' driver group, or `process()`
// never fires on a busy graph (see the mic's connect comment in mod.rs).
stream
.connect(
spa::utils::Direction::Input, // we CONSUME what games render into the sink
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.context("pw pad-sink stream connect")?;
let _ = ready.send(Ok(()));
mainloop.run();
tracing::debug!("pipewire pad-sink loop exited (capturer dropped)");
Ok(())
})();
if let Err(e) = &result {
let _ = ready.send(Err(anyhow!("{e:#}")));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pad_mac_is_reversed_display_form_and_per_pad_unique() {
// DS_FEATURE_PAIRING bytes 1..7 are 74 E7 D6 3A 53 35 LSB-first → display reverses.
assert_eq!(pad_mac(0), "35:53:3A:D6:E7:74");
// The pad index offsets the LOW octet — the LAST display octet.
assert_eq!(pad_mac(1), "35:53:3A:D6:E7:75");
assert_ne!(pad_mac(2), pad_mac(3));
}
#[test]
fn identity_carries_every_match_surface() {
let id = PadSinkIdentity::new(0, false);
// The name-substring matchers (GE-Proton + the community WirePlumber rule).
assert!(id.node_name.contains("Sony_Interactive_Entertainment"));
assert!(id.node_name.contains("Wireless_Controller"));
assert!(id.node_name.contains("DualSense"));
assert!(id.node_name.ends_with("-00.analog-surround-40"));
// No colons in a udev-style serial/name.
assert!(!id.node_name.contains(':'));
assert_eq!(id.description, "Wireless Controller");
assert_eq!(id.product_id, "0ce6");
let edge = PadSinkIdentity::new(1, true);
assert!(edge.node_name.contains("DualSense_Edge"));
assert_eq!(edge.product_id, "0df2");
// Distinct pads mint distinct names (the serial octet).
assert_ne!(id.node_name, PadSinkIdentity::new(1, false).node_name);
}
#[test]
fn template_expansion() {
assert_eq!(expand("pad{pad}-{mac}", 2, "AABB"), "pad2-AABB");
assert_eq!(expand("static", 0, "x"), "static");
}
}
+120 -15
View File
@@ -27,7 +27,7 @@
use super::pad_endpoint as pe;
use super::{audio_control, wiring_plan};
use anyhow::{bail, Context, Result};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
@@ -40,6 +40,17 @@ const ENDPOINT_WAIT: Duration = Duration::from_secs(15);
/// Minimum spacing between provisioning retries once the startup attempt failed
/// ([`ensure_provisioned`] is called from wiring passes, which recur freely).
const RETRY_COOLDOWN: Duration = Duration::from_secs(60);
/// Full passes that ended unlatched before minting gives up for this host lifetime (a service
/// restart re-arms). An unlatched pass that reaches the PnP surface costs the whole BOX, not
/// just us: the driver (re)bind raises a device-change broadcast every running app services,
/// and games rebuild their audio graph on it — a box that cannot mint must not pay that on
/// every retry forever (field-measured 2026-08-12 as Helldivers 2 hitching to 25 FPS 1% lows,
/// one hitch per mic-pump reopen).
const MAX_UNLATCHED_ATTEMPTS: u32 = 5;
/// How long [`ensure_blocking`] waits on a pass another thread already runs before giving the
/// wiring plan the unlatched answer (a full cold-boot pass worst-cases around two
/// [`ENDPOINT_WAIT`]s plus the stamp settles).
const BLOCKING_WAIT: Duration = Duration::from_secs(90);
/// The two minted roles. `value` is the persisted marker; the needles drive
/// [`discover_driver`].
@@ -107,6 +118,26 @@ static PROVISIONED: OnceLock<Arc<MintedAudio>> = OnceLock::new();
static PROVISIONING: AtomicBool = AtomicBool::new(false);
/// When the last attempt STARTED — the [`RETRY_COOLDOWN`] anchor.
static LAST_ATTEMPT: Mutex<Option<Instant>> = Mutex::new(None);
/// Completed passes that did not latch, across the worker and the blocking path — the
/// [`MAX_UNLATCHED_ATTEMPTS`] give-up counter.
static UNLATCHED_ATTEMPTS: AtomicU32 = AtomicU32::new(0);
/// Count one finished-but-unlatched pass; the crossing attempt logs the give-up exactly once.
fn record_unlatched_attempt() {
let n = UNLATCHED_ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
if n == MAX_UNLATCHED_ATTEMPTS {
tracing::warn!(
attempts = n,
"minted-audio provisioning keeps failing — giving up for this host lifetime so \
retries stop broadcasting device changes at the whole box; the wiring plan keeps \
the name-based ladder, a service restart re-arms minting"
);
}
}
fn gave_up() -> bool {
UNLATCHED_ATTEMPTS.load(Ordering::SeqCst) >= MAX_UNLATCHED_ATTEMPTS
}
/// The wiring plan's tier-0 input: the minted ids, or all-empty while nothing is provisioned.
///
@@ -135,7 +166,7 @@ pub(crate) fn provisioned() -> Option<Arc<MintedAudio>> {
/// Spawn the provisioning worker (idempotent; returns immediately). Called at host start next
/// to the pad provider, and again from [`ensure_provisioned`] on the retry path.
pub(crate) fn provision_at_startup() {
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() {
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || gave_up() {
return;
}
if PROVISIONED.get().is_some() || PROVISIONING.swap(true, Ordering::SeqCst) {
@@ -155,13 +186,19 @@ pub(crate) fn provision_at_startup() {
);
let _ = PROVISIONED.set(Arc::new(m));
}
Ok(_) => tracing::info!(
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
wiring plan keeps the name-based ladder"
),
Err(e) => tracing::warn!(error = %format!("{e:#}"),
"minted-audio provisioning failed — the wiring plan keeps the name-based \
ladder and a later wiring pass retries"),
Ok(_) => {
tracing::info!(
"no minted audio endpoints (Steam's streaming drivers absent?) — the \
wiring plan keeps the name-based ladder"
);
record_unlatched_attempt();
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"),
"minted-audio provisioning failed — the wiring plan keeps the name-based \
ladder and a later wiring pass retries");
record_unlatched_attempt();
}
}
PROVISIONING.store(false, Ordering::SeqCst);
});
@@ -175,7 +212,7 @@ pub(crate) fn provision_at_startup() {
/// [`RETRY_COOLDOWN`] — a box where Steam arrives later mints on a later pass instead of at
/// the next reboot.
pub(crate) fn ensure_provisioned() {
if PROVISIONED.get().is_some() {
if PROVISIONED.get().is_some() || gave_up() {
return;
}
{
@@ -219,6 +256,19 @@ fn ensure_all() -> Result<MintedAudio> {
/// back any default device the fresh endpoint grabbed (measured on the pad program: a newly
/// registered endpoint can take either default).
fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
// Steady state: a previous run's devnode with all endpoints live — resolve by marker and
// return without touching PnP or the default-device policy. The full pass below (re)binds
// the driver even over an existing devnode, and that bind raises a device-change broadcast
// every running app services — right at first mint, ruinous from a retry path (each
// broadcast makes games rebuild their audio graph; see [`MAX_UNLATCHED_ATTEMPTS`]).
if let Some((devnode, render, capture)) = find_healthy_role(role)? {
stamp_identity(&render, role, false);
if let Some(cap) = capture.as_ref() {
stamp_identity(cap, role, true);
}
return Ok((devnode, render, capture));
}
let prev_render = audio_control::default_render_id();
let prev_capture = audio_control::default_capture_id();
@@ -284,6 +334,27 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
Ok((devnode, render, capture))
}
/// The role's marker devnode with EVERY endpoint the role owes already registered, or `None`
/// (missing devnode, missing endpoint, or an enumeration error → the caller runs the full
/// pass). Same endpoint resolvers [`wait_for`] polls, so "healthy" here is exactly the state
/// the full pass would declare ready.
fn find_healthy_role(role: Role) -> Result<Option<(String, String, Option<String>)>> {
let Some(devnode) = find_role_devnode(role)? else {
return Ok(None);
};
let Some(render) = pe::find_endpoint_for_devnode(&devnode)? else {
return Ok(None);
};
let capture = match role {
Role::Mic => match pe::find_capture_endpoint_for_devnode(&devnode)? {
Some(cap) => Some(cap),
None => return Ok(None),
},
Role::Speakers => None,
};
Ok(Some((devnode, render, capture)))
}
/// How many stamp/settle passes a name gets before we accept "stored but not yet served"
/// (a settled endpoint takes the stamp on the first pass; a freshly minted one may need the
/// audio stack to notice — it serves after the next Audiosrv restart/reboot at the latest).
@@ -510,7 +581,6 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
)
}
/// `audio-probe mint` devtest body: one synchronous provisioning pass, results printed.
/// Synchronous provisioning — for the mic pump's resolve and the devtests.
///
/// The pump's FIRST open must not race the startup worker: measured on the target box, the
@@ -520,15 +590,50 @@ pub(crate) fn discover_driver(needle: &str, inf_name: &str) -> Result<(String, S
/// (existing marker devnodes re-resolve in milliseconds; a cold boot pays the one-time mint)
/// keeps the pump's target and the plan's verdict the same thing. Latched calls return
/// immediately; the opt-out env is honoured like everywhere else.
///
/// While UNLATCHED this is where the pump's reopen backoff (capped at 60 s) used to meet an
/// unguarded full pass: one PnP rebind + device-change broadcast roughly every minute, forever,
/// on any box where minting cannot converge (the 2026-08-12 Helldivers 2 field report). Now a
/// pass someone else already runs is WAITED for instead of raced, a failed pass repeats at most
/// every [`RETRY_COOLDOWN`], and [`MAX_UNLATCHED_ATTEMPTS`] failures stop retrying for the
/// host lifetime.
pub(crate) fn ensure_blocking() {
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some() || PROVISIONED.get().is_some() {
if std::env::var_os("PUNKTFUNK_NO_AUDIO_MINT").is_some()
|| PROVISIONED.get().is_some()
|| gave_up()
{
return;
}
if let Ok(m) = ensure_all() {
if m.any() {
let _ = PROVISIONED.set(Arc::new(m));
// A pass is in flight (the startup worker, or a concurrent resolve): wait for its verdict
// rather than racing a second SetupAPI/PnP sweep against it — that race is how the pump
// once ended up wired to the cable while the worker minted (the dead-mic-air deploy race).
if PROVISIONING.swap(true, Ordering::SeqCst) {
let deadline = Instant::now() + BLOCKING_WAIT;
while PROVISIONING.load(Ordering::SeqCst) && Instant::now() < deadline {
thread::sleep(Duration::from_millis(100));
}
return;
}
// We own the slot. First-ever resolve runs unconditionally (the cold-boot mint the doc
// above insists on); after a failed pass the cooldown answers instead of a re-run.
let run = {
let mut last = LAST_ATTEMPT.lock().unwrap();
if last.is_some_and(|t| t.elapsed() < RETRY_COOLDOWN) {
false
} else {
*last = Some(Instant::now());
true
}
};
if run {
match ensure_all() {
Ok(m) if m.any() => {
let _ = PROVISIONED.set(Arc::new(m));
}
_ => record_unlatched_attempt(),
}
}
PROVISIONING.store(false, Ordering::SeqCst);
}
pub(crate) fn devtest_mint() -> Result<()> {
@@ -27,15 +27,28 @@
//! device pulls per-period) whose prime depth the mic pump sets from measured uplink jitter
//! ([`VirtualMic::set_target_depth`]), filling silence when the client isn't talking. WASAPI
//! objects are `!Send`, so they live entirely on that thread (mirrors `WasapiLoopbackCapturer`).
//!
//! **Idle stop (host must be able to SLEEP).** A RUNNING WASAPI stream makes the audio stack
//! hold a kernel power request ("An audio stream is currently in use", attributed to the
//! target device in `powercfg /requests`) that vetoes system sleep — and this pump is
//! host-lifetime, so rendering silence 24/7 kept every idle Punktfunk box awake forever
//! (field report 2026-08-12: "doesn't go to sleep anymore; powercfg shows the Steam Streaming
//! Microphone"). So after [`IDLE_STOP_AFTER`] of silence-only output the render loop stops the
//! stream (`IAudioClient::Stop` — the client stays initialized, the mic ENDPOINT keeps
//! existing, only the power request is released) and parks on the queue's condvar; the next
//! pushed frame resumes it within one device period. During a session the box is kept awake by
//! the session's own `DisplayWakeRequest` (pf-frame), never by this silence.
//! `PUNKTFUNK_MIC_ALWAYS_ON=1` restores the old always-running stream in case a virtual audio
//! driver misbehaves when its render side pauses.
use super::{audio_control, MicBackendStats, VirtualMic, SAMPLE_RATE};
use anyhow::{anyhow, Context, Result};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use std::time::{Duration, Instant};
use wasapi::{Direction, SampleType, StreamMode, WaveFormat};
const CHANNELS: u32 = 2;
@@ -60,9 +73,30 @@ const MAX_QUEUE_BYTES: usize = (SAMPLE_RATE as usize * 120 / 1000) * BLOCK_ALIGN
/// Producer-side overflow headroom (~32 ms) over the render loop's prime threshold when the
/// adaptive target drives the ring.
const CAP_HEADROOM_BYTES: usize = (SAMPLE_RATE as usize * 32 / 1000) * BLOCK_ALIGN;
/// Stop the render stream after this long of silence-only output (unprimed, nothing queued), so
/// an idle host releases the audio stack's sleep-blocking power request (see the module docs).
/// Long enough that mid-conversation pauses never cycle the stream; resume is one condvar
/// notify + `IAudioClient::Start`, well under the jitter buffer's prime depth.
const IDLE_STOP_AFTER: Duration = Duration::from_secs(10);
/// While the stream is idle-stopped the render thread parks on the queue condvar; this timeout
/// only bounds how long a host-shutdown `stop` can go unnoticed (same discipline as the pump's
/// `drain_sleep`).
const IDLE_WAKE_CHECK: Duration = Duration::from_millis(250);
/// The mic inject ring plus the wake signal for an idle-stopped render thread: `push` notifies
/// on the empty→non-empty transition, which is exactly the moment a stopped stream must resume.
type MicQueue = (Mutex<VecDeque<u8>>, Condvar);
/// `PUNKTFUNK_MIC_ALWAYS_ON=1`: never idle-stop the render stream (pre-2026-08 behaviour — the
/// host then blocks system sleep for its whole life). Escape hatch in case a virtual audio
/// driver's capture side misbehaves while its render side is paused.
fn mic_always_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("PUNKTFUNK_MIC_ALWAYS_ON").is_some_and(|v| v != "0"))
}
pub struct WasapiVirtualMic {
queue: Arc<Mutex<VecDeque<u8>>>,
queue: Arc<MicQueue>,
stop: Arc<AtomicBool>,
/// False once the render thread has exited (device error or stop) — the pump's reopen signal.
alive: Arc<AtomicBool>,
@@ -94,7 +128,7 @@ impl WasapiVirtualMic {
channels == CHANNELS,
"virtual mic is stereo-only (got {channels})"
);
let queue = Arc::new(Mutex::new(VecDeque::<u8>::new()));
let queue = Arc::new((Mutex::new(VecDeque::<u8>::new()), Condvar::new()));
let stop = Arc::new(AtomicBool::new(false));
let alive = Arc::new(AtomicBool::new(true));
let ring = Arc::new(RingShared::default());
@@ -144,9 +178,11 @@ impl VirtualMic for WasapiVirtualMic {
if !self.alive.load(Ordering::Acquire) {
return false;
}
let Ok(mut q) = self.queue.lock() else {
let (lock, wake) = &*self.queue;
let Ok(mut q) = lock.lock() else {
return false;
};
let was_empty = q.is_empty();
q.reserve(pcm.len() * 4);
for &s in pcm {
q.extend(s.to_le_bytes());
@@ -171,6 +207,12 @@ impl VirtualMic for WasapiVirtualMic {
.overflow
.fetch_add((excess / BLOCK_ALIGN) as u64, Ordering::Relaxed);
}
drop(q);
if was_empty {
// Empty→non-empty is the resume moment for an idle-stopped stream (the waiter
// re-checks the queue under the lock, so this cannot be a lost wakeup).
wake.notify_one();
}
true
}
@@ -179,7 +221,7 @@ impl VirtualMic for WasapiVirtualMic {
}
fn discard(&self) {
if let Ok(mut q) = self.queue.lock() {
if let Ok(mut q) = self.queue.0.lock() {
q.clear();
}
}
@@ -199,7 +241,7 @@ impl VirtualMic for WasapiVirtualMic {
if prime == 0 {
return None; // render loop hasn't run yet
}
let q = self.queue.lock().ok()?;
let q = self.queue.0.lock().ok()?;
Some((q.len() / BLOCK_ALIGN, prime / BLOCK_ALIGN))
}
@@ -387,7 +429,7 @@ fn try_install_steam_audio(inf_name: &str) -> bool {
}
fn render_thread(
queue: Arc<Mutex<VecDeque<u8>>>,
queue: Arc<MicQueue>,
stop: Arc<AtomicBool>,
shared: Arc<RingShared>,
ready: SyncSender<Result<String>>,
@@ -461,7 +503,40 @@ fn render_thread(
// the target).
let mut buf: Vec<u8> = Vec::new();
let mut primed = false;
while !stop.load(Ordering::Relaxed) {
// Idle stop (see the module docs): `running` mirrors the stream's Start/Stop state, and
// `idle_mark` is the queue length last seen while unprimed plus when it was first seen at
// that length — IDLE_STOP_AFTER of unprimed output at an UNCHANGED length is the idle
// verdict. Keying on the length (not just emptiness) covers a sub-prime tail a vanished
// client left behind, while any genuinely new audio moves the length (a push appends a
// whole frame and the drop-oldest cap sits above the prime threshold, so an unprimed queue
// can never coincidentally return to its marked length) and restarts the window.
let always_on = mic_always_on();
let mut running = true;
let mut idle_mark: Option<(usize, Instant)> = None;
'render: while !stop.load(Ordering::Relaxed) {
if !running {
// Idle-stopped: park on the condvar until mic audio arrives (push notifies on the
// empty→non-empty edge); the timeout only keeps `stop` responsive.
{
let (lock, wake) = &*queue;
let mut q = lock.lock().unwrap();
while q.is_empty() {
if stop.load(Ordering::Relaxed) {
break 'render;
}
let (guard, _timed_out) = wake.wait_timeout(q, IDLE_WAKE_CHECK).unwrap();
q = guard;
}
}
// A resume failure means the endpoint died while we slept — propagate, so the
// thread exits, `alive` flips, and the pump reopens (fresh plan) as for any death.
audio_client
.start_stream()
.context("resume render stream")?;
running = true;
idle_mark = None;
tracing::debug!("virtual mic stream resumed (client mic audio arrived)");
}
// The device signals when it wants more data; finite timeout keeps `stop` responsive.
if h_event.wait_for_event(100).is_err() {
continue;
@@ -486,7 +561,7 @@ fn render_thread(
// Silence base; overwrite with queued mic PCM once the cushion is primed.
buf[..need].fill(0);
{
let mut q = queue.lock().unwrap();
let mut q = queue.0.lock().unwrap();
if !primed && q.len() >= prime {
primed = true;
}
@@ -500,6 +575,35 @@ fn render_thread(
shared.reprimes.fetch_add(1, Ordering::Relaxed);
}
}
if primed {
idle_mark = None;
} else if !always_on {
// Unprimed = this period was pure silence. After IDLE_STOP_AFTER of that at an
// unchanged queue length, stop the stream so the box can sleep. Decided under
// the queue lock, and only when nothing has arrived for the whole window (see
// `idle_mark`'s docs) — a burst landing at the boundary moves the length and
// resets the window instead of being cleared. What IS cleared is ≥10 s stale
// (the pump's stale-gap discard would drop it before the next real frame
// anyway), which keeps "queue empty" ⇔ "nothing to play" for the wait above.
match idle_mark {
Some((len, since)) if len == q.len() => {
if since.elapsed() >= IDLE_STOP_AFTER {
q.clear();
audio_client
.stop_stream()
.context("idle-stop render stream")?;
running = false;
idle_mark = None;
tracing::debug!(
"virtual mic stream idle-stopped (releases the sleep-blocking \
audio power request; next mic frame resumes it)"
);
continue 'render;
}
}
_ => idle_mark = Some((q.len(), Instant::now())),
}
}
}
render_client
.write_to_device(space, &buf[..need], None)
+60
View File
@@ -231,6 +231,66 @@ pub fn dualsense_test(args: &[String]) -> Result<()> {
Ok(())
}
/// Mint one pad-audio PipeWire sink (the Linux 0xD1 source, `audio::pad_sink`) and capture
/// from it — the WP3 on-glass gate with no client involved. Verify the identity with
/// `pactl list sinks` (name/description/proplist) and drive it with
/// `pw-play --target <node.name> <file>` (or `paplay -d <node.name>`); captured chunks print
/// a per-second summary here. `--pad N` (default 0), `--edge`, `--seconds N` (default 30).
#[cfg(target_os = "linux")]
pub fn pad_sink_test(args: &[String]) -> Result<()> {
use crate::audio::AudioCapturer as _;
use std::time::{Duration, Instant};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(30);
let pad: u8 = args
.iter()
.skip_while(|a| *a != "--pad")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let edge = args.iter().any(|a| a == "--edge");
let mut cap = crate::audio::pad_sink::PadSinkCapturer::open(pad, edge)
.context("mint pad-audio sink (is PipeWire running in this session?)")?;
println!(
"pad sink minted: node.name = {}\n inspect: pactl list sinks | grep -A20 punktfunk-pad\n \
drive it: pw-play --target '{}' <48k-file>\nCapturing for {secs}s",
cap.node_name, cap.node_name
);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut chunks, mut samples) = (0u64, 0u64);
// Per-pair peaks: ch0/1 = speaker, ch2/3 = voice coils — the split_quad contract. Proving
// the pairs separately is the point of this devtest: a positional remix upstream would
// smear or zero one pair while a global peak still looks healthy.
let (mut peak_spk, mut peak_coil) = (0f32, 0f32);
let mut last_report = Instant::now();
while Instant::now() < deadline {
let c = cap.next_chunk().context("pad sink capture")?;
if !c.is_empty() {
chunks += 1;
samples += c.len() as u64;
for f in c.chunks_exact(4) {
peak_spk = peak_spk.max(f[0].abs()).max(f[1].abs());
peak_coil = peak_coil.max(f[2].abs()).max(f[3].abs());
}
}
if last_report.elapsed() >= Duration::from_secs(1) {
last_report = Instant::now();
println!(
" chunks={chunks} samples={samples} (~{:.1}ms of 4ch audio) \
peak_speaker={peak_spk:.4} peak_coils={peak_coil:.4}",
samples as f64 / (4.0 * 48.0)
);
(chunks, samples, peak_spk, peak_coil) = (0, 0, 0.0, 0.0);
}
}
println!("pad-sink-test: done");
Ok(())
}
/// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
/// streaming session): answers the full hid-nintendo probe conversation, then cycles the
/// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
+28 -7
View File
@@ -508,15 +508,25 @@ fn running_as_system() -> bool {
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() {
return true; // fail closed
}
let mut buf = [0u8; 256];
// TOKEN_USER is align-8; a bare `[u8; 256]` is align-1, and forming `&TOKEN_USER` out of it
// below would be UB by the language rule whenever the stack slot happens to land misaligned.
// (Shipped codegen happens to 8-align it today — that is luck, not a guarantee.) The wrapper
// keeps the buffer at 256 BYTES: redeclaring as `[u64; 32]` would silently turn the length
// argument below into 32 — `len()` counts elements — and a console operator's 44-byte
// TOKEN_USER+SID would then fail with ERROR_INSUFFICIENT_BUFFER, misclassifying every
// hand-run host as SYSTEM (it fits exactly for SYSTEM's own 16-byte S-1-5-18, so a
// SYSTEM-side test would not catch it).
#[repr(align(8))]
struct TokenUserBuf([u8; 256]);
let mut buf = TokenUserBuf([0u8; 256]);
let mut len = 0u32;
// SAFETY: `buf` is a writable local of the length passed; `len` is a live out-param.
let got = unsafe {
GetTokenInformation(
token,
TokenUser,
Some(buf.as_mut_ptr().cast()),
buf.len() as u32,
Some(buf.0.as_mut_ptr().cast()),
std::mem::size_of_val(&buf) as u32,
&mut len,
)
};
@@ -542,11 +552,22 @@ fn running_as_system() -> bool {
{
return true; // fail closed
}
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation; its `User.Sid` points into
// the same buffer, and both SIDs are valid for this comparison.
// SAFETY: `buf` holds a TOKEN_USER written by GetTokenInformation (align guaranteed by
// TokenUserBuf); its `User.Sid` points into the same buffer, and both SIDs are valid for
// this comparison.
unsafe {
let tu = &*(buf.as_ptr() as *const TOKEN_USER);
EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())).is_ok()
let tu = &*(buf.0.as_ptr() as *const TOKEN_USER);
// windows-rs maps EqualSid's BOOL(0) to Err BOTH for "SIDs differ" and for a genuine
// failure, telling them apart only via GetLastError — so clear it first (a stale value
// from an earlier call would otherwise read as failure) and split three ways. `.is_ok()`
// here previously meant an EqualSid ERROR yielded "not SYSTEM" — the fail-OPEN
// direction, contradicting the contract in the doc comment above.
windows::Win32::Foundation::SetLastError(windows::Win32::Foundation::WIN32_ERROR(0));
match EqualSid(tu.User.Sid, PSID(system.as_mut_ptr().cast())) {
Ok(()) => true, // equal: we are SYSTEM
Err(e) if e.code().is_ok() => false, // BOOL(0), last-error 0: genuinely not equal
Err(_) => true, // EqualSid itself failed: fail closed
}
}
}
+8 -3
View File
@@ -163,15 +163,20 @@ mod tests {
impl EnvGuard {
fn set(dir: &std::path::Path) -> EnvGuard {
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
std::env::set_var("PUNKTFUNK_CONFIG_DIR", dir);
// SAFETY: only called by tests that hold CONFIG_DIR_TEST_LOCK, which serializes
// every test that writes or reads this variable across the whole test binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", dir) };
EnvGuard(prev)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("PUNKTFUNK_CONFIG_DIR", v),
None => std::env::remove_var("PUNKTFUNK_CONFIG_DIR"),
// SAFETY: dropped while the owning test still holds CONFIG_DIR_TEST_LOCK — the
// same serialization as `EnvGuard::set`.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
+10 -4
View File
@@ -734,7 +734,9 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
// Confine the proxy to `dir` for the duration of this test.
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
// A real image inside the root: served, with the content type SNIFFED from the bytes.
let cover = dir.join("cover.png");
@@ -810,7 +812,8 @@ mod tests {
// A UNC path is refused outright (outbound SMB auth coercion), before any filesystem hit.
assert!(!art_path_is_servable(r"\\attacker\share\a.png"));
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
@@ -878,7 +881,9 @@ mod tests {
let outside = std::env::temp_dir().join(format!("pf-art-wr-out-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir);
// SAFETY: `_guard` holds ART_ROOTS_LOCK (`lock_art_roots`), which serializes every test
// that writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_LIBRARY_ART_ROOTS", &dir) };
let cover = dir.join("cover.png");
std::fs::write(&cover, PNG).unwrap();
@@ -930,7 +935,8 @@ mod tests {
"an out-of-root file:// cover is still refused"
);
std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS");
// SAFETY: still under `_guard` — the same ART_ROOTS_LOCK serialization as the set.
unsafe { std::env::remove_var("PUNKTFUNK_LIBRARY_ART_ROOTS") };
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}
+17 -4
View File
@@ -56,8 +56,10 @@ mod gamestream;
#[path = "linux/gpuclocks.rs"]
mod gpuclocks;
mod hooks;
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below).
#[forbid(unsafe_code)]
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below). Test
// builds carve out like `native`: the identity tests scope `PUNKTFUNK_CONFIG_DIR` by mutating
// the process env, which edition 2024 makes an unsafe call; shipped code keeps the forbid.
#[cfg_attr(not(test), forbid(unsafe_code))]
mod identity;
// The input-injection backends live in the `pf-inject` subsystem crate (plan §W6); this shim keeps
// every existing `crate::inject::*` path valid (the native/gamestream input planes + devtest consume
@@ -81,8 +83,10 @@ mod log_capture;
// exposes — are safe Rust by compiler-enforced invariant (rust-safety programme): `forbid`
// here means a future edit cannot quietly introduce unsafe into a network-facing module.
// (`native` carves out its `#[cfg(test)]` C-ABI roundtrip tests, which exercise the CLIENT
// side of punktfunk-core against this host in-process and are unsafe by nature.)
#[forbid(unsafe_code)]
// side of punktfunk-core against this host in-process and are unsafe by nature; `mgmt` and
// `identity` carve out test builds too — their tests scope `PUNKTFUNK_CONFIG_DIR` by mutating
// the process env, an unsafe call since edition 2024.)
#[cfg_attr(not(test), forbid(unsafe_code))]
mod mgmt;
#[forbid(unsafe_code)]
mod mgmt_token;
@@ -396,6 +400,12 @@ fn real_main() -> Result<()> {
// restored (crash/kill/power loss) — before any new session touches the topology.
#[cfg(target_os = "windows")]
monitor_devnode::startup_recover();
// The same recovery for the experimental `edid_lock` axis: unpin AMD connector
// emulation a previous host locked and never unlocked — pinned emulation outlives
// the process (and can outlive a reboot), so this is the only thing standing between
// a crash and a permanently-emulated connector.
#[cfg(target_os = "windows")]
pf_win_display::adl_emul::startup_recover();
// The same recovery for the DEFAULT Exclusive path: a previous host that died holding a
// CCD isolate left the operator's panels deactivated with nothing to put them back (the
// restore snapshot was process memory). Runs AFTER the devnode leg so re-enabled
@@ -613,6 +623,9 @@ fn real_main() -> Result<()> {
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session).
#[cfg(target_os = "linux")]
Some("dualsense-test") => devtest::dualsense_test(&args),
// Mint one pad-audio PipeWire sink and capture from it — the Linux 0xD1 source gate.
#[cfg(target_os = "linux")]
Some("pad-sink-test") => devtest::pad_sink_test(&args),
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no session).
#[cfg(target_os = "linux")]
Some("switchpro-test") => devtest::switchpro_test(&args),

Some files were not shown because too many files have changed in this diff Show More