6de325a6b63ab07065b540975534f338ccc0047f
1960
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6de325a6b6 |
fix(ci): the unsafe lint said warn while CI enforced it as deny, and main went red
windows-host / package (push) Failing after 13m8s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m43s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m55s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m5s
android / android (push) Failing after 6m25s
ci / bench (push) Successful in 8m21s
deb / build-publish (push) Successful in 8m44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 32s
deb / build-publish-host (push) Successful in 10m2s
ci / rust (push) Failing after 17m18s
ci / rust-arm64 (push) Successful in 16m6s
arch / build-publish (push) Failing after 17m19s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 24s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m54s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7m44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m12s
deb / build-publish-client-arm64 (push) Successful in 14m5s
flatpak / build-publish (push) Failing after 8m59s
apple / swift (push) Failing after 7m20s
apple / screenshots (push) Skipped
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 17m16s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 26m3s
`unsafe_op_in_unsafe_fn = "warn"` was adopted workspace-wide in 39513528 on the assumption that
`warn` is a soft setting you can clear at leisure. It is not: ci.yml runs `cargo clippy … -D
warnings`, which promotes it to a hard error, so main has failed on EVERY commit since — Linux
`rust` and `rust-arm64` both dying on `pf-client-core` with 70 E0133 errors, and windows-host.yml
alongside them. A lint level that understates its own severity is worse than a strict one, so this
states what CI already does — `deny` — and writes the exemptions down instead.
Fourteen GPU/FFI backend files take `#![allow(unsafe_op_in_unsafe_fn)]`, each with its reason and
the workspace Cargo.toml carrying the argument in full. They are not "not done yet": measured
across them, 64% of the sites are a single third-party FFI call (ash, pyrowave-sys, libav, the
NVENC/AMF entry tables), and of the 44 `unsafe fn`s only 4 have a body containing no unsafe
operation at all. Since pf-encode also denies `undocumented_unsafe_blocks`, narrowing them means a
hand-written SAFETY comment per line that could only restate the signature — the exact noise that
made `unsafe` stop meaning anything here before. Everything else stays at zero and enforced, and
each allow is removable on its own terms.
Two smaller things this had to clear, both invisible to the job that would have caught them:
- `service.rs`: `undocumented_unsafe_blocks` wants the proof on EACH block, and a comment covering
a group of consecutive `unsafe` statements only credits the first — so the two `OwnedHandle`
wraps became their own statements. Windows-gated, so only the .47 gate sees it.
|
||
|
|
f40c90aa23 |
refactor(host/inject/win-display): the Win32 unsafe surface is the FFI call, not the function
`spawn_in_active_session` existed only to launder `spawn_inner`'s `unsafe fn` marker — and its own comment said why that marker was empty: the callee "has no caller-side preconditions, it validates the session/token itself and owns every handle it opens". A marker that documents the absence of a contract is not a contract. The two collapse into one safe fn whose `unsafe` now sits on the eight Win32 calls, each with the proof it actually needs. Kept `unsafe fn` exactly where a caller CAN break something: `merged_env_block` (a `*const u16` block it must trust), `spawn_host` (a borrowed job `HANDLE`), `desktop_name` (an `HDESK`), `inject_following_desktop` (a live synthetic-pointer device). Those bodies gain explicit blocks instead — `merged_env_block`'s pointer walk is the one place here where the proof is load-bearing rather than restating a signature, and it now says why the scan cannot run off the block's end. With those four files at zero, all three crates take `deny(unsafe_op_in_unsafe_fn)` at the root, next to the `undocumented_unsafe_blocks` deny they already had. The pair matters: alone, the older deny is satisfied by an `unsafe fn` body, which needs no blocks at all and so can hide an unproven FFI call. The workspace lint stays `warn` — it is a ratchet over the encoder backends still to be cleared, and a crate that reaches zero should not be able to drift back silently. Windows E0133 214 -> 182 (the remainder is the ash/AMF-dense encode backends). Verified on the Intel box .47 and on Linux .21: no errors, no `unused_unsafe`, no dead-code fallout. |
||
|
|
808eba7292 |
feat(client/linux): bind a host to a profile, or connect with one just this once
ci / web (push) Successful in 1m56s
ci / rust (push) Failing after 6m15s
ci / docs-site (push) Successful in 2m1s
android / android (push) Canceled after 11m36s
ci / rust-arm64 (push) Failing after 9m8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 24s
decky / build-publish (push) Successful in 36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 5m23s
deb / build-publish-client-arm64 (push) Successful in 7m28s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m31s
deb / build-publish (push) Successful in 9m19s
arch / build-publish (push) Successful in 18m42s
flatpak / build-publish (push) Failing after 8m19s
deb / build-publish-host (push) Successful in 10m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m15s
apple / swift (push) Successful in 5m30s
docker / deploy-docs (push) Successful in 29s
docker / build-push-arm64cross (push) Successful in 11m22s
apple / screenshots (push) Successful in 27m32s
The other half of P1 on GTK (design/client-settings-profiles.md §5.2): until now a
profile could be created and edited but only *used* through `--profile` or a URL. Host
cards get two menus, and they are separate on purpose — the predictability rule is that
connecting with a profile must never change what the card does next time.
- **Connect with ▸** is a one-off. It rides `ConnectRequest.profile` into the session's
`--profile` flag and touches no stored state. "Default settings" in that menu is
`Some("")`, not `None`: on a bound host, asking for the defaults is a real choice and
has to survive as a value rather than degrade into "use the binding".
- **Default profile ▸** is the explicit rebinding act. It writes straight onto the host
record — the binding IS a field there, so there is no map to keep in step — matched by
fingerprint, else by address, like every other per-host lookup in this client.
- The card carries a **chip** naming its bound profile, so what a plain click will do is
visible without opening a menu. A binding whose profile was deleted shows nothing and
connects with the defaults, which is exactly what the resolver does.
A URL's `profile=` now takes the same one-off route, since it is the same kind of thing.
Verified on .21: `punktfunk://connect/Bound%20Box?profile=Work` against a host bound to
"Game" spawned a session that logged `profile=Work id=bbbbbbbbbbbb` — the one-off won,
and the binding on disk was untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
37a42078a0 |
fix(xcheck): mirror [workspace.lints] too — and the two things that hid behind it
windows-host / package (push) Failing after 5m0s
windows-host / winget-source (push) Skipped
ci / rust-arm64 (push) Failing after 22s
ci / web (push) Successful in 2m28s
ci / docs-site (push) Successful in 2m55s
ci / rust (push) Failing after 6m19s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / bench (push) Successful in 6m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
android / android (push) Successful in 12m2s
deb / build-publish-client-arm64 (push) Successful in 8m59s
arch / build-publish (push) Successful in 14m57s
deb / build-publish (push) Successful in 12m3s
deb / build-publish-host (push) Successful in 13m33s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m27s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 14m22s
`scripts/xcheck.sh` stopped working the moment the workspace adopted its unsafe discipline
(`52191071`). That commit added `[lints] workspace = true` to every crate manifest, and a member
inheriting a lint table the workspace ROOT does not define does not merely lose the lint — cargo
refuses to parse the manifest at all:
error inheriting `lints` from workspace root manifest's `workspace.lints`
`workspace.lints` was not defined
The generated root mirrored `[workspace.package]` but nothing else. It now mirrors every
`[workspace.lints.*]` table generically, so a future table (clippy, rustdoc, …) is picked up
without touching the script again.
That mattered more than a broken helper, because xcheck is the ONLY thing that lints these crates
for Windows — the Windows CI job clippies `-p punktfunk-host -p punktfunk-tray`, which does not
lint a dependency. So while it was down, two real failures sat on main unseen:
* **pf-frame `dxgi.rs`, 8 × `clippy::undocumented_unsafe_blocks`.** `307ca88b`/`0a710fdf` split
the D3DKMT/DXGI calls into one `unsafe` block per FFI call under a single shared SAFETY comment,
but the lint wants a proof per block — a comment two lines up with an intervening statement does
not count. Each site now carries its own. Not "as above", which this sweep already found to be a
proof that rots when the block it points at moves.
* **pf-win-display `input_desktop.rs`, one E0133.** `GetUserObjectInformationW` is an unsafe
operation in an `unsafe fn` body, which `unsafe_op_in_unsafe_fn` (now warn workspace-wide, and
`-D warnings` here) requires to sit in its own block. The SAFETY proof was already written; only
the block was missing.
The `&&` chain in `elevate_process_gpu_priority` keeps two separate blocks rather than one around
the chain — wrapping it would destroy the short-circuit and always issue the relative-priority
call. Same trap this sweep hit in `wait_mode_settled`.
Verified: both xcheck targets clean again.
|
||
|
|
21eda37aa3 |
fix(win-display): punktfunk's own display is not one of the operator's panels
The IddCx driver declares `DISPLAYCONFIG_OUTPUT_TECHNOLOGY_HDMI` for its monitors
(`packaging/windows/drivers/pf-vdisplay/src/monitor.rs`, `IDDCX_MONITOR_INFO::MonitorType`),
and `output_tech_class`'s allowlist reads HDMI as a real external panel. So every consumer of
`target_inventory` counted OUR OWN virtual display among the operator's physical monitors.
Measured on .173: both targets came back `external_physical`, `[(4352, "LG TV SSCR2", HDMI),
(257, "punktfunk", HDMI)]`.
That is not cosmetic. `restore_displays_ccd` ends with the last-resort guarantee that the desk
is never left all-dark:
let (connected, lit) = target_inventory().filter(external_physical) …
if connected > 0 && lit == 0 { force_extend_topology() }
The restore runs BEFORE the virtual is REMOVEd, so our own still-active display kept `lit >= 1`
and the backstop could NEVER fire — in exactly the situation it was written for. It also made
our display a candidate "physical suspect" in the disturbance-attribution inventory, which
`TargetInventory`'s own doc rules out ("only indirect/virtual targets (our own IDD included)").
Targets carrying our EDID manufacturer id ("PNK", stamped by the driver's edid.rs) are now
classified `external_physical = false`, tech `punktfunk-virtual`. Matching on the monitor device
path rather than the friendly name — the path carries the manufacturer id the OS itself derived
— and on both spellings, since the CCD device-interface path uses `#`
(`\\?\DISPLAY#PNK0000#…`) while the PnP instance id for the same monitor uses `\`
(`DISPLAY\PNK0000\1&15ecd195&5&UID264`, observed on .173). Allowlist-shaped like
`output_tech_class`, so a third-party virtual display is never silently adopted.
ON GLASS, read-only, on .173 — and it caught the backstop's trigger live:
target 264 active=true external_physical=false tech=punktfunk-virtual "punktfunk"
target 4352 active=false external_physical=true tech=HDMI "LG TV SSCR2"
The physical is dark while our virtual holds the desktop. Post-fix that is `connected=1, lit=0`
— the backstop fires. Pre-fix it was `connected=2, lit=1` — suppressed. The new hardware case is
deliberately READ-ONLY (creates and destroys nothing) so it is safe against a live host;
repeated IddCx create/destroy churn is what wedges the driver's slot pool.
Also adds `manager::FAIL_NEXT_ISOLATES`, a `#[cfg(test)]` seam that fails the next N
`isolate_displays_ccd` calls, with every call site in manager.rs routed through it. The Phase-3
recovery legs fire only on a failed isolate, which real hardware does not produce, so they
cannot otherwise be exercised. `#[cfg(test)]` rather than an env knob: this crate's live tests
compile under `cfg(test)`, so no production switch exists that could disable display isolation
on an operator's box. The §5 3.2 case built on it is included but is NOT yet a passing on-glass
result — see the plan for where it got to.
Verified on .173: clippy -p punktfunk-host -p pf-vdisplay --all-targets and -p pf-win-display
--all-targets clean; pf-vdisplay 56 pass, pf-win-display 2 pass + hardware cases; both xcheck
targets clean.
|
||
|
|
2b2c6f045d |
test(vdisplay): pin the force-EXTEND backstop on glass, with a real panel attached
`force_extend_topology` does two jobs — stop a fresh IddCx monitor being CLONED onto the existing panel, and serve as `restore_displays_ccd`'s last-resort "the desk is not left dark" backstop. Probed directly on .173 with only the LG TV connected, its preset returns rc=31 ERROR_GEN_FAILURE while SDC_USE_DATABASE_CURRENT returns 0, which reads like a backstop that cannot back anything up. On glass it is not, and this case is the measurement that settled it. Active paths went 1 -> (virtual up) 1 -> (after force-EXTEND) 2: with one connected display there is nothing to extend across, hence rc=31; with the virtual present there are two and the preset applies. Both real call sites run in exactly that state — the restore fires BEFORE the REMOVE, so the virtual is still there. No defect. Recording it so nobody re-derives the alarming half from the rc alone. ⭐ It also caught the clone hazard live: the arriving virtual monitor did NOT get its own active path (1 -> 1); only the forced EXTEND gave it one (-> 2). That is exactly the "no distinct source -> no frames" case the function's own doc describes, observed rather than assumed. ⚠️ Residual, noted not asserted: a restore that fails once the virtual is already gone is back to one connected display, where EXTEND returns 31 and cannot re-light anything. Context for the sweep's other on-glass claims: the earlier `live_create_drop` and `live_inplace_resize` passes on this box were taken with the TV POWERED OFF (every monitor devnode Code 45), so they proved the driver round trip but never exercised deactivating a real panel. Re-run now with the TV attached, `live_inplace_resize` still reports in_place=true (target 257 -> 257, 2356x1332), so Phase 6.2 holds with a physical present. Verified on .173: clippy -p punktfunk-host -p pf-vdisplay --all-targets and -p pf-win-display --all-targets clean, 56 pass + 3 ignored, no orphans. |
||
|
|
ea9a25d3b1 |
fix(win-display): a host with nothing lit is an ANSWER, not a failed CCD query
`query_active_config` asked `QueryDisplayConfig` for the active topology even when the sizing call had just answered `numPaths = 0`. Windows rejects a zero-count query rather than handing back an empty set, so "nothing is active" came back as "the query failed". Zero active paths is an ordinary state, not a broken one: every panel off or in standby, a KVM switched away, a headless box between its adapter arriving and its first monitor. The `None` propagated into the two places that matter — `count_other_active` reported failure where the honest answer is `Some(0)`, and `isolate_displays_ccd` bailed at its first line, which is exactly the teardown-gate condition whose recovery legs exist to stop the operator's physical panels being left dark (sweep §5 3.1). MEASURED on .173 (RTX 4090, Win11 26200) with the TV powered off — every monitor devnode Code 45, `numPaths = 0` for `QDC_ALL_PATHS` too — in a live, logged-on CONSOLE session: `GetDisplayConfigBufferSizes` returns 0 paths and `QueryDisplayConfig` then returns 0x57 ERROR_INVALID_PARAMETER (0x5 ERROR_ACCESS_DENIED from session 0). This is the mechanism behind the plan's ⭐ note that `count_other_active(&[])` yields `None` on that box: not the API refusing us, just a query nobody should have made. Three changes, one defect: * `query_active_config` short-circuits `numPaths == 0` to an empty-but-valid config. * `isolate_displays_ccd` returns that empty snapshot straight away instead of entering its retry loop. Nothing is active, so nothing needs deactivating and nothing needs restoring; the loop's re-commit exists to drive the IddCx adapter's COMMIT_MODES, and with no path at all there is no config to commit — four attempts would each log a rejected apply and sleep 250 ms to reach the topology we already have. `restore_displays_ccd` already treated an empty config as the no-op it is, so the round trip stays consistent. * `set_virtual_primary_ccd` goes through the shared query. It carried a verbatim copy of it — same flags, same shape — and was therefore the one CCD entry point that would not have inherited the fix. Same seam asymmetry this crate keeps producing, and two hand-written unsafe blocks go with it. Also this file's first tests. The case is `#[ignore]`d hardware, so it reports `ignored` rather than a vacuous `ok` when nobody runs it. On-glass A/B in the console session on .173: it passes with the short-circuit and FAILS without it, naming the conflation. A box that does have a display lit passes either way. ⚠️ The test is read-only and must stay that way — the obvious companion assertion, `isolate_displays_ccd(&[])`, is deliberately not written: an empty keep set means "keep nothing", so on a box with displays it would deactivate every one of them. Verified on .173: `clippy -p punktfunk-host -p pf-vdisplay --all-targets` and `clippy -p pf-win-display --all-targets` both clean, pf-vdisplay 56 pass, pf-win-display 1 pass + 1 ignored. Both xcheck targets clean. |
||
|
|
5a14d2b3f4 |
fix(vdisplay): the helper budget must end the tree, not just the process we spawned
`Child::kill` is one `TerminateProcess`: it ends exactly the process we launched. On Unix
that is the whole story here — `kscreen-doctor`, `systemctl`, `pw-dump` are single processes
we exec directly. On Windows there is no direct exec, so every helper is reached through a
shell (`cmd /c …`, `powershell -Command "… | pnputil …"`) and the process that actually
hangs is a GRANDCHILD. Killing the shell left it running, holding the stdio handles and the
working directory it inherited from us, which means the budget bounded nothing.
Not theoretical: it is why a fully green `cargo test -p pf-vdisplay` still failed its CI job
on `33a31427`. The suite's own hung-helper case orphaned a 60-second `ping.exe`; it kept the
build step's stdout pipe open past the runner's 10 s WaitDelay ("exec: WaitDelay expired
before I/O complete") and pinned `crates\pf-vdisplay` so the workspace could not be cleaned
up. The tests reported `55 passed; 0 failed` in the same log.
Spawned children are now enrolled in a Job object. Job membership is inherited across
`CreateProcess`, so one `TerminateJobObject` ends every descendant, and
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` makes that hold on paths that never reach the explicit
call — an early `?`, a panic — because closing the last handle is then itself the kill.
Best-effort: a box that cannot make a job object degrades to the single-process kill it did
before rather than failing the query, the same stance as the already-ignored `Child::kill`.
`output_within` ends the tree BEFORE `wait_with_output`. That read runs to EOF, and a
grandchild outliving the helper holds the write end — the one way this "bounded" helper
could still hang forever.
The new test is the permanent lever, and it is not vacuous: it asserts the grandchild
reached its first marker before asserting it never reached its second. Verified on .173
(Win11 26200): 56 pass, `clippy -p punktfunk-host -p pf-vdisplay --all-targets` clean, no
`ping.exe` survivors. A/B with the job disabled: the test fails with "a grandchild outlived
the budget" and one orphan survives. On the CI runner .133 the job APIs were also checked
directly, as Administrator (already inside a job — nesting works) and as SYSTEM: both kill
the tree.
Trap for anyone editing that test: a .cmd file is read in the OEM code page, so an absolute
path baked into it is mangled the moment the temp dir holds a non-ASCII character
(`C:\Users\Enrico Bühler\…` arrives as `B?hler`) and every redirect fails with "path not
found". It uses `%~dp0` instead, so the file stays pure ASCII.
|
||
|
|
cf419d8b6f |
feat(client/linux): the settings dialog edits profiles, not just the defaults
ci / web (push) Successful in 2m12s
ci / docs-site (push) Successful in 1m12s
android / android (push) Successful in 12m14s
ci / rust (push) Failing after 9m46s
ci / rust-arm64 (push) Failing after 9m43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
decky / build-publish (push) Successful in 32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / bench (push) Successful in 7m57s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
deb / build-publish-client-arm64 (push) Successful in 7m40s
deb / build-publish (push) Successful in 9m5s
deb / build-publish-host (push) Successful in 10m19s
flatpak / build-publish (push) Failing after 8m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m10s
arch / build-publish (push) Successful in 21m3s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
P1's core on GTK (design/client-settings-profiles.md §5.1). The same surface now edits either layer: a scope switcher at the top of General swaps between "Default settings" and one profile's overrides. Deliberately not a second editor — a parallel one would drift from this one field by field, which is exactly what the "one settings UI" principle is there to prevent. Three decisions carry the design's semantics into this file: - Rows always show the EFFECTIVE value — the global underneath with the profile's overrides applied — so a row the profile doesn't touch reads as the live global. That is what "inherit by default" has to look like. - Overrides are recorded ON TOUCH, never by diffing values at save time. A profile may therefore pin a value that happens to equal today's global and keep it when the global later moves. The handlers that record this are installed after the seed pass, or opening a profile would override every row in it. - Only profileable rows render in profile scope. Auto-wake, the library toggle, the decoder and GPU pickers, the audio endpoints, the forwarded-pad picker and the detected pad list are facts about this device or this network, not about "Game vs Work" (§3's tier G/H), so they simply aren't there. Switching scope closes the dialog — which commits the layer being edited — and asks the app to re-open in the new one, so there is a single code path that builds the rows and no ambiguity about commit ordering. Both scopes read the controls through one shared closure; the tri-state resolution row is the obvious trap if they ever diverged. Create / Rename / Duplicate / Delete live in the switcher's group. Names are unique case-insensitively and the prompt refuses duplicates in place rather than after the dialog is gone. Delete counts what actually breaks — hosts that fall back to the defaults, pinned cards that disappear — and leaves bindings dangling on purpose: they resolve as "no profile" everywhere, and rewriting every host record here would be a second, racier source of truth. `PUNKTFUNK_SHOT_SETTINGS_SCOPE` puts the screenshot scene into profile scope, so both halves of the surface are capturable. Verified on .21 under Xvfb: the switcher, the per-profile actions and the tier filtering render, and the Statistics row seeds to the Game profile's "Detailed" rather than the global's value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6cf5d4ab87 |
feat(client/linux): punktfunk:// links open the GTK client
ci / web (push) Successful in 2m41s
android / android (push) Successful in 12m27s
ci / docs-site (push) Successful in 3m8s
ci / rust (push) Failing after 10m42s
ci / rust-arm64 (push) Failing after 10m44s
arch / build-publish (push) Successful in 15m31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 28s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 5m33s
deb / build-publish-client-arm64 (push) Successful in 7m15s
deb / build-publish (push) Successful in 13m59s
deb / build-publish-host (push) Successful in 14m55s
flatpak / build-publish (push) Failing after 10m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m37s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 27s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 3m8s
D1's activation half (design/client-deep-links.md §4.1). The client already had a `--connect` CLI, but nothing claimed the scheme, so nothing outside a terminal could start a stream: no browser prompt, no xdg-open, no double-clickable shortcut. The `.desktop` entries gain `MimeType=x-scheme-handler/punktfunk;` and `Exec=… %u`, and the rpm scriptlet joins deb and arch in running `update-desktop-database` — an entry nothing indexes claims nothing. (nix substitutes the Exec line by prefix, so `%u` survives; flatpak's exported entry is renamed to the app id by the standard mechanism.) In the app, `HANDLES_OPEN` plus a positional-URL argv is the whole mechanism, and the second half is the interesting one: argv stays withheld from GApplication as before EXCEPT when it carries a URL, and then GIO's own single-instance forwarding delivers it to the running window over D-Bus. Clicking a link with Punktfunk already open reuses that window instead of racing a second instance, and we wrote no IPC to get it. A URL that lands during a cold start (before the model exists) is parked rather than dropped — silently losing a link is the one outcome that would make the feature untrustworthy. Routing is four lines of translation, because the decisions are the brain's: parse, resolve the host, resolve the profile, refuse. A link that resolves becomes exactly the message a card click raises — the same wake, trust gate, and error surfaces — so there is no second connect path to keep in sync. A link to a host we don't know, or know but never pinned, opens the ordinary PIN ceremony seeded with what the link claimed, which is how "a URL may never pair or trust on its own" stays true while still being useful. Never preempting a live session is checked here, since only this layer knows one is running. Verified on .21 under Xvfb against hand-authored stores: `connect/Bound%20Box?launch=` resolved the host by name, carried the launch id through the trust gate, and spawned the session; `punktfunk://pair/1234` and an unknown `profile=` both refused before any dial. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c262bf43df |
refactor(host): three more Windows unsafe fns that had no caller contract
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m16s
android / android (push) Successful in 12m8s
windows-drivers / probe-and-proto (push) Successful in 29s
ci / rust (push) Failing after 10m31s
ci / rust-arm64 (push) Failing after 11m1s
ci / bench (push) Successful in 5m40s
decky / build-publish (push) Successful in 22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
windows-drivers / driver-build (push) Successful in 1m44s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 16m4s
windows-host / package (push) Failing after 5m12s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 8m59s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m12s
flatpak / build-publish (push) Successful in 7m0s
deb / build-publish-client-arm64 (push) Successful in 9m59s
apple / swift (push) Successful in 5m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m28s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m9s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m32s
deb / build-publish-host (push) Successful in 14m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m45s
docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 30m14s
release / apple (push) Successful in 39m36s
apple / screenshots (push) Successful in 23m3s
Continues the crate-by-crate `unsafe_op_in_unsafe_fn` work. Same finding as pf-frame: the useful move is often deleting the `unsafe fn` marker, not decorating the body. `install_steam_audio_pair` (no arguments) and `try_install_steam_audio(&str)` are now safe fns. The call sites had already worked this out — both carried a SAFETY comment opening "`install_steam_audio_pair` is `unsafe` only because it `LoadLibraryExW`s `newdev.dll`", i.e. the obligation was never the caller's. Making them safe deletes 14 lines of that prose from two call sites and moves the real reasoning next to the `LoadLibraryExW`/`transmute`/call chain it actually describes. wasapi_mic.rs: 8 -> 0. `make_job` (no arguments, and it wraps its handle in an `OwnedHandle` before the first fallible step) and `open_log_handle(&Path)` are safe fns too. The latter returns a raw `HANDLE`, but that is an ownership obligation, not a safety one — nothing a caller does with it can cause UB, and it is inherited by the child and closed there. service.rs: 19 -> 14. `spawn_host` keeps `unsafe fn`: it takes a raw `HANDLE`, and an invalid one is UB. That is a contract worth stating, so it keeps the marker. Windows-only note: `LoadLibraryExW` here already passes `LOAD_LIBRARY_SEARCH_SYSTEM32`, so the newdev.dll load cannot pick up a planted DLL from the working directory — now recorded in the SAFETY note rather than left implicit. Verified on Windows .47: punktfunk-host clean at EXITCODE=0 — zero errors, zero unused-unsafe. Crate's own files now 29 E0133 (interactive.rs 15, service.rs 14), down from 42. |
||
|
|
307ca88ba7 |
refactor(frame): dxgi.rs to zero E0133, and two of its unsafe fns didn't need to be
First crate done under `unsafe_op_in_unsafe_fn`. 23 sites -> 0, and the file's
`unsafe fn` count drops 5 -> 2, because narrowing was the wrong answer for two of
them:
`enable_inc_base_priority` takes no arguments and touches only the current process's
own token. `hags_enabled` takes a `LUID`, a plain value with no pointer a caller
could get wrong, and builds every gdi32 argument struct locally. Neither leaves a
precondition for a caller to uphold, so neither should have been `unsafe fn` at all —
they are now safe fns with the unsafe confined inside, which deletes the obligation
from their callers rather than restating it. That is a strictly better outcome than
wrapping their bodies, and it is invisible if you treat the lint as a mechanical
find-and-wrap.
`d3dkmt_set_scheduling_priority_class` stays `unsafe fn`: it takes a `HANDLE`, and an
invalid one is UB. That contract is real, so it keeps the marker and gains narrow
blocks inside instead.
The rest is narrowing. The clearest win is `elevate_process_gpu_priority`, whose
`ONCE.call_once(|| unsafe { .. })` had 25 lines inside one block — a `match` on a
config enum, three `tracing!` arms and an early return — for exactly one call that
needed it. That block is gone; only the `d3dkmt_*` call carries `unsafe` now.
Every remaining block is scoped to the FFI call it covers and carries a SAFETY note
naming what makes it hold: which handle is live, which local the callee writes,
which size matches which variable.
Verified: Windows .47 pf-frame clean at EXITCODE=0 — zero errors, zero E0133, zero
unused-unsafe. Linux .21 pf-frame zero errors (dxgi.rs is Windows-gated, checked for
regressions in the shared parts).
|
||
|
|
0a710fdfe5 |
refactor(frame): narrow make_device's unsafe to the FFI calls themselves
First slice of the `unsafe_op_in_unsafe_fn` work on the Windows-gated code, and a
deliberate demonstration of the shape the rest should take.
`cargo fix` is NOT the tool here. Its suggestion for this lint wraps the entire
function body:
pub unsafe fn make_device(..) -> Result<..> { unsafe {
..40 lines..
}}
One block spanning everything, `{ unsafe {` / `}}` bracing, and — the actual problem
— zero change in how much code runs unchecked. It makes the status quo explicit
rather than smaller, which is the opposite of the goal.
Narrowed by hand instead: four blocks, each around the FFI call that needs it
(`D3D11CreateDevice`, two `SetGPUThreadPriority`, `SetMaximumFrameLatency`), each
with its own SAFETY note. The `Option` handling, the `?`, the `if let Ok(..)` casts
and the `tracing!` calls are back outside, where the compiler checks them. Roughly 40
unchecked lines become 6.
Fixed two of my own mistakes in the same pass: `elevate_process_gpu_priority` and
`auto_priority_gate` are ALREADY safe fns, and I had wrapped their calls in `unsafe`
out of momentum. Unnecessary `unsafe` is not free — it is the same "this is scary"
noise the lint exists to remove, and the compiler only warns about it. That mistake
is a good argument against doing the remaining ~590 sites mechanically.
pf-frame drops 23 -> 19 E0133. The remaining three functions in this file need a
different answer, not just narrowing: `enable_inc_base_priority` takes no arguments
and touches only the current process's own token, so it has NO caller contract and
should become a SAFE fn with unsafe blocks inside — which removes an `unsafe fn`
rather than decorating it. Same likely for `hags_enabled(LUID)`, whose only argument
is a plain value.
Verified on Windows .47: pf-frame checks clean at EXITCODE=0, no errors, no
unused-unsafe warnings.
|
||
|
|
a3843b6996 |
test(encode): the VAAPI dmabuf path verified on AMD silicon, and its owner-only fields say so
Closes the last unverified leg of the AvBuffer work. `DmabufInner` was the heaviest
ownership change in the crate — four owned objects (DRM device, derived VAAPI device,
DRM-PRIME frames ctx, filter graph) whose eight failure branches each repeated the
same four-line unwind, once inside a macro, plus a ninth copy in `Drop` — and it had
no coverage at all. `vaapi_cpu_encode_smoke` does not reach it: that drives the
swscale/CPU-upload path, which uses `VaapiHw` and never builds a graph.
`dmabuf_inner_alloc_drop_cycles` loops construct/drop, which is the whole contract
now that every handle releases itself. **It passes on a Radeon 780M**, as do the two
pre-existing VAAPI tests, so `VaapiHw` and `DmabufInner` are both hardware-verified
rather than compile-only.
Also fixes three more owner-only fields the AMD box surfaced: `graph`,
`vaapi_device` and `drm_device` are `never read` since the hand-written `Drop` that
used to read them is gone. Same call as the decoders and the QSV pair — annotate
rather than delete (removing `graph` would free it while `src`/`sink` still point
into it) or underscore-rename (which hides what they hold). `drm_frames` is NOT
annotated: `submit` genuinely reads it per frame.
I had missed these twice: my Linux log greps searched "never constructed|never used",
which does not match "never read". The check now includes all three spellings.
Two environment notes worth keeping, both diagnosed by testing OUTSIDE our code
first (ffmpeg's own CLI reproduced each):
* Inside a distrobox on an immutable host, VAAPI needs
`LIBVA_DRIVERS_PATH=/run/host/usr/lib64/dri`. The container's mesa (25.3.6) is
older than the host's (26.0.4) and every encoder open fails with a bare ENOSYS —
"Function not implemented", naming nothing. The test doc says so.
* `cargo check --workspace` in that container fails on `glib-sys` (no GTK dev
headers). Unrelated to this branch; .21 checks the full workspace clean.
Verified: AMD .116 (Radeon 780M, Mesa 26.0.4) full pf-encode suite 33 passed / 0
failed plus all 3 ignored VAAPI hardware tests green. Linux .21 workspace check exit
0 / zero errors, pf-encode 33/0, pf-client-core 34/0, and zero dead-code warnings
across all three spellings.
|
||
|
|
4241bc0383 |
fix(encode/windows): enable D3D11 multithread protection before libav sees the device
libav turns on `ID3D11Multithread::SetMultithreadProtected` in
`d3d11va_device_create` — the path where it creates the device itself. We take the
other path, `d3d11va_device_init`, because we hand it the capturer's existing
`ID3D11Device`, and that path does not. Nothing enabled it on our side either, so it
was simply off. Two consequences, one of them shipping since long before this branch:
QSV zero-copy could not open at all. `av_hwdevice_ctx_create_derived(QSV <- D3D11VA)`
ends in `MFXVideoCORE_SetHandle`, and MFX rejects a device without multithread
protection as `MFX_ERR_UNDEFINED_BEHAVIOR (-16)`. libav reports that as "Error
setting child device handle", which names neither the cause nor the cure.
AMF — the DEFAULT Windows zero-copy path — has been running with a lock that does
nothing. We deliberately leave `lock`/`unlock` null so libav installs its
`d3d11va_default_lock`, and that lock is `ID3D11Multithread::Enter`/`Leave`, which
are documented no-ops while protection is off. The lock libav installs to serialise
our capture thread against its encode thread has therefore never actually serialised
anything. That is the more serious half of this fix, and it is not QSV-specific.
Measured on the Intel VM (UHD 750, FFmpeg 7.1.5 + libvpl), sweeping device flags x
adapter x protection:
BGRA -> QSV derive FAILED (-16)
BGRA +MT -> QSV derive OK
BGRA|VIDEO +MT -> QSV derive OK
`D3D11_CREATE_DEVICE_VIDEO_SUPPORT` makes no difference in either direction, and the
same result holds on both Intel adapters the box enumerates. Protection is the only
variable that matters, and it read back `was=false` every time, confirming nothing
else had enabled it. The hypothesis came from ffmpeg's own CLI succeeding at the
identical derive (`-init_hw_device d3d11va=d3d -init_hw_device qsv=q@d3d`) — the only
difference being who created the device.
With this, `zerocopy_qsv_alloc_drop_cycles` passes on real Intel silicon, which also
makes the QSV arm of the AvBuffer refactor hardware-verified rather than
compile-only. Its doc comment now points back here, since a future regression will
present as that same opaque "child device handle" line.
NOT flipped: `zerocopy_active` still defaults QSV off. The derive works and the
handles construct and release cleanly, but that is not the same as a validated
streaming session, and the default should move on glass evidence, not on this.
Verified on .47 (Intel UHD 750): full pf-encode --features amf-qsv suite 39 passed /
0 failed, d3d11hw_alloc_drop_cycles green, zerocopy_qsv_alloc_drop_cycles green, and
the native VPL path (qsv_encode_live_smoke) still green. Linux .21: workspace check
exit 0 / zero errors, pf-encode 33/0, pf-client-core 34/0.
|
||
|
|
2d78d2a514 |
test(encode): D3d11Hw verified on real silicon; the QSV derive is broken upstream of us
Ran the QSV probe on the Intel VM (UHD 750, FFmpeg 8). It fails — and not in the
code it was written to cover:
[AVHWDeviceContext] Error setting child device handle: -16
ZeroCopyInner::open(QSV) failed on iteration 0: derive QSV device from D3D11VA
That is `MFX_ERR_UNDEFINED_BEHAVIOR` out of `MFXVideoCORE_SetHandle`, raised inside
`av_hwdevice_ctx_create_derived` — libav's own code, called with the same arguments
as before this branch. Confirmed pre-existing by A/B: the same probe, written against
unmodified `origin/main` with the raw-pointer struct, fails byte-identically on the
same box at the same first iteration. The ownership change is not implicated.
It is also not the hardware. `qsv::tests::qsv_encode_live_smoke` — the native VPL
backend, untouched by this branch — encodes real H.264 on that box via VPL 2.15 with
its own D3D11 zero-copy. So QSV works; libav's QSV-from-D3D11VA derive is what does
not. That matches `zerocopy_active` already defaulting QSV **off** pending Intel
validation, and is now a measured fact rather than a suspicion. The test stays,
`#[ignore]`d, with the finding in its doc comment so the next Intel driver / FFmpeg
bump re-checks it instead of the question being quietly dropped.
What that leaves is real coverage of the half that IS reachable anywhere:
`d3d11hw_alloc_drop_cycles` loops construct/drop on `D3d11Hw`, the hwdevice +
frames-pool pair both Windows zero-copy vendors share, so it covers the AMF path's
ownership without needing AMD hardware. **It passes on the Intel box: 8 cycles, no
abort.** Adapter selection is factored into `test_hw_device`, which prefers a vendor
and skips the Microsoft Basic Render Driver — a punktfunk host enumerates our own
virtual-display adapter too, so `EnumAdapters1(0)` is not a safe assumption.
Verified: QSV VM .47 full `pf-encode --features amf-qsv` suite 39 passed / 0 failed
(EXITCODE=0), with `d3d11hw_alloc_drop_cycles` green on real silicon. Linux .21
workspace check exit 0 / zero errors, pf-encode 33/0, pf-client-core 34/0.
|
||
|
|
564797a12a |
test(encode): a QSV construct/drop smoke test, and AvFilterGraph goes Linux-only
Two loose ends ahead of running this on real Intel silicon. `zerocopy_qsv_alloc_drop_cycles` covers the one ownership question in the crate that was genuinely ambiguous. `ZeroCopyInner::open` builds a `D3d11Hw` and then DERIVES a QSV device + frames ctx from it, and the tuple it used to return handed those two derived pointers out twice — once as the encoder's args, once as the pair moved into `Self`. Free for raw pointers, two owners for `AvBuffer`. Looping construct/drop is what separates the outcomes: a double-unref aborts in the CRT, a missed one leaks an Intel device per session. Nothing else reaches this code — `zerocopy_enabled` defaults QSV OFF, and the native VPL backend supersedes this whole file unless `PUNKTFUNK_QSV_FFMPEG=1` — so the test calls `open` directly and sidesteps both gates, the same shape as `cuda_hw_alloc_drop_cycles`. `AvFilterGraph` is now `#[cfg(target_os = "linux")]`. The Windows gate had been reporting `struct AvFilterGraph is never constructed` since a960dff8 and I had been filtering it out of my own log greps (searching for "never read", which does not match "never constructed"). It is true: the VAAPI dmabuf path is the only filter-graph user, and the AMF/QSV backends build no graph. Cfg'd out rather than `allow`ed, so it cannot outlive its last caller unnoticed. Verified. Windows .133: `cargo test -p pf-encode --features amf-qsv --no-run` at EXITCODE=0 with the AvFilterGraph warnings gone (107 -> 105), and the test binary lists `ffmpeg_win::tests::zerocopy_qsv_alloc_drop_cycles`. Linux .21: workspace check exit 0 / zero errors, pf-encode 33 passed / 0 failed, pf-client-core 34 passed / 0 failed, and zero dead-code warnings there (AvFilterGraph is live on Linux). NOTE for running it: the test binary needs `C:\Users\Public\ffmpeg\bin` on PATH at RUNTIME. `FFMPEG_DIR` is build-time only, and without the DLLs the harness exits silently with no output at all — which reads exactly like a test that does not exist. |
||
|
|
e60fee7da6 |
refactor(encode): the QSV derived pair stops being two owners of the same pointer
`ZeroCopyInner` held the QSV device + frames ctx as nullable raw pointers, where null meant "AMF, which feeds D3D11 frames directly" — a convention documented in a comment and enforced by three `is_null()` checks. `Option<AvBuffer>` says it in the type, so the checks and the hand-written `Drop` both go away. The reason this one was left out of the previous commit is the aliasing. `open` built a five-element tuple whose QSV arm handed the SAME two pointers out twice: once as the encoder's `dev_ref`/`frames_ref`, once as the pair moved into `Self`. For raw pointers that is free; for an owning type it is two owners and a double-unref. Ownership and borrowing are now separated — the pair is owned in `qsv_frames`/`qsv_device`, and the encoder's arguments are `as_ptr()` views taken from whichever owner applies. `open_win_encoder` takes its own refs of what it is handed, so lending transfers nothing. Net: all 13 `av_buffer_unref` calls in this file are gone (the last two hand-written Drops with them), including the encoder-open failure arm, which collapses to `?` now that every handle releases itself. Field order pinned and commented, as with the others: QSV frames, QSV device, then `enc`, then `hw` — reproducing the old `Drop`, which ran ahead of all fields and so released the derived pair before the encoder's AddRef'd copies and the D3D11 refs. `qsv_device` picked up an `#[allow(dead_code)]`: `qsv_frames` is still read by the send path, but the device is now purely an owner (the frames ctx and the encoder each hold their own ref). Same call as the decoders — deleting it would free the device early, an underscore name would hide it. Verified on BOTH: Windows .133 `cargo check -p pf-encode --features amf-qsv --all-targets` EXITCODE=0, no dead-code warnings, and confirmed non-vacuous (log shows `Removed 23 files, 36.2MiB` then a real `Checking pf-encode`). Linux .21 `cargo check --workspace --all-targets` exit 0 / zero errors, pf-encode 33 passed / 0 failed, pf-client-core 34 passed / 0 failed, and `cuda_hw_alloc_drop_cycles` still passing on real CUDA. The QSV path itself still wants Intel silicon to exercise. |
||
|
|
5b142a7e85 |
refactor(encode): the Windows D3D11VA hwdevice owns its refs
`D3d11Hw::new` is the third instance of the same shape as `CudaHw` and `VaapiHw` — alloc a hwdevice, deref to fill it, init, alloc a frames ctx, deref, init — with the same hand-written unwind: three `av_buffer_unref` calls spread over the failure branches plus a `Drop` repeating the pair. It gets the same treatment, so all three libav hwdevice wrappers in the crate now share one release path. Field order carries the semantics here as in the other two: frames declared before device, so declaration-order dropping reproduces what the hand-written `Drop` did. `ZeroCopyInner`'s `qsv_device`/`qsv_frames` are deliberately NOT converted in this commit. They are nullable (null for AMF, which feeds D3D11 frames directly), and the QSV branch hands the same two pointers out twice — once as the encoder's `dev_ref`/`frames_ref` and once as the owned pair moved into the struct. Modelling that needs `Option<AvBuffer>` plus a borrow/own split so the aliasing does not become two owners, which is a different change from the mechanical one this commit makes. Verified on the Windows runner .133: `cargo check -p pf-encode --features amf-qsv --all-targets` at EXITCODE=0. Confirmed non-vacuous — the log shows `cargo clean -p pf-encode` removing 23 files / 36.2 MiB followed by a real `Checking pf-encode`, because a fast green on that box can otherwise just be cargo reusing artifacts whose timestamps outrank the freshly-extracted sources. |
||
|
|
06a406adf2 |
refactor(client): the D3D11VA decoder owns its refs too, and the owner-only fields say so
Completes the decoder trio. `D3d11vaDecoder::new` had the same cascading unwind as the VAAPI and Vulkan constructors — four branches each unref'ing the hwdevice by hand — and `d3d11va_decode_supported` opened a frames context it released on one exit path while relying on the null check for the other. Both are `AvBuffer` now. The one surviving `av_buffer_unref` in the file is deliberate: it runs before ownership is taken, on the `av_hwdevice_ctx_init` failure ahead of `from_raw`. The Windows gate then caught something worth keeping: `field hw_device is never read`, in all three decoders. It was true and it was new — the hand-written `Drop` used to be the field's only reader, so moving the unref into the type left a field that is *purely* an owner. The compiler cannot express that, so each one now carries a doc comment saying what it is and an explicit `#[allow(dead_code)]`. Deleting the field would free the device early; renaming it `_hw_device` would silence the lint by hiding what it holds. Neither is what we mean. Verified on BOTH platforms this time. Windows runner .133 (the toolchain env from the runner notes, plus `PF_FFVK_VULKAN_INCLUDE` for pf-ffvk): `cargo check -p pf-client-core --all-targets` at EXITCODE=0, clean of the dead-code warnings. Linux .21: `cargo check --workspace --all-targets` exit 0 / zero errors, pf-client-core 34 passed / 0 failed, pf-encode 33 passed / 0 failed. |
||
|
|
a42ab075a8 |
refactor(client): the hardware decoders own their hwdevice ref
`VaapiDecoder::new` and `VulkanDecoder::new` create a hwdevice and then do several more fallible things with it — resolve `vkWaitSemaphores`, find the decoder, alloc the context, open it — and every one of those branches unref'd the device by hand, with a `Drop` doing it once more. Six hand-written unrefs between them, each one a line somebody has to remember when adding a step. `video_libav::AvBuffer` owns it instead, so an early `bail!` releases it and the branches carry nothing. It is a deliberate second copy of `pf-encode`'s type: the crates do not depend on each other (host encode and client decode share no code path), and this one needs something the host's does not — see below — so hoisting it to a shared crate would mean giving that crate an ffmpeg dependency and both sets of semantics to save about twenty lines. That extra piece is `into_raw`. `pick_vulkan` hands its frames context to the codec (`(*ctx).hw_frames_ctx = fr` — the codec unrefs it when the context closes), so the wrapper must give up ownership rather than drop. Making the transfer explicit is the point: dropping an `AvBuffer` there too would be exactly the double-unref this type exists to prevent. Two `av_buffer_unref` calls survive in `video_vulkan.rs` on purpose. One runs before ownership is taken (the `av_hwdevice_ctx_init` failure, ahead of `from_raw`); the other releases the codec's OWN pre-existing frames ctx before we replace it, which was never ours to model. Field order preserved: `hw_device` stays declared after `ctx`, so it still releases after each `Drop` frees packet/frame/context — the order the hand-written unref had. Verified on .21 (CachyOS, FFmpeg 62): `cargo check --workspace --all-targets` clean at exit 0 with zero errors — the workspace-wide check owed since the AvBuffer commit — plus pf-client-core 34 passed / 0 failed and pf-encode 33 passed / 0 failed. The decoders themselves still need real VAAPI/Vulkan playback to exercise. |
||
|
|
eb9c5be20d |
refactor(encode): the VAAPI dmabuf path stops unwinding by hand
`DmabufInner::open` builds four owned objects — a DRM device, a VAAPI device derived from it, a DRM-PRIME frames context, and a filter graph — and every one of its eight failure branches unwound them by hand. The same four-line block (`avfilter_graph_free` + three `av_buffer_unref`s) appeared eight times, once *inside a macro*, plus a ninth copy in `Drop`. Adding a step to that function meant remembering to extend the unwind at exactly the right depth; getting it wrong leaks a device per failed session (the persistent listener accumulates them) or frees one twice. All eight are gone. `AvBuffer` already owned the buffer refs; `AvFilterGraph` now does the same for the graph, so each handle is owned the moment it exists and an early `bail!` releases whatever was built so far. `open` lost ~40 lines of cleanup and gained none. Field order in `DmabufInner` is load-bearing and says so: graph, frames, VAAPI device, DRM device, then `enc` LAST. Fields drop in declaration order, and that sequence reproduces the old hand-written `Drop` exactly — including that it ran ahead of every field, so all four were released before ffmpeg-next dropped the encoder. Everything here holds its own reference, so refcounting makes any order sound; the ordering is pinned so a future reorder cannot quietly change what ships. One subtlety preserved deliberately: the buffersrc parameters take `drm_frames` BORROWED, not ref'd (`av_buffersrc_parameters_set` takes its own ref). That is now `drm_frames.as_ptr()` — same borrow, same single owned ref, no new leak. Verified on .21 (CachyOS, RTX 5070 Ti, FFmpeg 62): `cargo check -p pf-encode --all-targets` clean at exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and `cuda_hw_alloc_drop_cycles` still passes against real CUDA. vaapi.rs now contains zero `av_buffer_unref` and zero `avfilter_graph_free` calls, down from 40 and 8. The dmabuf path itself still needs AMD/Intel silicon to exercise end to end. |
||
|
|
7adc1db672 |
refactor(encode): AVBufferRef ownership moves into an RAII handle
`CudaHw::new` and `VaapiHw::new` are the same shape — alloc a hwdevice, deref it to fill fields, init, alloc a frames ctx, deref, init — and both unwound by hand: `av_buffer_unref` on every failure branch (three in CUDA, two in VAAPI) plus a hand-written `Drop` repeating the pair. That shape has two failure modes and the compiler can see neither: add a branch and forget the cleanup (leak), or let two cleanup paths run (double-unref, an abort inside glibc). `libav::AvBuffer` owns the ref instead. It null-checks on the way in — the check each caller open-coded — and unrefs exactly once on drop, so an early `?` releases whatever was built so far and the failure branches carry no cleanup at all. Both hand-written `Drop` impls are gone, and `linux/mod.rs` goes from seven `av_buffer_unref` calls to zero. The one subtlety, called out at both structs: these two fields must stay declared frames-BEFORE-device. Fields drop in declaration order, and the code being replaced deliberately unref'd frames first (a frames ctx holds its own reference on its device). Refcounting makes either order sound, but a field reorder should not silently change what ships, so the ordering is load-bearing and commented as such. Also adds `cuda_hw_alloc_drop_cycles` — the RAII path had NO test coverage: the NVENC smoke tests take the CPU path and never construct a `CudaHw`, and the VAAPI twin's tests need AMD/Intel silicon. Looping construct/drop is what catches the double-unref (abort) and the leak (allocator growth) this refactor is about. Verified on Nobara (RTX 5070 Ti): `cargo check -p pf-encode --all-targets` clean at exit 0, `cargo test -p pf-encode` 33 passed / 0 failed, and `cuda_hw_alloc_drop_cycles` passes against a real CUDA device — eight construct/drop cycles, no abort. `VaapiHw` is compile-verified only; it is the identical shape but no AMD/Intel box was reachable to run its two ignored tests. |
||
|
|
5219107177 |
chore(unsafe): the workspace adopts the drivers' unsafe discipline
`packaging/windows/drivers/*` has run `deny(unsafe_op_in_unsafe_fn)` +
`deny(clippy::undocumented_unsafe_blocks)` for a while, with `forbid(unsafe_code)`
on the modules that need no unsafe at all. The main workspace had no lint config
whatsoever, so nothing stopped a clean crate from quietly growing an `unsafe`, and
nothing distinguished the handful of genuinely-unsafe lines inside a 600-line
`unsafe fn` from the safe ones surrounding them.
Three things, all mechanical:
* `#![forbid(unsafe_code)]` on the eight crates that already contain zero unsafe
(`pf-driver-proto`, `pf-host-config`, `pf-paths`, the three clean clients, both
tools). These were clean by accident, not by contract; now they are clean by
contract.
* `unsafe_op_in_unsafe_fn = "warn"` workspace-wide. `unsafe fn` states a contract
the CALLER must uphold — it was never meant to switch off checking for the whole
body. Measured fallout is 300 sites on Linux, and they are concentrated: six
files carry all of them, while `punktfunk-core`, `pf-frame`, `pf-clipboard` and
`pf-vdisplay` are already at zero. `warn` (not `deny`) so the build stays green
while those six are worked down; it flips to `deny` once they are. This is also
the Rust 2024 default, so it pays off the edition migration early.
* `proc::current_uid()` replaces eight `unsafe { libc::getuid() }` blocks. Each
site had copied out the same SAFETY note verbatim, which is the tell: `getuid()`
is parameterless, always succeeds and touches no memory, so there is no contract
for a caller to uphold and no reason for the unsafe to be visible eight times.
One `unsafe` behind a safe wrapper, none at the call sites.
Verified: `pf-vdisplay` builds clean on Linux (Nobara) at zero E0133; the
macOS-buildable crates build clean locally. No behaviour change.
|
||
|
|
37edb1cc1a |
refactor(clients): both shells wake through the shared state machine
audit / bun-audit (push) Successful in 21s
ci / web (push) Successful in 1m39s
ci / docs-site (push) Successful in 2m7s
audit / cargo-audit (push) Successful in 2m55s
apple / swift (push) Successful in 5m25s
ci / bench (push) Successful in 7m47s
ci / rust-arm64 (push) Successful in 10m28s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
windows-host / package (push) Failing after 11m22s
windows-host / winget-source (push) Skipped
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
deb / build-publish-client-arm64 (push) Failing after 6m0s
deb / build-publish (push) Successful in 13m3s
arch / build-publish (push) Successful in 16m27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
flatpak / build-publish (push) Successful in 6m50s
android / android (push) Successful in 19m52s
deb / build-publish-host (push) Successful in 16m54s
ci / rust (push) Successful in 22m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m33s
release / apple (push) Successful in 30m41s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
apple / screenshots (push) Successful in 22m37s
The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left in each shell is the part that genuinely differs: its dialog or screen, its advert drain, its re-key, and its route back into the trust gate. Behavioural review, since a sleeping host to test against wasn't available: - GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second like every other implementation. Up to half a second slower to notice a host coming back, and exactly the reference cadence in exchange. - GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully and takes the last, i.e. the freshest address. Same connect, fresher re-key. - Windows sent one packet before the loop AND one on its first pass; the machine owns the packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the budget still expires at 90 s. - Both keep their own cancel path, their own re-key, and their own error surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
983ff8e78d |
fix(session/console): a profile must not advertise a cursor channel the presenter isn't drawing
Found reviewing the P0 wiring. The console builds its window and input models ONCE, from
the global defaults, and they live across every launch — but each launch now re-resolves
the host's profile. So a profile that sets `mouse_mode: desktop` on a host, while the
global stays `capture`, made the session advertise `CLIENT_CAP_CURSOR` ("this client
draws the host cursor itself") to a presenter latched in capture mode, which draws no
cursor: the host stops compositing the pointer and the stream has no visible cursor at
all — the exact failure the capability's own doc comment warns about.
The advertisement now follows the LATCHED mouse model, which is the one that is actually
true of the presenter. Everything the host is told about the stream itself — mode,
bitrate, codec, audio, pad — keeps honoring the binding, and the comment says which
fields are latched and why, so the console's presentation-tier gap is documented rather
than rediscovered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
eab4829630 |
feat(client): the brain layer — one connect plan, one wake machine, one spawn
C0 of design/client-architecture-split.md. Wake-then-connect exists three times today — GTK's `WakeConnect`/`wake_fallback`, the WinUI shell's `wake_and_connect` (whose comment says it "mirrors the Apple HostWaker"), and Apple's `HostWaker` itself — and the deep-link and profile work was about to make it five. `pf-client-core` already held the ingredients (wol, discovery, trust, library, session); what was missing was the layer that composes them, so every front-end composed them itself. `ConnectPlan` is a resolved intent: which host, which pin, which launch id, which profile, the effective settings, whether to wake. One constructor per door — a card click, a CLI verb, a URL — one type out. `ConnectPlan::resolve` is pure (stores in, plan out), which is what lets the URL router be tested without a config directory; `for_host` is the convenience that loads them. `plan_from_link` is where the deep-link security rules actually live, rather than in each shell: a contradicted fingerprint refuses and says which host it was about, an ambiguous name refuses instead of picking the first, a profile the catalog can't honor refuses BEFORE anything is dialled, and an unknown — or known-but-never-pinned — host becomes a confirmation sheet carrying the claimed name and expected pin, so the first connect is verified rather than blind TOFU. Preempting a live session stays with the caller: only the front-end knows one is running. `WakeWait` is Apple's `HostWaker` cadence as a pure step function — packet at 0 s and every 6 s, presence polled every second, 90 s budget, and a PARK (not an error) at the end, because "it didn't wake in 90 s" is usually "give it 10 more". As a step function each front-end drives it from its own loop and they still agree, and the whole cadence is testable without waiting 90 seconds. Session spawn, its argv and its stdout contract move here too, and the GTK shell now spawns through them — so "which flags does a stream get" and "what does ready mean" have one answer for the shells, the console and the coming CLI. `--profile` deliberately does NOT ride a plain card click: the session resolves the host's binding through the same helper, and passing it would be a second source of truth for one decision. Not yet adopted: the two shells' wake paths. That swap changes the most-used path in the product and the handoff wants it verified on glass with a genuinely sleeping host, which this session couldn't do — so the state machine lands, the duplication stays until it can be verified away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3350d3cfbf |
feat(client): the punktfunk:// grammar — one parser, one emitter, one vector file
D0 of design/client-deep-links.md. Apple has shipped `punktfunk://connect/<uuid>` for a
while; this is the same grammar, generalised and given a Rust implementation the GTK and
WinUI shells, the session and the coming CLI all share:
punktfunk://connect/<host-ref>[?fp=…][&host=addr[:port]][&launch=…][&profile=…][&name=…]
The rule the grammar exists to keep is that a URL may only ever do what a click on an
existing card could do, minus trust decisions. So it carries REFERENCES to things that
already exist on this device — never values: no resolution, no bitrate, no codec, so a
web page cannot shape a session beyond picking among the user's own configurations. And
`pair` is not a route: it parses, and refuses, so a link can never start a trust
ceremony. Everything hostile is rejected here, once, for every front-end — 2 KB total
cap, per-parameter caps, strict percent-decoding (a half-escape or invalid UTF-8 is an
error, not a shrug), control characters refused after decoding, launch ids held to the
charset the host and Decky already agree on, and `fp=` that must actually be 64 hex.
Resolution follows the same order everywhere: stable record id → unique host name →
`addr[:port]`, with `host=`+`fp=` as the reinstall recovery path, which is why
self-emitted links carry all three. An ambiguous name refuses rather than guessing; a
stale id with no recovery address refuses rather than dialling "11111111-…" as if it
were a hostname; an unknown-but-plausible address becomes the confirmation sheet, never
an auto-connect.
`pf://` parses as an input alias and is never emitted or registered — claiming a
two-letter scheme on MSIX/Apple is unconditional squatting, and a link that resolves on
one platform only is a trap.
The 44-case `clients/shared/deeplink-vectors.json` is the cross-language contract: this
module's tests run every case, and the Swift and Kotlin ports will run the same file, so
three parsers cannot drift into three different security postures. Refusal codes are
part of that contract, not just the happy path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a2be3f4de9 |
feat(client): settings profiles — the override catalog, the one resolver, and the session flag
A client setting is global today: pick 4K@120 HDR and the retro box gets it too.
The one escape hatch (per-host `clipboard_sync`) proves the shape works but covers a
single field. This is P0 of design/client-settings-profiles.md — the headless core the
shells, Apple, Android and the deep-link grammar all build against. No UI yet.
A profile is a NAMED BUNDLE OF OVERRIDES, not a snapshot: `SettingsOverlay` is sparse
`Option`s, so a field the user never touched keeps following the global value live, and
fixing a global once fixes it everywhere. `Some(x)` equal to today's global is still
meaningful — it pins x against a later global change — which is why the UI will write
`Some` on touch and `None` on an explicit reset, never by diffing.
The catalog is its own `client-profiles.json`, deliberately not part of the settings
file: that file has five whole-file load-modify-save writers with no merge, so a profile
written by one would be dropped by the next. Which host uses which profile is a field on
the host record instead of a map keyed by host — that is what dissolves the client's
long-standing "what identifies a host record" question for this feature.
Resolution has exactly one implementation, `trust::effective_settings()`:
effective = overlay(profile).apply(global)
profile = one-off pick ?? host binding ?? none
Both session construction sites go through it (`main.rs` AND `console.rs` — touching one
and not the other is a Windows-only build break), so the console, and therefore Decky,
honor host bindings with no work of their own. Nothing here can fail a connect: a
deleted binding resolves as "no profile", and a one-off that can't be honored falls back
to the DEFAULTS rather than to the host's own profile — "connect with Work" must never
quietly stream "Game". `--profile <id|name>` is the one-off door for the shells' coming
"Connect with ▸" and for `punktfunk://…&profile=`; `--profile ""` forces the defaults on
a bound host. The active profile's name closes the stats overlay's first line, so
"which profile am I on?" is answerable without leaving the stream.
Also here, because this effort touches every one of these anyway:
- `KnownHost` gains `profile_id`, `pinned_profiles` and a lazily minted stable `id`. The
id is groundwork (design §4.5) — no lookup is re-keyed, `fp_hex`/`addr:port` stay.
- `upsert` now preserves the state the USER set on a record — the profile binding, its
pins, the clipboard decision, the id — against refreshes that carry none of it.
`clipboard_sync` survived that only because the function happened not to mention it.
- `KnownHost::default()` exists so construction sites read `..Default::default()`; five
hand-written literals were exactly how a new field gets silently dropped on re-pair.
- All three client stores now write temp+rename. A torn settings file loads as `Default`
— i.e. silently resets every setting the user has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
33a31427ae |
fix(vdisplay): locks held across seconds-long work, an AB/BA inversion, and a panic that poisons the manager
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m9s
ci / bench (push) Successful in 5m30s
windows-host / package (push) Failing after 7m52s
windows-host / winget-source (push) Skipped
android / android (push) Successful in 12m10s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
ci / rust-arm64 (push) Successful in 13m14s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
deb / build-publish (push) Successful in 9m5s
docker / build-push-arm64cross (push) Successful in 16s
docker / deploy-docs (push) Successful in 25s
arch / build-publish (push) Failing after 17m12s
deb / build-publish-client-arm64 (push) Successful in 8m56s
ci / rust (push) Failing after 17m13s
deb / build-publish-host (push) Successful in 10m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m39s
apple / swift (push) Successful in 5m32s
apple / screenshots (push) Successful in 25m53s
Phase 7's contained half. 7.1's writer split and 7.3's reservation are NOT here — see the plan; both need more than a local edit. **Lock-order inversion, gamescope.** `create_managed_session_steamos` held MANAGED_SESSION while taking STEAMOS_TOOK_OVER (and `persist_takeover`'s locks), while `do_restore_tv_session` takes STEAMOS_TOOK_OVER first and MANAGED_SESSION second — AB/BA between a connect and the restore worker, which genuinely run concurrently (`vd.create` runs off the registry lock; the worker is its own thread). The connect path now drops its guard before the pair and re-acquires after `poll_managed_node`, the shape `create_managed_session` already used. And `takeover_live` held all four statics at once — in a `||` chain every `.lock()` temporary lives to the end of the statement — for four reads; scoped bindings drop each guard before the next, taking it out of the ordering graph entirely. **`observe_session_instance` decided and acted under one lock.** It held LAST_INSTANCE across `invalidate_backend` (which drops keep-alive displays through the registry lock) and a `systemctl` shell-out on a 10 s budget, from three call sites including a per-second watcher. It decides under the lock and acts outside now; the baseline is advanced inside it, so a concurrent observer cannot run the action twice. **`admit` held the live-session table across the budget checks** — `manager::snapshot()` blocks on the manager `state` lock, which is itself held across DDC round trips and three 3 s activation ladders. Every connect, disconnect and mgmt read queued behind one slow display operation. The guard is scoped to `decide` alone. **GameStream never registered**, so its display was invisible to both Windows budgets (they gate on `!live.is_empty()`) and a native connect could be admitted past capacity the box had already spent. It registers now, anonymously, for the session's lifetime. **`ensure_exclusive_watch` panicked on a failed thread spawn** while holding BOTH `exclusive_watch` and (via its caller) `state` — poisoning the two locks the whole manager runs on, so every later acquire and mgmt read would panic on `.lock().unwrap()`. It logs and degrades instead: no re-assert watchdog is a degradation, a wedged manager is not. Also fixes two things only a Windows build shows, both mine: Phase 2.3 left `gamescope_route` unused there (every reader is Linux-gated), which `-D warnings` in Windows CI would have rejected — I had only run the host clippy on Linux. And Phase 5.4's helper-safening reaches a FOURTH crate, pf-inject, which xcheck does not lint. Verified on .173: `cargo clippy -p punktfunk-host -p pf-vdisplay --all-targets` clean across the whole tree; local fmt, both xcheck targets and 53 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
093c66cb1a |
fix(vdisplay/windows): a failed resize left a phantom monitor, and a refresh-only one silently didn't happen
Three defects in the mid-stream resize path. **A failed re-arrival put a DEAD monitor back in the slot.** `re_add` REMOVEs the old driver monitor before it ADDs the new one, so its only fallible step past that point means the old monitor is gone — yet the caller answered an `Err` by re-inserting the `Monitor` struct it had handed in, complete with a dead `key`, a departed `target_id` and a stale `gdi_name`. Every later `acquire` then joined a monitor that did not exist. It now rolls back: on ADD failure it re-ADDs at the OLD mode, so the session keeps streaming what it already had. The outcome is a three-way `ReAdd` enum because the distinction matters and a `Result` cannot carry it — `Arrived` stores the new monitor, `RolledBack` stores the RECOVERED one (a re-arrival mints a fresh `target_id`, so the struct the caller handed in is stale either way), and `Lost` means both ADDs failed and the slot must be left EMPTY so the next acquire creates a monitor rather than joining a phantom. **A refresh-only change reported success at the old refresh.** `wait_mode_advertised` compared resolution only, so a same-WxH new-Hz request answered "already advertised": the fast path skipped the `IOCTL_UPDATE_MODES` that would have taught the driver the new rate, `set_active_mode` fell back to the best advertised rate <= requested, and the resize returned Ok. It matches `dmDisplayFrequency` now, so an unadvertised refresh routes to UPDATE_MODES / the re-arrival instead. And `mon.mode = mode` recorded what was ASKED FOR. `set_active_mode` deliberately falls back to a lower advertised refresh rather than lose the client's resolution, so the field could claim a rate the display was not running — and it is what the next resize diffs against and what `/display/state` reports. It records what actually committed now, read back via the new `active_mode` (`active_resolution` delegates to it), and logs when the two differ. Deliberately NOT done by tightening `wait_mode_settled`: that gates the re-arrival fallback, so requiring an exact refresh there would force a full re-arrival every time the OS legitimately picked a lower rate. **A short IOCTL_ADD reply leaked an IddCx slot.** The IOCTL had SUCCEEDED — the driver already created the monitor and took a slot; only the reply was short — and the bail undid none of it. The slot pool is small enough that ~16 leaks wedge every later ADD at 0x80070490, which is the wedge the ghost-reap in this same function exists to recover from. It now issues the compensating REMOVE with the session id already in scope, and says so at error level if that fails too. Windows runner: clippy --all-targets clean, pf-vdisplay 55 passed, pf-capture 18 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
50a153b72a |
refactor(win-display): the CCD helpers were unsafe fn for serialization, not soundness
`pf-win-display`'s 21 CCD/GDI helpers were `pub unsafe fn` without a memory-safety obligation between them. Every one takes `Copy` scalars or borrowed Rust data, returns owned values, and discharges its FFI preconditions internally — `retry_set_display_config` even binds the input desktop itself, which is the one thing a caller could plausibly get wrong. Their own `# Safety` sections say as much: one reads "safe to call from any thread", and the rest say "call under the manager `state` lock". That is a SERIALIZATION requirement — calling one unlocked races the topology mutator and returns a stale answer, a correctness bug, not undefined behaviour. Encoding it with `unsafe fn` cost 55 `unsafe` blocks across THREE crates whose proofs could only restate "this takes a u32 and returns a String". That is how `unsafe` stops meaning anything: the blocks that wrap a real FFI obligation read exactly like the ones that wrap nothing. The requirement is now prose on the functions that have it, and `unsafe` marks only the FFI calls inside the helpers. win_display.rs 97 -> 63 `unsafe`, manager.rs 56 -> 30, pf_vdisplay.rs 34 -> 32, plus 10 sites in pf-capture's IDD-push paths. 55 blocks and 55 orphaned `// SAFETY:` proofs gone; `#![allow(clippy::missing_safety_doc)]` with them, since nothing is `pub unsafe fn` any more. Two things the compiler handed back once the blocks went, both of which existed only to carry a proof: the `let-else` parens in `force_mode_reenumeration` and `apply_source_positions`, and the nested `if` in `wait_mode_settled` — which is `&&` again, still short-circuiting, so the second CCD query on a 25 ms poll is unchanged. Scope note: the sweep and the plan both scoped this as two crates. It is three — pf-capture calls the same helpers from 10 sites, and `unused_unsafe` under `-D warnings` makes that non-optional. Found by re-deriving the count, which the plan asked for because the underlying finding had been REFUTED in verification; the refutation was wrong about the magnitude (22 of manager.rs's 45 blocks, against its claimed 24). Verified on the Windows runner: clippy --all-targets clean over pf-frame, pf-win-display, pf-capture and pf-vdisplay; pf-vdisplay 55 passed, pf-capture 18 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
029979e824 |
fix(vdisplay/windows): own the device handle, prove the ladder once, and fix two proofs that were wrong
Three of Phase 5's four items; 5.4 is held for a decision (see below). `open_device` is a SAFE fn returning an `OwnedHandle`. It never had a caller obligation — it takes no arguments and every precondition is internal — so the `unsafe fn` it used to be pushed a proof burden onto four call sites with nothing to prove, and handed back a raw `HANDLE` that two of them re-owned by hand with `CloseHandle`. That shape has already leaked once in this file (the wrap-IMMEDIATELY comment in `open` records it). Ownership is now a `Drop`, `probe()` is `open_device().map(|_| ())`, `is_available()` is `open_device().is_ok()`, and `CloseHandle` is no longer imported here at all. `resolve_target_gdi` ran the same 60 × 50 ms poll three times verbatim, and the 2nd and 3rd copies documented themselves as "SAFETY: as the resolve loop above" — a pointer to a proof rather than a proof, which rots silently the moment the block it points at moves. One `poll_gdi_name`, one proof, and the three-stage ladder now reads as the three stages its doc describes. Two proofs were simply wrong, both discharged everything except the one obligation that mattered: * The SetupAPI detail buffer was a `Vec<u8>` (align 1) written through as `SP_DEVICE_INTERFACE_DETAIL_DATA_W` (align 4). The SAFETY comment proved bounds and aliasing and was silent on alignment. Now a `Vec<u64>`. Its guard also compared the required size against `size_of::<u32>()` while stamping `size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>()` into `cbSize` — checking a different number than the one it promises the OS. * `GetMonitorInfoW` was handed `&mut info.monitorInfo`: `cbSize` promises 104 bytes but that pointer's provenance covers the 40-byte `MONITORINFO` prefix, so everything the OS writes past byte 40 — which is `szDevice`, the only field the caller reads — is out of bounds for it. A compiler may assume those bytes were untouched and fold the zeroed initializer into the reads, i.e. return an EMPTY device name. That name is what `panel_off_except`'s exclusion compares against, so an empty one darkens the panel it is meant to spare. The pointer now carries the full struct's provenance. pf_vdisplay.rs 42 -> 34 `unsafe`, manager.rs 58 -> 56. Windows runner: clippy --all-targets clean, 48 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f94191d927 |
fix(vdisplay/linux): stop destroying the user's portal config, and honour the /tmp promise the docs made
Both wlr-family backends need ONE key set in a config file the user also owns —
`chooser_cmd` in xdpw's `[screencast]`, `custom_picker_binary` in xdph's
`screencopy { … }`. Both did it by `fs::write`-ing a COMPLETE file over whatever was
there, so every other setting the user had in
`~/.config/xdg-desktop-portal-wlr/config` or `~/.config/hypr/xdph.conf` was destroyed
on first connect — silently, permanently, no backup.
They now set that one key in place and keep every other line byte-for-byte, with a
one-time `create_new` backup the first time we touch a file we did not write (once, so
a later edit cannot overwrite the user's ORIGINAL with our own earlier output).
The merge lives in its own module because a merge is only worth doing if it cannot
corrupt what it merges into: seven tests cover the three cases per grammar — block
absent, key present, key absent — plus idempotence (which is what keeps the caller
from restarting the portal on every connect) and the empty-file shape. Four of them
FAIL against the old whole-file write, so they are not vacuous. The module is declared
unconditionally though only Linux calls it: the logic is pure string handling, and
tests that guard a user's real config should run on every platform's CI, not only
where the callers compile.
Separately, seven call sites resolved the runtime dir as
`env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into())` while their own doc
comments promised the opposite — "per-user, 0700 — NOT a world-writable /tmp path
another local user could pre-create or rewrite between our write and xdph's read". One
of those paths is an executable xdph then RUNS. `Ok("")` was the same defect's other
half: it yielded a path relative to the process CWD rather than any runtime dir. All
seven now go through one resolver that applies the rule `session.rs` already had
(non-empty, else `/run/user/<uid>`, never /tmp). The picker shim also gets its mode at
CREATION rather than a chmod afterwards, so it is never briefly world-readable while
being a file something else executes.
And identity.rs was the only writer in the workspace creating
`pf_paths::config_dir()` — which holds the host private key, the pairing allow-list and
the mgmt token — with a plain `create_dir_all` instead of the 0700 `create_private_dir`
every other writer uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2c015bba30 |
fix(vdisplay/windows): three teardown gates that each ended with the operator's panels dark
All in the same ~120 lines, all the same shape: a recovery leg gated on the wrong flag, so the path that turned something off had no path that turned it back on. **The DDC wake was nested inside the CCD-restore arm.** Panels are commanded dark over DDC/CI *before* the isolate, but `panel_on_all()` sat inside `if let Some(saved) = ccd_saved.take()` — and `isolate_displays_ccd` returns `None` whenever its `query_active_config` fails. So a failed isolate meant the panels were never woken, and because the link stays live there is no returning signal to trigger a DPMS wake either: dark for the rest of the host's life. Hoisted out of that arm, next to the `pnp_disabled` restore which was already outside it for exactly this reason. **A later member's isolate could deactivate the physicals with nothing able to restore them.** If the FIRST member's isolate failed, `ccd_saved` stayed `None` and `ccd_exclusive` `false`; a second member's isolate then succeeded, blanked the physicals, and discarded its snapshot as "the group restores the first member's" — except there wasn't one. Teardown's restore is gated on `ccd_saved`, and the re-assert watchdog never started. The group now adopts the first SUCCESSFUL isolate's snapshot. **A non-last-member teardown ran the exclusive isolate on a Primary group.** The gate was `ccd_saved.is_some()`, but `Topology::Primary` stores a snapshot too (from `set_virtual_primary_ccd`) — so a shrink cleared `DISPLAYCONFIG_PATH_ACTIVE` on every non-kept path, blanking precisely the physical displays `Primary` exists to keep lit. It keys off `ccd_exclusive` now (what the re-assert watchdog already checks), and a Primary shrink re-promotes a survivor instead, since the departing member may have been the one holding primary. Also: `apply_group_layout` had only ever run from `acquire`, so a member LEAVING never re-arranged the survivors — they kept the origins computed while the departing monitor was still between them, leaving a dead gap in the desktop until the next acquire. Now called at the end of `teardown_removed`, which is where the slot map has already shrunk and after the REMOVE, so it covers all six teardown paths at once. The shrink gate is extracted as `shrink_action` so it can be pinned without a driver or a desktop — manager.rs had no tests at all, which is how a gate keyed on the wrong flag survived. Verified on the Windows runner: 48 passed (was 46), and both new tests FAIL against the old gate, so they are not vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8b076f5cc9 |
fix(vdisplay): the gamescope sub-mode is a per-session value, not a process-global env write
Completes what the env-lock fix started. `apply_input_env` published its decision into
PUNKTFUNK_GAMESCOPE_NODE/_SESSION and `GamescopeDisplay::create` read it back out —
with the lock released in between, and with the GameStream plane, the mid-stream
switch watcher and the capture-loss rebuild all re-running the writer. So session B's
routing decision could retarget session A's `create`: A asks for a bare spawn, B
connects, A attaches to B's session instead.
The decision now travels as a `GamescopeRoute` return value and is carried on the
backend INSTANCE via `set_gamescope_route` — the discipline `set_launch_command`
already documents two lines above it ("Carried on the backend instance — NOT a
process-global env var — so concurrent sessions can't stomp each other's launch
target"). Nothing writes the two keys any more; they are read once, as operator
overrides, and `create`/`poolable_now`/`launch_is_nested` all answer from the
session's own route.
The route carries its payload rather than just the verdict, so the managed session
flavour and the attach node id stop being separate env reads too.
Threaded through both planes: handshake -> SessionContext -> `vd`, and the GameStream
`open_source` -> `run`. Three re-resolution points needed the same treatment and each
would have silently downgraded a session to a bare spawn without it — the switch
watcher's rebuilt backend, the capture-loss rebuild (which can swap the whole
instance), and the operator-pinned path, which deliberately skips the input-backend
write but still needs a route. That last one is why the ladder is split out as
`resolve_gamescope_route`: a `PUNKTFUNK_COMPOSITOR` pin must not silently mean "bare
spawn" on a box pinned to the managed session.
`#[must_use]` on both entry points found the capture-loss site, which I had missed.
Verified on 192.168.1.21: whole workspace `cargo check -p punktfunk-host`, clippy
--all-targets clean over the host and pf-vdisplay, 100 pf-vdisplay tests, and the live
GNOME create/drop still green. Windows + Linux xcheck clean on the Mac.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
80e57c70be |
fix(vdisplay): the env lock covered its writers and not its readers, and the gamescope ladder read back its own output
Two halves of the same problem — process env used as shared mutable state. `apply_session_env` set XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS / WAYLAND_DISPLAY / HYPRLAND_INSTANCE_SIGNATURE / SWAYSOCK under ENV_LOCK, while detection read those same five keys with no lock at all, from five points spread across a `/proc` scan. That is exactly the hazard ENV_LOCK's own doc names: glibc `setenv` can realloc `environ` and free the old value string, so a concurrent `getenv` reads freed memory — UB documented there as "could crash the host". It is reachable today: the host's session watcher calls `detect_active_session` every second for a stream's lifetime while a second client's connect runs `apply_session_env` on a spawn_blocking thread. Detection now samples all five into an `EnvProbe` in ONE locked read and scans from that snapshot. Taking the lock around the whole of `detect_active_session` would have been simpler and wrong: it walks `/proc` every second, and holding a process-wide lock across a directory walk trades this problem for the one Phase 7 is about. The readers become pure functions of their inputs, which is also what let the tests stop mutating process env to steer them — `without_inherited_swaysock` is gone, replaced by handing in a probe. Empty values are now treated as absent, so `XDG_RUNTIME_DIR=""` falls back to /run/user/<uid> instead of yielding a relative path. The gamescope sub-mode ladder had a nastier version: `apply_input_env` WRITES PUNKTFUNK_GAMESCOPE_NODE/_SESSION to publish the sub-mode it chose, and read the same keys back as operator overrides. The Attach arm sets `_NODE=auto`, and `node_env` sits at rung 2 of the ladder — above `dedicated_launch` at rung 3 — so one Attach decision latched Attach for the rest of the host's life and silently overrode `game_session=dedicated`. Only rung 1 could escape it, because the Spawn arm that would have cleared the keys sits below the rung that by then always fired. The overrides are now sampled once, before this module has written anything, which keeps their actual meaning: "the operator set this before we ran". The live reads that remain (`launch_is_nested`, `poolable_now`) are deliberate — those consume the published decision. And gamescope's `create` read both keys unguarded, racing the writer and skipping the lock its two siblings take for the same keys; it now takes one guarded snapshot. Verified on Linux (.25): 100 passed, 1 ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
787756e68b |
test(vdisplay/mutter): the on-glass lever for the GNOME backend, and what it took to see the leak
`live_mutter_create_drop`, `#[ignore]`d in the same convention as the Windows backend's live tests: the real four-step handshake, hold, then drop the keepalive and let the RAII teardown revert it. Verified against gnome-shell on 192.168.1.21 — node_id came back in ~5 ms, preferred_mode as asked, and the topology reverted. Two things that would otherwise be re-derived by whoever validates this next, both of which cost a wrong answer here first. The virtual monitor does NOT appear in DisplayConfig's GetCurrentState just because `create` succeeded — its size follows the PipeWire negotiation, so without a consumer attached there is no monitor to census. A before/after monitor diff therefore proves nothing about this backend, which is the trap the first attempt fell into. And a short-lived process cannot exhibit the abandoned-thread leak at all: exiting drops the D-Bus connection, which makes Mutter tear the session down no matter which code is running. Reproducing it needs the process held alive past the failed create, and the observable is the live RemoteDesktop session object, not the monitor: `gdbus introspect -d org.gnome.Mutter.RemoteDesktop -o /org/gnome/Mutter/RemoteDesktop` lists a `node Session` child per live session. With a 1 ms create timeout forced and the process held 20 s, that probe reads 0 with b89a36a6 and 1 without it — the orphan thread parked on a live session. Both read 0 after exit, which is precisely why this only ever bit a long-running host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
69bc92afed |
fix(vdisplay/mutter): a create timeout no longer abandons the session thread with the desktop dark
`create` gave up after 45 s and dropped its `stop` flag WITHOUT setting it — the `StopGuard` that owns the only `store(true)` was built on the success path only. The thread meanwhile discarded its own send failure with `let _ =`, so it sailed past the dead channel, made the virtual output PRIMARY, and parked in `while !stop` for the rest of the process's life holding the D-Bus connection that IS the monitor's lifetime. Its teardown was unreachable. Under the default topology that orphan applies a SOLE-monitor APPLY_TEMPORARY config. Every physical head goes dark, and Mutter reverts such a config only once the virtual monitor disappears — which the parked thread prevents. There is no in-process recovery: each retry adds another orphan and another virtual monitor. The other three portal/D-Bus backends never had this — kwin.rs, hyprland.rs and wlroots.rs all `?` on that exact send. Mutter is now the same shape, via a shared `report_node` that stops the session when the opener has gone. Two more layers behind it, because the send can also LAND in the moment `recv_timeout` gives up: `create` and `stream_existing_output` build their guards BEFORE the wait, so every error arm signals the thread on its way out, and the thread re-checks the flag before it touches topology — so a doomed session never applies a sole-monitor config at all, rather than applying one and reverting it a tick later. The fix lands once because the duplication that caused it is gone first: `connect` and `connect_monitor` shared ~72 verbatim lines, and the mirror path repeating the session path's `let _ = send` is precisely what that copy bought. Both now share `open_rd_sc` (steps 1-2) and `start_and_await_node` (step 4). Also stops a physical hotplug stealing the virtual monitor's identity. `wait_virtual_connector` took "any connector absent from the pre-snapshot" across a window spanning a ~10 s stream wait plus 6 s of polling; TOPOLOGY_LOCK keeps sibling sessions out of it but a hotplug is not ours to serialise, and the session's topology apply and scale persistence would then be aimed at the operator's new panel. It now prefers the connector advertising the client's exact WxH — the discriminator `make_virtual_primary` already matches on — falls back to the old behaviour when that singles nothing out, and warns when more than one candidate appears. Tests: `pick_virtual` split out of the polling loop so the choice is testable without a session bus. Verified on Linux (192.168.1.25): 99 passed, and the hotplug case FAILS against the old first-new-connector logic, so it is not vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e7dc7e9d18 |
fix(win-display): every CCD/GDI operation now carries a proof, not just the blocks that needed one
Companion to c16f2850, which closed the same hole in pf-vdisplay. The file header claims "Every `unsafe` block in this file carries a `// SAFETY:` proof", and that was true — it just did not cover the 84 unsafe OPERATIONS sitting directly in `unsafe fn` bodies, which under edition 2021 need no block and so carry no proof. That is the whole CCD surface: every QueryDisplayConfig, every SetDisplayConfig, every DISPLAYCONFIG union read. Rather than 84 restatements of the same argument, the shared contract is stated ONCE at the top — how the buffer-size/query pair keeps pointer and count in agreement, and why the two obligations the compiler cannot see (arrays only ever come from the OS or from a SavedConfig this module built; SetDisplayConfig serialised under the manager `state` lock and bound to the input desktop) hold at every site. Per-site comments then name the site-specific fact instead of repeating the invariant. The union reads justify differently and the header says so: `modeInfoIdx` and `ADVANCED_COLOR_INFO.value` overlay same-sized POD, so no discriminant can be got wrong, while `MODE_INFO.Anonymous.sourceMode` IS discriminated by `infoType` — and every read of it is guarded by that check. Writing that down found the one place the guard does NOT gate the read: the `then_some` in set_virtual_primary_ccd evaluates eagerly, so POD-ness is what carries it there, not the check. The comment says so rather than implying a guard that is not doing the work. Two shape changes, both deliberate. `wait_mode_settled`'s two calls are nested rather than `&&`-joined so the second CCD query stays short-circuited — it polls every 25 ms and must not run while the target has no active path. And the eager union read in `set_virtual_primary_ccd` is hoisted to a `let` so its proof has somewhere to attach; `then_some` already evaluated it eagerly, so nothing changes. Union field ASSIGNMENTS are safe in Rust — only reads are — so two of them keep no block. Verified on the Windows CI runner: clippy -D warnings clean across pf-frame, pf-win-display, pf-capture and pf-vdisplay, and both crates' tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6782aa0054 |
fix(vdisplay): stop the suite reporting ok for tests that never ran, and let clippy see the KWin backends
Three coverage holes, all of the same shape — a gate that looks green because
nothing is behind it.
The Windows half (~3,400 lines) had no executed tests. Its only two `#[test]`s
opened with `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`, so
without the hardware they passed without running, and no CI job compiled the
crate's test targets at all — `-p punktfunk-host` builds pf-vdisplay as a
dependency, which never reaches a `#[cfg(test)]` module. Both live tests are
`#[ignore]`d now (an unrun hardware test should say `ignored`), and windows-host.yml
gains the `--all-targets` lint plus a real test step. The link question that keeps
the host and pf-encode on clippy-only does not apply: pf-encode is `default = []`
and nothing here turns on nvenc/amf-qsv/qsv. Settled on the runner to the same
standard as pf-capture's step — 46 passed, 2 ignored.
`#![allow(clippy::all, ...)]` at the top of kwin.rs and kwin_output_mgmt.rs was
meant for wayland-scanner output, but the generated modules already carry their own
copy of that attribute — so the file-level one was only ever silencing 2,366 lines
of hand-written backend, with `clippy::correctness` (deny-by-default) among the
casualties. Dropping it found a dead match arm in the `kde_output_configuration_v2`
dispatch; that one is now exhaustive on purpose, so a re-vendored XML that adds an
event fails the build instead of dropping it silently. (The sibling `_ => {}` in the
DeviceMode dispatch is NOT dead — `ModeEvent::Removed` really does reach it and get
swallowed, which is a separate defect.)
`dead_code` is now enforced on Linux, where ~10k of the ~17k lines live, instead of
allowed crate-wide behind a rationale that had stopped being true. It turned up no
genuinely dead code: four items, every one live from tests or another platform, now
annotated with which. One deserved the comment most — `Entry::keepalive` is "never
read" because it is an RAII guard whose `Drop` releases the compositor output, so
deleting it as unused would release every pooled display the moment it was created.
Two tests were not testing what they claimed. `another_uids_socket_is_ignored`
asserted the sway-socket ownership guard but never reached it — `find_sway_socket`
filters on the `sway-ipc.<uid>.` filename prefix first, so the foreign-uid file was
rejected by name and the assertion held even with `md.uid() != uid` deleted. It is
renamed to what it does cover, and the real leg is a new `#[ignore]`d root test that
chowns a correctly-named socket (querying with a non-matching pid, or the exact-path
shortcut would return before the guard and make it vacuous all over again). And
proc.rs's tests were ungated while the module is compiled everywhere, so they would
have gone red on Windows the first time anyone ran them: `#[cfg(all(test, unix))]`
now, with a `cmd /c` twin that does run there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
995cac5d03 |
fix(vdisplay): the three hardest FFI calls were exempt from the crate's own unsafe-proof rule
`#![deny(clippy::undocumented_unsafe_blocks)]` cannot see an unsafe operation that sits directly in an `unsafe fn` body — in edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, so a bare call inside such a body needs no block and therefore carries no proof. The file headers claim "every unsafe block carries a SAFETY proof", and that was true; it just did not cover the calls that needed it most. Turning the lint on named exactly three: `DeviceIoControl` and the `ioctl` wrapper in pf_vdisplay.rs — the whole host<->driver control channel — and `restore_displays_ccd` in manager.rs, the call the teardown path depends on to give the operator their physical panels back. Each now carries a real proof, and `ioctl` and `set_render_adapter` grew the `# Safety` heading their callers were owed. Also teaches scripts/wincheck.sh to cover pf-vdisplay, which is what let the above be compile-verified rather than reasoned: the crate's Windows half is ~3,400 lines that Linux CI never compiles. That needed a stub pf-encode (the real one drags ffmpeg-sys, and the admission gate calls exactly one predicate from it) and CompositorPref on the stub core. Verified non-vacuous with a planted type error. pf-win-display's half of the same lint is a separate change — it flags 62 further operations, and they want proofs written one at a time, not in bulk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9b38b6a22d |
feat(stats): a capture records which encoder and GPU produced it
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m4s
apple / swift (push) Successful in 5m41s
ci / bench (push) Successful in 7m17s
windows-host / package (push) Failing after 8m32s
windows-host / winget-source (push) Skipped
deb / build-publish-host (push) Successful in 11m4s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 11m33s
ci / rust-arm64 (push) Successful in 12m37s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
android / android (push) Successful in 12m59s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
docker / deploy-docs (push) Successful in 32s
deb / build-publish-client-arm64 (push) Successful in 8m45s
docker / build-push-arm64cross (push) Successful in 4m42s
arch / build-publish (push) Successful in 19m34s
ci / rust (push) Successful in 22m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m1s
release / apple (push) Successful in 31m52s
apple / screenshots (push) Successful in 23m26s
The stage split is the thing an fps-shortfall report is diagnosed from, and it cannot be read without knowing the backend behind it: a p50 `submit` of 10 ms means "the GPU's CSC+encode throughput is the ceiling" on one backend and something else entirely on another. The capture's meta carried the mode, codec and client but neither the encoder nor the GPU, so every report so far cost a round-trip asking which one it was. Both come from `pf_gpu::active()` — the record the encoder open itself writes — so they name the branch that really opened rather than a re-derived guess that goes stale the moment a backend gains an internal fallback. Read once per capture, not per frame. The console shows them next to the mode in the recording's header. Both fields are `serde(default)`, so a recording made before this still loads and simply omits them. Refs #9 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eb4ab91627 |
feat(host): a frame limiter for the game, not for the stream
PUNKTFUNK_MAX_FPS caps how fast the compositor lets the game render. It deliberately does NOT touch the session: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one, and a repeat of an unchanged picture is an almost-empty P-frame. So a 60-capped game on a 120 Hz session still puts 120 frames a second on the wire — and the GPU time the game gives up goes to capture and encode instead, which is the point (on a laptop or a handheld, it goes to heat and battery too). Capping the stream would be a different and mostly unwanted feature: it hands the client fewer frames than it asked for and saves the game's GPU nothing. gamescope is the compositor that has the lever, so it is the one that gets it: the rate becomes --nested-refresh, which is what gamescope clamps the game to. All three sessions we own take it — the bare spawn's -r, the managed gamescope-session-plus wrapper's PF_HZ, and the SteamOS PATH shim's. In the managed path the limit lands on PF_HZ alone and NOT on CUSTOM_REFRESH_RATES, so the mode the session advertises stays the client's — that is what makes games see the real refresh rather than the box's EDID. Two scoping notes. Under gamescope the cap is the nested output's rate, so everything it composites moves at it, not the game alone; there is only the one output. And the attach path (PUNKTFUNK_GAMESCOPE_NODE) mirrors a gamescope we do not own, so it has no lever to pull and is untouched. Closes #13 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
86b605f01f |
feat(host): run the virtual display faster than the stream needs
PUNKTFUNK_VDISPLAY_HZ_MULT runs the virtual display at a multiple of the session's rate without putting one extra frame on the wire. A compositor paints on its own vblank, so a frame finished just after the capture sampled waits nearly a full interval to be picked up — the jittery part of the latency budget, not the steady part; at 2 that worst case halves. It costs the compositor and GPU the extra composites, so it stays opt-in at 1. The pacing rate was previously just "whatever the backend achieved". It is now the session's rate floored by the achieved one — the same value as before whenever the knob is unset, and the only correct answer when it is set: never above what the client negotiated, never above what the display actually produces. Both build sites get it, the initial build and the in-place resize, so a mid-stream resize can't silently drop back to 1x. A backend that won't give the multiplied rate now says so at info rather than warn, since that is the expected outcome of an opt-in knob; falling short of the SESSION's own rate still warns, because that one costs the client frames. Closes #14 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a4aa89be31 |
fix(decky): drive a native client install, not only the flatpak
Every headless call went through `flatpak run io.unom.Punktfunk`, and the launch wrapper exec'd the same. On a Deck whose client came from a sysext, a .deb/rpm, an AUR build or a nix profile there is no such app: discovery worked (it is mDNS), and everything that needed the client — pairing, the library fetch, the host store, the stream launch itself — failed. Resolve the client once. The flatpak still wins when it is actually installed, so an existing Deck is byte-for-byte unchanged, and a native `punktfunk-client` is the fallback; `PF_DECKY_CLIENT` forces one on a machine with both. Both kinds share ~/.config/punktfunk — the flatpak's sandbox HOME is the real home — so identity, known-hosts and settings need no divergence. The wrapper takes the resolved binary as PF_CLIENT_BIN and execs it in place of `flatpak run`; kill_stream sends a name-matched SIGTERM where there is no flatpak instance to kill; and the client-update check, which reads the flatpak's tracked remote, simply reports nothing for a native install, whose updates belong to whatever installed it. Installed-ness is now decided by the app's exported directory rather than by the presence of the `flatpak` binary, so a machine that has flatpak but not this app no longer resolves to a client that cannot run. Closes #11 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f98547c41f |
fix(web-console): the paired-clients card counts both pairing planes
GameStream and native (punktfunk/1) clients pair into separate stores, and `/status` only ever reported the GameStream certificate count. Native is the DEFAULT plane, so a host whose clients had all paired normally — and which Moonlight had never touched — showed "0 paired" on the dashboard. Report the native count alongside it, the way the tray's own summary route already does, and sum the two in the card. Closes #10 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
198b1d5e80 |
fix(apple): iPad mouse buttons, held-key repeat, and the scroll direction
Three separate things an iPad's keyboard and mouse got wrong. Every mouse button past the first two clicked LEFT on the host. UIKit's button mask is 1-based over the HID order, and only `.secondary` was read — middle, back and forward all fell into the `else` and became button 1. Map 3/4/5 through `.button(_:)`; only middle and right swap places, because the wire numbers them the other way round. Holding a key deleted exactly one character. GameController reports a key once on press and once on release — it has no repeat channel — and the host injects exactly what it is told, so nothing downstream ever turned a held key into a stream of presses. Generate the repeat client-side at the usual delay-then-rate, newest key only, with modifiers and lock keys excluded. macOS was never affected: NSEvent hands it the window server's own repeats. Scrolling ignored the system's Natural Scrolling switch. UIKit has already applied that preference by the time it hands over a pan translation — it is what makes every UIScrollView on the device turn the right way — so negating y pinned the stream to traditional scrolling and inverted the setting for everyone on the default. Pass the sign through, exactly as the macOS path passes `NSEvent.scrollingDeltaY` through, and let that one recognizer own scroll under pointer lock too: it is the only way trackpad two-finger scrolling ever arrives, so gating it on the lock had been dropping it entirely there, and the raw GameController axis it deferred to carries no such preference. iOS installs no GCMouse scroll handler now, so nothing double-sends; tvOS, which has no scroll pan, keeps it. Closes #7, closes #15 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
580ea0d338 |
fix(android): a mouse's side buttons work even when they arrive as keys
Not every mouse reports back/forward the same way. One that carries them on the HID button page (BTN_SIDE/BTN_EXTRA) gets BUTTON_BACK/BUTTON_FORWARD in the motion button state, which is the only shape the forwarder listened for. A mouse that carries them on the consumer page (AC Back / AC Forward — the common Bluetooth shape, and what Android TV boxes tend to see) produces only synthesized KEYCODE_BACK/KEYCODE_FORWARD, and those were swallowed outright to stop them doubling as Android navigation. On that hardware both side buttons were simply dead. Route the key shape into the same wire buttons, deciding by the DEVICE (it has to be able to be a mouse) rather than by the event's source, since the consumer-page collection is a separate sub-device that may be stamped SOURCE_KEYBOARD. A D-pad-capable device is excluded so an air-mouse remote's BACK stays the couch user's way out of the stream. Both paths now funnel through one press/release helper whose held-set guard collapses a device that reports BOTH into a single wire press. Closes #8 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2f39e15e88 |
fix(ci): the shader drift gate never ran — dash has no process substitution
ci / rust (push) Failing after 5m29s
arch / build-publish (push) Failing after 6m45s
ci / web (push) Successful in 1m50s
ci / docs-site (push) Successful in 2m38s
android / android (push) Successful in 12m23s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 9m36s
ci / bench (push) Successful in 6m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 9m47s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m44s
deb / build-publish (push) Successful in 12m27s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m8s
docker / deploy-docs (push) Successful in 13s
deb / build-publish-host (push) Successful in 13m1s
apple / swift (push) Successful in 5m40s
docker / build-push-arm64cross (push) Successful in 3m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m31s
apple / screenshots (push) Successful in 24m33s
`diff <(spirv-dis …) <(spirv-dis …)` is bash syntax, and Gitea's runner executes
a step's `run:` under `sh -e`. dash rejected the script at PARSE time, so the
gate compared nothing — and, being step 2 of the `rust` job, it took Format,
Clippy, Build, Test, the feature-gated encode backends, the C ABI harness and
the committed-header check down with it. Every one of those has been skipped on
the 35 commits between the gate landing (
|