Compare commits

...
Author SHA1 Message Date
enricobuehler bc70a58fb1 Merge main into chore/rust-safety-programme
windows-drivers / probe-and-proto (pull_request) Successful in 22s
ci / bun-nix (pull_request) Successful in 36s
ci / web (pull_request) Successful in 1m17s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m58s
ci / rust-arm64 (pull_request) Successful in 3m18s
ci / docs-site (pull_request) Successful in 3m55s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 4m46s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust (pull_request) Failing after 10m11s
nix / flake (pull_request) Successful in 15m6s
Two conflicts: the test-module import list in gamescope.rs (union — the branch's takeover-state
tests and main's WSI opt-out tests both stay), and next_frame_timed_out in pf-capture, where the
branch still carried the pre-#168 else-if chain — resolved to main's match-based refactor, which
already embeds the same arm semantics plus the provisional-budget latch gate.
2026-08-11 22:34:38 +02:00
enricobuehler ce25aca7bd Merge pull request 'Two black screens from the .41 field session — a NO_FOCUS window stole the composite, and one truncated timeout downgraded the host forever' (#168) from worktree-blackscreen-fixes into main
apple / swift (push) Successful in 1m42s
ci / web (push) Successful in 1m14s
ci / docs-site (push) Successful in 1m20s
ci / bun-nix (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 1m46s
deb / build-publish (push) Successful in 5m9s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
apple / screenshots (push) Canceled after 1m18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
deb / build-publish-host (push) Successful in 7m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m28s
arch / build-publish (push) Successful in 11m28s
android / android (push) Canceled after 6m31s
ci / rust (push) Canceled after 2m21s
ci / rust-arm64 (push) Canceled after 1m13s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Canceled after 5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m2s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-11 20:32:16 +00:00
enricobuehler 5587699a85 Merge pull request 'Every pinned card gets a library, and it launches with that card's profile' (#167) from worktree-console-pinned-profile-library into main
apple / swift (push) Successful in 1m40s
android / android (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 1m47s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 56s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 1m55s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 55s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m34s
release / apple (push) Successful in 9m29s
flatpak / build-publish (push) Canceled after 13m45s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m59s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 7s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-08-11 20:30:03 +00:00
enricobuehler c946fcdcb5 Merge pull request 'build(web): silence rollup's "use client" directive warnings in the nitro pass' (#166) from build/web-silence-rollup-directive-warnings into main
arch / build-publish (push) Canceled after 47s
ci / bun-nix (push) Successful in 23s
ci / rust (push) Canceled after 42s
ci / docs-site (push) Canceled after 47s
ci / rust-arm64 (push) Canceled after 59s
ci / web (push) Canceled after 58s
deb / build-publish (push) Canceled after 5s
deb / build-publish-host (push) Canceled after 53s
deb / build-publish-client-arm64 (push) Canceled after 43s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1m28s
windows-host / package (push) Canceled after 3m49s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
2026-08-11 20:29:36 +00:00
enricobuehler fcf4c9fd63 fix(mgmt): unpair now revokes a LIVE session on both planes
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m38s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m38s
ci / docs-site (pull_request) Successful in 2m32s
windows-drivers / driver-build (pull_request) Successful in 1m50s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m29s
android / android (pull_request) Successful in 6m31s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m54s
ci / rust (pull_request) Failing after 10m42s
nix / flake (pull_request) Canceled after 8m26s
windows-drivers / probe-and-proto (pull_request) Canceled after 0s
An unpair removed the certificate but left the revoked client's running
session streaming until the client chose to leave. Now it is a complete
revocation:

- GameStream: when the removed certificate owns the active launch, the
  session is quit_session'd — the ENet control thread's ended-session arm
  gives the client the standard TERMINATION+disconnect. (An owner-less
  launch cannot be attributed and is left to the WP0 port teardown when the
  last pairing goes.) The endpoint docstring's long-standing caveat
  ('removes the client from the listing without severing its ability to
  reconnect') is retired: TLS handshakes complete by design, authorization
  is per-request, and a live session no longer survives its own revocation.
- Native: session_status::stop_by_fingerprint signals the unpaired
  client's live session(s) to tear down deliberately (quit+stop), matched
  by the registry's client label — the fingerprint's 12-hex-char prefix for
  every pairable client; anonymous/TOFU sessions carry IP labels and are
  never touched (they have no pairing to revoke).

(The unpair-didn't-PERSIST half of 'unpairing was broken' was already fixed
in 13d57210 — save_paired was never called; this closes the other half.)

Gates: Linux amd64 both flavors clippy --all-targets -D warnings clean;
session_status 2/2 (new revocation test), the extended paired-clients test
green in both flavors, native_pairing test green.
2026-08-11 22:17:41 +02:00
enricobuehler cc8eb7df08 feat(clients): every pinned card gets a library, and it launches with that card's profile
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m12s
ci / rust (pull_request) Successful in 7m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 11m18s
The console fix before this one closed the leak on one client. The same question
has a different wrong answer on each of the others, so this closes it everywhere:
a pinned host+profile card can be browsed, and every title launched off a pinned
card's shelf streams with that card's profile.

Two shapes of bug, one per client:

**The library was not offered on a pinned card at all** — Apple (both UIs:
`hasLibrary: profile == nil` on the console tile, no menu item on the touch card),
Android (`hasLibrary` required `pinnedProfileId == null`, and the console host
options gated Library on `pin == null`), GTK (the pinned card's menu had Connect,
Copy link, Create shortcut, Unpin — no library) and Windows (pinned tiles had no
menu whatsoever). Each was justified in a comment as "a pin is a shortcut, not a
second host, so the host-level actions stay on the host's tile" — right about
wake, pair, edit and forget, wrong about the library. Browsing is not a property
of the machine: it is this card's connect with a title picked first, which is
exactly what a shortcut is for. So the library joins Connect on every pinned
surface, and the host-level actions stay where they were.

**The launch dropped the profile.** GTK already carried it (its library clones
the card's whole ConnectRequest) and Windows already carried it (its library page
launches through the shared target, which the tile parks). Apple did not: the
library was keyed on a bare `StoredHost`, so `launchTitle` connected with
`.inherit` and the host's binding won even from a pinned card. Android was worse
still, and not only for pins — `LibraryScreen` dialled `connectToHost` with the
RAW settings, so a library launch applied NO profile at all, not even the host's
binding, on every host. Its host list resolves
`settings.effectiveFor(profileStore.resolveFor(kh, oneOff))`; its library resolved
nothing.

So the shelf, not the host, is now the unit both clients navigate by. Apple gets
a `LibraryTarget` (host + `ProfileSelection`) threaded through `libraryTarget`,
the shell's screen enum, both presentations and `SessionModel`; Android passes
the pinned id into `LibraryScreen` and resolves it there through the same
`ProfileStore.resolveFor` rule the connect path uses. Falling out of that:

  * a game that exits returns to the shelf it was launched from, pin and all,
    rather than to the host's default one — `SessionModel.launchedShelf` on Apple,
    `ActiveSession.libraryProfileId` → `LibraryReturn` on Android. Android also
    drops a pin that was unpinned while the game ran, rather than reopening a card
    that no longer exists.
  * Android's stats overlay finally names the profile a library launch used
    (`profileName` was never set on that path).
  * Apple's `punktfunk://browse/<host>` honours `profile=`, which it parsed and
    ignored — and refuses an unknown or ambiguous reference exactly as the connect
    route does (§10.6) rather than degrading to the binding.
  * every shelf says which one it is, in the card's own `host · profile` shape:
    the console's title, GTK's page title, Android's ConsoleHeader, Apple's
    navigation title and its console heading.

Verified per platform, none of it on trust:
  * console + GTK: fmt, build, clippy `-D warnings` and 85 tests green in the
    pf-lxcheck2 container (a Mac `cargo test -p pf-console-ui` compiles nothing).
  * Apple: `swift build` green for macOS, iOS and tvOS. Worth all three — the tvOS
    pass alone caught `navigationDestination(item:)` needing Hashable, and an
    iOS-only screen was confirmed genuinely compiled by planting a type error.
  * Android: `compileDebugKotlin` + unit tests, with HomeTilesTest's pin
    expectation flipped to match.
  * Windows: `cargo check --all-targets` + clippy `-D warnings` on the CI runner,
    cold (3m10s) — that client cannot compile on a Mac.
2026-08-11 22:13:54 +02:00
enricobuehler 6ca192b9ab fix(packaging/gamescope): +pfhdr6 — a GAMESCOPE_NO_FOCUS window can no longer steal the composite
ci / web (pull_request) Successful in 1m3s
ci / rust-arm64 (pull_request) Successful in 1m35s
apple / swift (pull_request) Successful in 1m40s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 1m30s
ci / rust (pull_request) Successful in 4m52s
android / android (pull_request) Successful in 5m36s
Patch 0008: honor GAMESCOPE_NO_FOCUS in steamcompmgr's focus selection. hhd (Handheld Daemon)
sets the atom once at init on its hhd-ui overlay window and never clears it; MangoHud sets it
too; show/hide for these clients runs over the STEAM_OVERLAY protocol. NOTHING consumed the atom
— not upstream gamescope, not Bazzite's fork (checked ba148 by strings) — so a
mapped-but-unpainted hhd-ui window (it crash-loops under a headless punktfunk takeover and remaps
on every respawn, stamping Steam's appid 769) was an ordinary focus candidate, and steamcompmgr
picked it over Big Picture. The composite, and the stream fed from it, went black while every
health signal stayed green: on .41 the client sat decoding 60 fps at 0.1 Mb/s of black,
GAMESCOPE_FOCUSED_WINDOW named the hhd-ui window with GAMESCOPE_NO_FOCUS(CARDINAL)=1 on it, and
killing hhd-ui brought the picture back the same second.

The patch wires the atom exactly like GAMESCOPE_EXTERNAL_OVERLAY — read at map,
PropertyNotify-tracked with MakeFocusDirty, skipped by both focus-candidate collectors (X11 and
XDG) — and touches neither compositing nor appID, so a NO_FOCUS window still paints if the
baselayer protocol brings it into view; it is only barred from being CHOSEN. Applies cleanly on
the full 0001..0008 series from the bare 5fb8dce4 pin (verified with git am).

Banner +pfhdr5 → +pfhdr6, PKGBUILD 3.16.25.pfhdr6-1; README gains the 0008 row, the missing
+pfhdr5 ledger row, and the reconciled bump rule (a bugfix bumps the level only when field triage
must read the difference off a box's banner — 0007's crash-loop, 0008's lost composite).
2026-08-11 22:07:24 +02:00
enricobuehler 022ede651f fix(pf-capture): the truncated first attempt no longer latches the sticky downgrades
The pipeline retry loop deliberately shortens its first attempt's first-frame wait to 2.5s so a
stream bound during a gamescope re-init fails over quickly. But the portal capturer's timeout
diagnosis treated EVERY expiry as a verdict: it latched whichever offer it implicated — HDR
capture off for the source, the raw-dmabuf offer off, the EGL→CUDA offer off — process-wide and
permanently, when the attempt was truncated by design and a gamescope cold start routinely
delivers nothing inside that window while accepting every offer a few seconds later (observed on
.41: pid 1962 hit the expiry at connect and every later session in that process ran silently
degraded). This is bug #6 from the pf-capture sweep, verified then and unfixed until now.

The truncated attempt is now declared PROVISIONAL end to end: a new
`Capturer::next_frame_within_provisional` (default: delegates) lets the retry loop say "this
budget is the schedule, not a verdict", and the portal capturer's timeout classification — split
out as the pure `classify_first_frame_timeout` + `timeout_convicts`, with tests — names the same
suspect in the error text but latches nothing unless the expired budget was full-length.
2026-08-11 22:06:31 +02:00
enricobuehler 9c6e06d3b9 feat(host): GameStream is now a cargo feature — WP19, compile-time isolation
A new 'gamestream' feature (default ON — every stock package is behaviorally
identical, and GameStream stays runtime-opt-in via --gamestream /
PUNKTFUNK_GAMESTREAM) gates the whole Moonlight-protocol surface: control
(the ENet plane), rtsp, nvhttp, pairing, serverinfo, the _nvstream mDNS
advert, the compat media path (stream/video/audio), pen/gamepad/input
decode, apps, crypto, cert (the RSA identity), and tls's
Moonlight-client-cert leniency. AppState keeps the shared vocabulary
unconditional and cfg-gates the Moonlight-only fields; the mgmt API's PIN
endpoints (routes, handlers, OpenAPI entries, lane classifications, tests)
exist only under the feature.

Building --no-default-features --features pyrowave yields the hardened
NATIVE-ONLY host: no rusty_enet (the c2rust-transpiled C ENet stack, 158
unsafe sites) and no rsa (the identity split's legacy fallback became a
pem-only read — rustls/ring serves an existing RSA cert without the crate —
so the accepted Marvin advisory no longer applies to native-only builds).
Both claims are ASSERTED, not assumed: a new CI leg keeps the native-only
flavor clippy-clean and fails if cargo tree finds either crate in its graph.
serve --gamestream (or the env knob) against such a binary refuses to start
with a clear error rather than serving less than the operator configured.

En route: the logs-paging test assumed a quiet process-global log ring
between its cursors and raced other tests' legitimate log lines (the
identity tests added new emitters) — it now asserts on its own markers
within the page.

Gates: Linux amd64 — BOTH flavors clippy --all-targets -D warnings clean;
default tests identity 3/3, mgmt 37/37, gamestream 59/59; native-only tests
identity 3/3, mgmt 35/35, residue 4/4; rusty_enet+rsa absent native-only,
present default. .133 Windows — both flavors clippy clean (clean-first,
sentinel-checked), tree claims hold, and the WP0 port-lifecycle functional
gate PASSES on the default build.
2026-08-11 22:05:30 +02:00
enricobuehler f5fa9649b7 fix(console): a pinned card's library launches with that card's profile
Pinning a profile onto a host gives it its own card on the console home, and
pressing A on that card has always connected with the pinned profile as the
one-off the resolver prefers over the host's binding. Y on the same card opens
a library — it is paired and saved, which is the only thing the hint bar asks —
and every title launched off that shelf went out with no profile at all, so the
host's default binding won. Connecting straight from the card honoured the
profile; going through its library did not, which is the shape a user reads as
"the pin works until I pick a game".

The screen was the leak: LibraryScreen copied the row's address, port,
fingerprint and mgmt port, and dropped `pin` — so its launch had nothing to
send and hardcoded `profile: None`, under a comment asserting that game
launches follow the binding. They should follow the card: a launch off a pinned
card's shelf is that card's connect with a title attached.

The screen now carries the row's pin and sends its id as the one-off. It also
says so, in the card's own `host · profile` shape: the shelf's title names the
profile, and so does the connecting card, so which of a host's cards you came
in on is legible from the screen rather than inferred from the tile you pressed
two screens ago. Off the host's primary tile there is no pin and nothing
changes — `None`, and the binding decides.

Console-only. The Apple and Android consoles keep Y off pinned cards outright
(`hasLibrary: profile == nil`), the GTK client clones the card's whole
ConnectRequest — profile included — into its library, and the Windows client
offers "Browse library" from the primary tile only. The console's own
copy-link and wake-then-connect paths already carried the pin.

Both directions are tested, and the pinned one was checked against a reverted
fix: it fails with exactly the reported symptom (left: None, right: "hdr").
2026-08-11 21:42:14 +02:00
enricobuehler 1009e14a44 build(web): silence rollup's "use client" directive warnings in the nitro pass
ci / bun-nix (pull_request) Successful in 31s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 2m52s
ci / rust (pull_request) Successful in 7m9s
The nitro server build re-bundles the whole dep tree (`noExternals: true`), so
every React package shipping a `"use client"` banner earns a MODULE_LEVEL_DIRECTIVE
warning — ~150 locally, ~800 in CI — which buries the warnings worth reading.

Ignoring the banner is correct rather than papered over: this bundle is the
Bun/Nitro server, not an RSC module graph, and TanStack Start splits client from
server with its own transform, so nothing downstream consults it.

Supplying `onwarn` replaces nitro's own handler, so its three filters
(CIRCULAR_DEPENDENCY, EVAL, "Unsupported source map comment") are restated.

Verified: `bun run build` drops from 148 such lines to 0 with no other log
delta; `tsc --noEmit` and `biome check` clean.
2026-08-11 21:00:19 +02:00
enricobuehler e658ad726b feat(host): the identity split — the native planes get their own P-256 identity
One RSA-2048 identity served every plane, because Moonlight mandates RSA and
the planes grew out of the GameStream host. The native punktfunk/1 QUIC plane
and the management API now share a separate ECDSA P-256 identity
(native-cert.pem/native-key.pem, src/identity.rs): ring-generated via rcgen
(no rsa crate on the native path — the accepted Marvin advisory stops
applying once WP19 gates the compat planes), real SANs (localhost, loopback,
machine hostname — the legacy cert had none), and browser-compatible on
purpose: Ed25519 was rejected because no mainstream browser accepts an
Ed25519 server cert and /api/docs is opened in one. GameStream keeps the RSA
identity untouched (Moonlight pins it; its pairing hashes bind its X.509
signature bytes).

Migration is pin-preserving by construction. Clients TOFU-pin ONE leaf-DER
SHA-256 for both QUIC and the mgmt/library API, so the identity is resolved
ONCE in serve (the planes cannot race the first-run mint) under the rule:
identity files exist → use them; else the native trust store is EMPTY →
mint P-256 (fresh installs); else keep presenting the legacy RSA cert the
paired clients pinned, and log the migration path (unpair all, restart,
re-pair). Fingerprint pinning is algorithm-agnostic — existing shipped
clients pair against P-256 hosts unchanged.

Followers updated: the tray's loopback pin and the plugin SDK's mgmt CA
prefer native-cert.pem → cert.pem; the Windows runner ACL grant lists both
(the grant loop tolerates absent files). The in-process native tests now run
on an EPHEMERAL identity — they previously read, and would newly have
MINTED, identity files in the real config dir, which on a dev box that is
also a live host would have switched its identity and stranded every pinned
client.

Gates: Linux amd64 clippy --all-targets -D warnings clean (host+tray);
identity 2/2, mgmt 37/37, control 6/6, native 68/68 (C-ABI roundtrips over
the ephemeral identity). .133 Windows clippy clean; the port-lifecycle gate
re-run PASSES with the split live — the fresh host minted P-256 and served
mgmt over it (curl 200/204), ports tracked the paired list as before.
2026-08-11 20:52:01 +02:00
enricobuehler 21f43d7f48 Merge pull request 'Games ran black in Nobara's Game Mode — the session script re-exports the WSI variable #144 cleared' (#165) from worktree-nobara-wsi-layer-clobber into main
apple / swift (push) Successful in 1m39s
ci / bun-nix (push) Successful in 55s
ci / web (push) Successful in 1m13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
ci / rust-arm64 (push) Successful in 1m43s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
ci / docs-site (push) Successful in 2m22s
deb / build-publish-client-arm64 (push) Successful in 2m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m1s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m22s
ci / rust (push) Successful in 4m57s
android / android (push) Successful in 8m21s
deb / build-publish-host (push) Successful in 10m37s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
arch / build-publish (push) Successful in 9m36s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Failing after 2m39s
apple / screenshots (push) Successful in 6m30s
deb / build-publish (push) Successful in 7m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m35s
windows-host / package (push) Successful in 15m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 19s
Reviewed-on: #165
2026-08-11 18:33:05 +00:00
enricobuehler 8d1e5ab5dd Merge pull request 'The Android console grows up — the desktop's real backdrop, Apple's drum, and the cross-client tables stop drifting in silence' (#163) from worktree-android-console-visual-refresh into main
audit / bun-audit (sdk) (push) Successful in 21s
audit / bun-audit (web) (push) Successful in 21s
audit / bun-audit (plugin-kit) (push) Successful in 42s
android / android (push) Canceled after 1m8s
arch / build-publish (push) Failing after 1m1s
apple / swift (push) Canceled after 1m23s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 47s
ci / docs-site (push) Canceled after 41s
ci / bun-nix (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
deb / build-publish (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 1m0s
deb / build-publish-host (push) Canceled after 1m0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 18s
docker / deploy-docs (push) Canceled after 0s
audit / docs-site-audit (push) Successful in 25s
audit / pnpm-audit (push) Successful in 16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
audit / cargo-audit (push) Successful in 47s
windows-host / package (push) Canceled after 1m57s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
audit / license-gate (push) Successful in 5m32s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m16s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m46s
release / apple (push) Successful in 10m18s
flatpak / build-publish (push) Successful in 9m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m51s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m46s
nix / flake (push) Failing after 16m5s
Reviewed-on: #163
2026-08-11 18:32:00 +00:00
enricobuehler 23d0452157 feat(host): GameStream opt-in on every route; the native plane is deny(unsafe_code)-enforced
The user direction after WP0: ENet exists only for Moonlight, so the native
plane must be provably safe and the compat planes a deliberate choice.

Opt-in, everywhere. Windows already was (unchecked installer task). The three
opt-out surfaces are flipped: the shipped systemd user unit (deb/RPM/Arch/
sysext) no longer bakes --gamestream into ExecStart — a new
PUNKTFUNK_GAMESTREAM=1 host.env knob (pf-host-config, OR-ed with the CLI
flag) is the packaged opt-in; the NixOS module default goes true→false, with
a module-check assertion that unset = native-only; the Deck installer takes
--gamestream to opt in (--no-gamestream kept as explicit-off). Docs
(quickstart, running-as-a-service, moonlight, ubuntu/fedora/arch firewall
sections, gnome/sway, how-it-works) rewritten to the opt-in shape; the
CHANGELOG carries the upgrade note.

Enforced-safe. punktfunk-core is #![deny(unsafe_code)] crate-wide — every
module that parses network bytes is safe Rust as a compile error, not a
census result. Carve-outs are exactly two documented classes, neither of
which interprets attacker bytes: the client surface (abi, client) and the
transport syscall-batching shims (udp/{apple,linux,windows}, qos_windows).
In punktfunk-host, the modules a secure-default host exposes — native
(cfg-not-test: its tests exercise the client C ABI on purpose),
native_pairing, mgmt, mgmt_token, discovery, wol — are #[forbid(unsafe_code)].

Gates: Linux amd64 container clippy --all-targets -D warnings clean over
core+host-config+host; core 204 tests green under the deny; mgmt 46/46,
control 6/6. .133 Windows clippy (shipped features, clean-first,
sentinel-checked) clean — covers the qos_windows/udp-windows carve-outs.
macOS + iOS cargo check green (the apple.rs carve-out compiles for real).
2026-08-11 20:21:16 +02:00
enricobuehler 9e28cd101c fix(pf-vdisplay): the WSI opt-out never reached the games — the session script re-exports it
ci / docs-site (pull_request) Successful in 1m23s
ci / web (pull_request) Successful in 1m24s
apple / swift (pull_request) Successful in 1m46s
ci / bun-nix (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m27s
android / android (pull_request) Successful in 5m53s
ci / rust (pull_request) Successful in 11m10s
A Nobara 44 field box on 0.27.0 got a working Game Mode takeover — right mode,
right refresh, perf overlay in the stream — and then every game it launched
played sound and took input over a BLACK PICTURE, with no error anywhere.

#144 disabled the distro's `VkLayer_FROG_gamescope_wsi` with
`--setenv=ENABLE_GAMESCOPE_WSI=0` on the transient unit, because that layer
speaks the distro gamescope's `gamescope_swapchain` protocol and ours rejects
its `swapchain_feedback`, killing the client. `gamescope-session-plus` then
runs an unconditional `export ENABLE_GAMESCOPE_WSI=1` near the top of the
script, before it launches anything — so the opt-out lived exactly as long as
it took the script to start, and gamescope, Steam and every game got the layer
back. The host logged that it had disabled it, which is what made this cost a
field round-trip: the claim was true of the unit and false of everything in it.

Nothing else looked wrong because the casualty is Vulkan clients specifically.
Steam's Big Picture UI is not one, so the session came up looking perfectly
healthy right up until a game started.

Send `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader resolves an implicit
layer's two manifest knobs in a fixed order (`loader_implicit_layer_is_enabled`):
`enable_environment` must equal `"1"` to switch the layer on, and
`disable_environment` is consulted last — "has priority over everything else" —
where the mere PRESENCE of the variable, at any value, forces it off. The
session script never mentions that one, so it is the only one that survives it.

Both spellings now go out through one place, so the transient unit
(`launch_session`) and the box's own session drop-in (`write_session_plus_dropin`)
cannot drift apart — the same shape `SessionBind` already uses for the bind.
`ENABLE_GAMESCOPE_WSI=0` stays alongside for a layer built without a
`disable_environment`, and because it is what an operator reads the unit for.
2026-08-11 20:14:19 +02:00
enricobuehler 13d5721049 feat(gamestream): the ENet control port exists only while a pairing does (WP0)
rusty_enet — a c2rust-style transpile of C ENet, 158 unsafe sites — parsed
unauthenticated UDP on 47999 from GameStream startup, before any client had
ever paired: the host's entire pre-auth-reachable unsafe surface. Pairing
itself is HTTPS on nvhttp and never touches the port, so it now binds only
while the paired-client list is non-empty: a Gate in control.rs reconciles
the port to the list (armed only under --gamestream), pairing phase 4 brings
it up before the new client can /launch, and removing the last pairing tears
it down — a live client gets the same termination+disconnect farewell as a
host-side session end. A never-paired host on a hostile LAN exposes no ENet.

En route: the management API's unpair never called save_paired, so a restart
resurrected the client — and would now have silently re-opened the port; it
persists (the test now runs against a throwaway PUNKTFUNK_CONFIG_DIR so it
can't clobber a real paired.json). rusty_enet is pinned =0.4.0 per the WP,
left to the cargo-audit job to flag advisories against it.

Gate (amd64 container): clippy --all-targets -D warnings clean;
gamestream::control 6/6; mgmt::tests 37/37 incl. the regenerated
api/openapi.json. On-box .133 verification (ports/pair/stream) still owed.
2026-08-11 19:29:50 +02:00
enricobuehler d4366e7464 fix(pf-encode): the Vulkan extension probe walked a driver-filled array with no bound
ci / web (pull_request) Successful in 1m20s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m49s
windows-drivers / probe-and-proto (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / bun-nix (pull_request) Successful in 24s
android / android (pull_request) Successful in 4m37s
ci / rust (pull_request) Successful in 10m25s
`ext_advertised` did `CStr::from_ptr(e.extension_name.as_ptr())` over a
driver-filled `[c_char; VK_MAX_EXTENSION_NAME_SIZE]`, and `vk_build.rs` open-coded
the identical call a second time. Neither had an in-Rust bound: a driver that
fills all 256 bytes without a NUL runs the walk into the NEXT
`ExtensionProperties`, and on the LAST element past the allocation.

The SAFETY comment asserted the spec guarantee ("a spec-guaranteed NUL-terminated
byte array") instead of enforcing it. That is the defect class this programme
keeps finding: a proof that restates what the other side promised rather than
checking it. Vulkan drivers are exactly the other side.

The bounded answer already shipped in the same crate — `pyrowave.rs:210` uses
ash's `extension_name_as_c_str()` for the identical job. It stops at
VK_MAX_EXTENSION_NAME_SIZE and returns Err when there is no terminator, so a
malformed entry is a non-match instead of an overrun. Both sites now route
through the one helper, which is no longer unsafe at all.

Deletes 2 unsafe operations and one duplicated walk.

⚠ The pre-existing test could not have caught this: it only ever built
well-formed, NUL-terminated entries. Added a case whose LAST element is 256
non-NUL bytes — the exact shape that used to leave the array — and a
prefix-match case, so the bound is now asserted rather than assumed.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain):
  cargo check  -p pf-encode --features vulkan-encode,pyrowave --locked      ok
  cargo test   -p pf-encode --features vulkan-encode,pyrowave ext_advertised
                                                              2 passed / 0 failed
  cargo clippy -p pf-encode --all-targets --locked
        --features vulkan-encode,pyrowave -- -D warnings                    clean
Linux-only code (`enc/linux/`), so the Windows leg is unaffected.
2026-08-11 16:34:34 +02:00
enricobuehler cd72f77a3c fix(pf-encode): the AMF layout guards broke Windows clippy — 0*SLOT and 1*SLOT
windows-drivers / probe-and-proto (pull_request) Successful in 29s
apple / swift (pull_request) Successful in 1m39s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 2m10s
ci / rust-arm64 (pull_request) Successful in 2m53s
ci / web (pull_request) Successful in 1m15s
android / android (pull_request) Successful in 4m44s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Canceled after 5m25s
ci / docs-site (pull_request) Canceled after 1m7s
`27f08340` wrote every vtable offset assertion as `offset_of!(T, f) == N * SLOT`
so the slot INDEX stays visible in the assertion. For N=0 and N=1 that is
`0 * SLOT` and `1 * SLOT`, which clippy rejects as `erasing_op` and
`identity_op` — six errors, and windows-host.yml runs clippy with `-D warnings`,
so the branch as pushed would have turned the Windows leg red.

This is the blind spot the programme document names in §1.5, demonstrated on the
programme's own first code commit: 44% of the host's unsafe is `#[cfg(windows)]`,
no Linux or macOS check compiles it, and `cargo fmt`/`cargo check` on a Mac are
all clean. Only the .133 gate sees it.

Fixed with a `const fn slot(i: usize) -> usize` rather than by writing the two
offending cases as bare `0` and `SLOT`: that would have made those two the only
assertions where the slot index is invisible, and the index is the entire point.

Also records the cheap local gate that would have caught this without a Windows
round-trip: `amf_sys.rs` depends on nothing but `c_void`, so copying it into a
throwaway one-file crate and running `cargo clippy -- -D warnings` reproduces the
exact error on any host. Verified by reintroducing `0 * SLOT` and watching the
harness fail with the same message the runner gave.

Verified on 192.168.1.133 (Windows CI runner, the box with the WDK), after a
`cargo clean -p pf-encode` that reported `Removed 47 files, 135.5MiB` so the
recompile is real and not a cached green:
  cargo check -p pf-encode                                                 ok
  cargo check -p pf-encode --all-targets --features nvenc,amf-qsv,qsv      ok
  cargo clippy -p pf-encode --all-targets --features nvenc,amf-qsv,qsv
        -- -D warnings                                          exit 0 (was 101)
  cargo clippy -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings
                                                                exit 0 (was 101)
The gate also greps the extracted tree for the assertions before building, so a
stale upload cannot produce a passing run.
2026-08-11 16:28:59 +02:00
enricobuehler cd3f5474bf fix(pf-driver-proto): a layout test read an align-8 struct out of an align-1 buffer
ci / web (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m48s
apple / screenshots (pull_request) Skipped
ci / bun-nix (pull_request) Successful in 20s
windows-drivers / driver-build (pull_request) Successful in 2m14s
windows-drivers / probe-and-proto (pull_request) Successful in 40s
android / android (pull_request) Successful in 4m11s
ci / rust-arm64 (pull_request) Successful in 3m7s
ci / rust (pull_request) Successful in 7m4s
`control_structs_roundtrip_through_bytes` built the legacy-size wire form in a
stack `let mut legacy = [0u8; 40]` (align 1) and then called
`bytemuck::from_bytes::<control::AddRequest>`. `AddRequest` opens with
`session_id: u64`, so it is align 8, and `from_bytes` hands back a REFERENCE
into the buffer — it panics unless the buffer happens to be 8-aligned.

A stack `[u8; 40]` usually is, which is why this passed on every machine and
every CI leg since it was written. Under Miri it fails outright: Miri does not
let an accidentally-favourable stack slot stand in for a guarantee.

Switched to `pod_read_unaligned`, which reads by value and has no alignment
precondition. That is not a new idea here — `ChannelProof::parse` at lib.rs:1013
already carries a comment saying "`pod_read_unaligned`, NOT `from_bytes`" for
exactly this reason. This site is the only other one in the crate that reads a
POD out of a stack byte array; every other `from_bytes` call in the tests reads
from `bytes_of(&x)`, which is aligned by construction.

Test-only, so no shipped defect — but the crate is `#![forbid(unsafe_code)]` and
is path-dep'd by BOTH the main workspace and the driver workspace, so it is the
layout oracle for every frame and IOCTL that crosses that boundary. A test that
cannot be trusted to fail is worth fixing there more than anywhere else.

Found by the first Miri run ever performed against this repo.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0):
  cargo +nightly miri test -p pf-driver-proto                              21/21
  cargo +nightly miri test -p pf-driver-proto --target x86_64-pc-windows-msvc
                                                                          21/21
  cargo test -p pf-driver-proto --locked                                     ok
  cargo clippy -p pf-driver-proto --all-targets --locked -- -D warnings    clean

The cross-target run is the interesting one: it interprets the crate at MSVC
layout on a Linux box with no Windows anywhere. Nothing else in CI does that.
2026-08-11 13:57:34 +02:00
enricobuehler 972af2992f fix(pf-capture): the gamescope cursor fallback rewrote environ under a live multithreaded host
`connect_via_env_swap` did set_var("XAUTHORITY", …) / connect / restore, guarded
by a mutex that serialised this source against itself and against nothing else.
`getenv` takes no lock. setenv/unsetenv rewrite the process-global `environ`, and
glibc REALLOCATES that array when a variable is added — while, at that exact
moment, the PipeWire thread is inside pw_init()'s dlopen making bare getenv()
calls and EGL/CUDA init is running alongside. The file's own doc already called
the pattern "unsound from a live multithreaded host"; it stayed as a fallback.

Three things made it worse than the comment suggested:

- The damaging branch is the one where XAUTHORITY is ABSENT and therefore gets
  ADDED (the realloc case). scripts/punktfunk-host.service deliberately does not
  import the login shell's environment, so absent is the DOCUMENTED NORMAL
  configuration for the shipped unit, not an edge case.
- `rediscover` re-runs this every 2 s for the whole session. A display whose
  connect fails is never pushed into `displays`, so the dead-display skip never
  covers it — the race is not once at startup, it repeats forever.
- It is unfixable in place. Sharing pf_vdisplay's ENV_LOCK is the wrong layer: it
  cannot make C `getenv` take a lock.

The fix is to stop writing `environ` at all. Connecting with an explicitly empty
auth token is what the swap actually achieved: we only reach the fallback when
our own lookup found no usable MIT-MAGIC-COOKIE-1 entry, and x11rb's internal
lookup reads the same file with a STRICTER matcher (it matches family/address
too, which we deliberately do not), so where we find nothing it finds nothing
either and connects unauthenticated. That is exactly why the swap "worked"
against a nested Xwayland started without -auth.

Gives up one case: an .Xauthority using an auth family we decline to guess at but
x11rb would have handled. A gamescope Xwayland writes a single-entry
MIT-MAGIC-COOKIE-1 file, so it is not reachable here, and declining to attach a
cursor overlay beats tearing `environ` out from under a live session.

Also removes XAUTH_LOCK, whose only user this was.

Verified on 192.168.1.25 (Ubuntu, cargo 1.96.0 — the pinned toolchain, pipewire
dev headers present): `cargo check -p pf-capture --locked` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` both clean.
Not verified on glass: the fallback is only reached when the cookie parse fails,
so a normal gamescope session does not enter it. Forcing it needs a nested
Xwayland started without -auth, or a mangled cookie file, on .181/.136.
2026-08-11 13:54:56 +02:00
enricobuehler df6f270e7b chore(safety): forbid unsafe on the crates that are already at zero
Five permanent ratchets, all free today — the point is that they cannot regress
tomorrow. Each crate was re-measured at the commit, not taken from a survey.

`forbid(unsafe_code)`:

  punktfunk-encode-worker  the binary that carries cap_sys_nice. Its header
                           claims "no Wayland, no D-Bus, no network, no
                           plugins"; this makes the memory-safety half of that
                           claim mechanical. `forbid`, not `deny`, so it cannot
                           be re-opened by an #[allow] further down.
  pf-update-check          parses a signed, network-fetched manifest and its own
                           header says it "owns the part where being wrong is a
                           security bug". Signature checking is worthless if the
                           parser around it can be walked out of bounds.
  pf-vaadec                its header states the design constraint outright — it
                           links no libva and compiles on macOS, "which is the
                           point". The crate is full of hand-declared libva
                           repr(C) mirrors; one raw deref and it stops being the
                           CPU-testable half.
  tools/cursor-probe       free, and a probe is where "just deref it to see" is
                           most tempting.

`deny(unsafe_code)` + one localized allow:

  pf-update                root runs this. Its single unsafe operation, a bare
                           geteuid, moves into a named `effective_uid()` helper
                           carrying the crate's one #[allow(unsafe_code)].

Deliberately NOT rewritten to rustix, contrary to the programme document's first
draft: pf-update's Cargo.toml states that its zero-dependency posture IS a
security invariant of a root helper ("no HTTP client, no TLS, no argument
parsing"), and the extern block says the same. Pulling a general-purpose syscall
crate into a root helper to delete one `unsafe` would trade a real property for
a cosmetic one. The localized allow keeps the ratchet: any NEW unsafe anywhere
in the crate is a build error.

Verified: `cargo check -p pf-vaadec -p pf-update-check` and
`cargo check -p pf-update -p cursor-probe` clean on macOS, plus
`cargo check -p pf-update --target x86_64-unknown-linux-gnu` — pf-update's whole
body is behind `cfg(target_os = "linux")`, so the macOS check does not reach the
line that changed. punktfunk-encode-worker is not built here (pf-encode's C
dependencies do not cross-compile from macOS) and needs the Linux CI leg.
2026-08-11 13:49:41 +02:00
enricobuehler 27f0834025 fix(pf-encode): const-assert the AMF vtable and POD layouts
amf_sys.rs mirrors five AMF COM vtables by hand and amf.rs dispatches through
them BY SLOT POSITION — 18 distinct slots across the five tables. The mirrors
carried 118 `Slot` placeholders whose only job is to hold the following slots at
their C offsets, and not one layout assertion of any kind. A slot inserted,
removed or reordered in an AMF header bump calls an arbitrary function pointer
through a mismatched signature: no compile error, no runtime signal.

`AMF_MIN_VERSION` does not defend against this. It checks a version NUMBER, not
a layout, and it is a floor with no ceiling.

The three POD checks that did exist (`AmfVariant`, `AmfGuid`, `AmfHdrMetadata`)
lived in amf.rs's `#[cfg(test)]` module, so they were verified only when someone
ran pf-encode's tests, on Windows, with AMF enabled — and NEVER in a release
build, which is exactly where a mis-mirrored `AMFVariantStruct` does its damage:
it crosses the FFI BY VALUE on every SetProperty. This is the same hole
`a8dd348b` closed for the cuda.h mirrors and missed here.

Adds ~40 `const _: () = assert!(...)` guards next to the mirrors: size of each
of the five vtables, the byte offset of every slot amf.rs actually calls, the
three POD layouts promoted out of the test module, and the AMFData/AMFBuffer
shared-prefix agreement that `create_surface_from_dx11_native`'s
AMFSurface-through-AMFData reinterpretation silently depends on.

Verified by compiling amf_sys.rs standalone (it needs only `c_void`, and a
repr(C) struct of code pointers has the same layout on any 64-bit target, so a
macOS const-eval proves the Windows arithmetic), and by deliberately breaking one
offset to confirm the guard actually fires rather than silently passing.

That check earned its keep immediately: `alloc_buffer` sits at slot 43, not 42.
Counting AMFInterface(3) + AMFPropertyStorage(10) + the AMFContext block by hand
is exactly the error these assertions exist to catch.

Zero runtime behaviour change. The `AMF_MIN_VERSION` ceiling is deliberately NOT
part of this commit: a ceiling would make the next AMF driver release refuse
encode on every AMD box, so it needs a warn-and-continue policy plus an env
override and a real AMF session to gate it.
2026-08-11 13:49:25 +02:00
enricobuehler db6683a585 chore(safety): commit the unsafe census, fix its two bugs, record the baseline
Founding commit for a host-focused Rust safety programme. Adds the census tool
that measures the programme, the 2026-08-11 baseline it produces, and the
programme document itself.

The metric is SHIPPED NON-FFI UNSAFE OPERATIONS: 713. Raw `unsafe {}` block
count is the wrong target and the workspace manifest already says why — 63.3%
of unsafe operations in host scope (1542 of 2435) are a single third-party FFI
call that ash/windows-rs/ffmpeg mark unsafe on our behalf. A block count also
rewards merging blocks, ignores SAFETY comments, and IMPROVES when code moves
from Linux to Windows, because no local check can see the Windows half.

The tool shipped here had two defects, both fixed:

- `in_test_mod` cached parsed `#[cfg(test)]` spans in a dict keyed on `id(src)`,
  the memory ADDRESS of the source string. CPython recycles addresses, so once
  one file's source was collected the next file's string could be allocated at
  the same address and silently inherit the previous file's test spans. Ten
  consecutive runs over an unchanged tree produced 694, 695, 696, 701, 703,
  709, 710, 713, 714 and 721. Fixed by holding a strong reference to the string
  beside its spans, which makes the address un-recyclable while the entry is
  live. Five consecutive runs now agree exactly.

- The layout-assertion regex matched `const _: () = assert!(...)` but not the
  `const _: () = { ... };` block form, which 18 files use — including abi.rs,
  pf-inject/linux/gamepad.rs and pf-capture/.../idd_push/probes.rs. It reported
  102 unguarded repr(C) declarations across 25 files where the true figure is
  60 across 22, defaming three well-guarded files.

A metric that is not reproducible is not a ratchet. The acceptance gate for
this commit is therefore five consecutive identical runs, not one.

Baseline: 713 shipped non-FFI unsafe operations; 60 unguarded repr(C)
declarations across 22 files; unsafe reachable pre-authentication by an
unpaired peer = 0 first-party.
2026-08-11 13:42:05 +02:00
enricobuehler 7ffafb5ef3 chore(api): regenerate openapi.json after merging main
`main` gained the launcher brand tokens (`f62a48d4`) while this branch was open, and both sides
touch the generated document — so it was regenerated from the MERGED source rather than
text-merged. Verified to carry both: the 18 launcher-token entries from main, and this branch's
corrected schema descriptions. No `required` array changed, so no client regeneration is needed.
2026-08-11 11:03:42 +02:00
enricobuehler 4b686f026a Merge branch 'main' into worktree-vd-sweep-2
# Conflicts:
#	api/openapi.json
2026-08-11 10:57:35 +02:00
enricobuehler d6132f7523 chore(api): regenerate openapi.json for the pf-vdisplay policy doc corrections
The sweep rewrote doc comments on `ToSchema` types (`KeepAlive`, `Topology`, `ModeConflict`,
`Identity`, `LayoutMode`, `Layout`, `DisplayPolicy`, `EffectivePolicy`), and utoipa emits those
verbatim as schema descriptions — so the checked-in snapshot went stale and
`mgmt::tests::openapi_document_is_complete_and_checked_in` would have failed.

Several of the corrected descriptions were shipping outright falsehoods to API consumers. The worst:
`KeepAlive::Forever` documented itself as "**Not honored until the display-lifecycle stage**" while
the mgmt handler honors it end-to-end and the `gaming-rig` preset selects it (sweep item 11.7).

Diff is descriptions only — the `required` arrays are unchanged, so no SDK or client regeneration is
needed. Generated with `cargo run -p punktfunk-host -- openapi` in `ci/rust-ci.Dockerfile` under
`--platform linux/amd64`, and confirmed by running the host's own drift test there (37 mgmt tests).

`docs-site/public/openapi.json` is deliberately untouched: it is already ~34 KB behind `api/` from
earlier work, and refreshing it here would sweep in unrelated changes.
2026-08-11 10:55:27 +02:00
enricobuehler dc4d8d6832 fix(pf-vdisplay): correct the regressions this sweep introduced
An adversarial review of the sweep's own diff raised 39 claims; 23 survived independent
verification. This commit fixes them. Several are cases where the sweep traded one bug for another.

**The display budget was enforced in the wrong place.** The new Linux `max_displays` ceiling sat in
`registry::acquire` — which runs again on every mid-stream rebuild. All three create-before-drop
paths hold the old lease while acquiring the new display, and only the mode-switch path passes
`supersedes`, so a session at the ceiling counted itself against the budget and could never recover
from capture loss or a Game↔Desktop switch. At `max_displays = 1` that is a single streaming client.
Moved to `admission::admit`, which is where Windows has always applied it and which is reached once
per connect — so a rebuild cannot hit it.

**"Cannot tell" was collapsed into "wrong mode".** `unanimous_output_size` returning `None` for two
disagreeing gamescopes was compared with `== Some(target)`, so ambiguity took the destructive branch:
a nested per-title gamescope — the normal Game Mode shape — made every connect restart the box's
session and kill the running game. Now a three-state `BoxOutputSize`, where `Ambiguous` mirrors the
live node instead of re-moding, and the post-restart wait asks "did what we asked for come up"
rather than demanding unanimity.

**Decide-then-act lost its mutual exclusion.** Re-scoping the `MANAGED_SESSION` guard fixed the
shutdown restore but let two concurrent creates at the same mode both relaunch, the second stopping
the unit the first was polling. A separate `MANAGED_LAUNCH` mutex restores the exclusion without
putting launch progress back into the lock the restore samples.

**Per-axis policy salvage was applied to a selector.** `preset` chooses the other axes, so salvaging
it to the default silently re-pointed the whole document; it now refuses the document instead. A file
whose every axis is unreadable also reported `configured() == Some(default)` — flipping Linux
identity from Shared to PerClient — and now correctly reports unconfigured.

**The six `#[serde(default)]` on `EffectivePolicy` are reverted**: they loosened `POST/PUT
/display/presets` (an omitted axis defaulted where it used to 400), which nobody asked for. The
catalog salvage they were added for now lives in a private Deserialize-only mirror type, so the read
path stays lenient and the wire contract stays strict.

Also: the Windows create path stored the OS-committed refresh in the field `acquire` uses as its
resize discriminator, so a same-mode re-acquire looked like a hotplug — the requested and committed
modes are now separate fields; `output_within`'s timeout arm detached both reader threads (now
bounded by a drain grace, capped at 16 MiB, and logged honestly — a `systemd-run --pipe` unit escapes
the process group and cannot be reached); `reenable_outputs_kscreen` abandoned the mode restore
whenever kscreen-doctor hit its budget even though the enable may have landed (now tri-state);
`write_atomic` replaced a symlinked portal config with a regular file, severing dotfiles management;
several new budgets were too short for the helper they bound (`steam -shutdown` was being killed
before it could deliver the request; `linger_enabled` read a 300 ms timeout as "not lingering" and
hard-failed a correctly configured box); and a restore logged an operator-facing error for a
`systemctl` call that had merely outlived its budget while systemd still owned the queued job.

Verified: 107 tests on macOS, 202 on Linux (executed in a container, not merely type-checked),
Linux and Windows clippy clean at `-D warnings`, fmt clean.
2026-08-11 10:06:16 +02:00
enricobuehler 8b98d0b3ec fix(pf-capture): a sweep found nine real defects behind comments that asserted the opposite
Reviewed the whole crate (15.6 kloc) for bugs, safety, structure and comment truth.
Both compile gates are green: `scripts/xcheck.sh windows clippy` and
`cargo clippy -p pf-capture --all-targets --locked -- -D warnings` in the amd64 CI
image (the Linux half needs libpipewire, so it cannot ride xcheck).

Code defects, each one contradicted by a comment sitting next to it:

* `pipeline_depth` clamped to `OUT_RING` (3) while both `repeat_last` and `OUT_RING`
  state the safe maximum is 2. `d` frames in flight need `d + 1` textures, so
  `PUNKTFUNK_IDD_DEPTH=3` rotated onto the slot NVENC was still reading and the convert
  overwrote it in place — torn frames, silently. Now `OUT_RING - 1`.
* The GDI cursor poller published `visible: true` for a NULL `hCursor` carrying
  `CURSOR_SHOWING` — how an app hides the pointer for its own window. The last
  rasterised arrow was then blended into a game that had hidden its cursor. Every
  rasterise gate already tested `handle != 0`; the published verdict now agrees.
* The ETW event callback did `RING.lock().unwrap()`. That is an `extern "system"` fn, so
  a poisoned lock panicked across an FFI boundary and ABORTED the host — a diagnostic
  taking down capture. Poison-tolerant now, which also makes the poison unreachable.
* `ChannelBroker::send` bounded the ring with `debug_assert`, so a release build instead
  panicked mid-`duplicate_and_deliver`, unwinding past the reap and leaking every handle
  already planted in the driver's WUDFHost. Refuses before the first duplication.
* `set_active(false)` did not clear `stall_since`, so a pooled capturer carried a stale
  stall clock into its next stream and reported capture loss microseconds in.
* `attach_gamescope_cursor` evaluated `spawn` before dropping the old source: two readers
  published into one slot, and a failed spawn destroyed a working reader. Idempotent now.
* `PUNKTFUNK_FORCE_SHM` used a bare `== "1"` compare, silently ignoring `=true`/`=on`.
* `spa_meta_bitmap.offset == 0` is SPA's "no image data" signal, distinct from the
  `bitmap_offset == 0` position-only case. Unhandled, it decoded the header's own words
  as cursor pixels and cached them.
* A `VideoInfoRaw::parse` failure was swallowed, so a malformed Format pod surfaced as
  the generic "no acceptable format" timeout. It is logged, and parsed once, not twice.

Comment corrections, all verified against the code they describe: four claims that a
failed open falls back to DDA (removed — the caller drops the keepalive under
"no fallback"); three comparisons to the removed WGC path; "we do NOT gate HDR on the
client's VIDEO_CAP_10BIT" (it does, in three places); the P010 sampler's "4 explicit
taps / 2x2 box" (two taps, left-cosited — the box was the bug it replaced); the cursor
meta cap quoted as 256x256 (1024, and 256 is the value that cost the whole Linux cursor
channel on-glass); the poller's "~60 Hz" (4 ms, ~250 Hz); "several minutes of coverage"
(~26 s); "8 frames in 400 ms >= 20 fps" (7 intervals, so 17.5); three "process-wide" HDR
latch claims (per-source, which is why HdrSource exists); a SAFETY proof claiming a view
is "unmapped never" (Drop unmaps it); the Linux module header describing a bounded
channel and BGRx-only frames (one-deep overwriting slot, several formats); and a doc
line stranded on `DisplayDescriptor` by an earlier split, restored to `IddPushCapturer`,
which had none.
2026-08-11 10:04:58 +02:00
enricobuehler 6b33750edc fix(pf-vdisplay): one non-UTF-8 byte in a portal config destroyed the whole file — in the module written to prevent exactly that
`portal_config::ensure_key` folded EVERY read failure into an empty string
(`read_to_string(path).unwrap_or_default()`). `upsert("", …)` then produced a file containing only
our block, the one-time backup was skipped because `!existing.is_empty()` was false, and the write
replaced the user's config — returning `Ok(true)`.

So a single Latin-1 character in a comment in `~/.config/hypr/xdph.conf` or
`~/.config/xdg-desktop-portal-wlr/config` destroyed the operator's entire portal configuration, with
no backup and no warning. The module doc says flat-writing these files "destroyed [everything else]
on first connect, silently and permanently" and that this module exists so it cannot happen; that
one line re-opened the door. The same shape hit a transient EIO on an NFS or overlay config dir.

Now: bytes are read with an explicit match, only `NotFound` may mean "empty", a non-UTF-8 config is
refused by name rather than replaced, the backup is taken by BYTES, and the write is atomic
(temp + `sync_all` + rename in the same directory, permissions carried over). Five new tests, all
running on macOS — `a_non_utf8_config_is_refused_not_replaced` fails against the old code.

Also in the wlr/Mutter family:

* **Mutter's `Primary` rebuilt kept physicals from scratch** — scale forced to 1.0, transform to 0,
  disabled heads re-enabled — so a rotated, 2x-scaled or deliberately-disabled monitor came back
  wrong, while the code went to real trouble to preserve refresh. Each head now carries its
  pre-connect scale and transform, and x advances by the LOGICAL width.

* Three availability probes read session env (`SWAYSOCK`, `XDG_CURRENT_DESKTOP`,
  `HYPRLAND_INSTANCE_SIGNATURE`) with no `ENV_LOCK` while `apply_session_env` `set_var`s the same
  keys from another thread — the glibc setenv/getenv race this crate's own lib.rs documents as UB.

* `wlroots::create_output` ran a statement before its `OutputGuard` existed, so a raced
  `wait_new_output` orphaned the output permanently — hyprland takes the guard first. The
  before/after name diff also ran outside any lock, so two concurrent creates could adopt each
  other's output. Both now run under a create lock, with a stray sweep on the failure path.

* `select_and_cast`'s timeout arm dropped the portal thread's `stop` flag un-set — the same leak
  Mutter was already fixed for. The guard is now built before the wait, in both copies.

* The xdpw chooser file was written per session and never removed, permanently shadowing the
  config's fallback with the name of an already-unplugged output. Its lifetime is now the handshake,
  not the session — scoped deliberately, because tying removal to the keepalive would let one
  session delete another's selection hours later.

* Hyprland's headless outputs are now named `PF-<pid>-<n>` and reconciled at startup, so a crashed
  host's leftovers are reclaimed while a live sibling host's outputs cannot be pulled out from under
  it. `set_monitor_rule` no longer discards hyprctl's rejection text and then hard-codes a
  GBM/dmabuf diagnosis it never verified.

* Both wlr backends silently dropped the `topology` policy axis: `Primary`/`Exclusive` was accepted,
  echoed by the mgmt API, applied on three backends and a no-op on two. They now say so.

Item 8.1: `swaymsg`, `hyprctl` and the portal `systemctl --user try-restart` calls are bounded
through `proc` with named budgets.
2026-08-11 09:22:32 +02:00
enricobuehler ef72d102b6 fix(pf-vdisplay): KWin's re-enable reported success when it matched no outputs at all, leaving a physical monitor dark
* **`reenable_outputs` returned `true` when it matched NONE of the requested outputs.** Unresolvable
  outputs were `continue`d and the return was the apply verdict alone — but an empty
  `kde_output_configuration_v2` still gets an `applied` event. So a total no-op suppressed the
  `reenable_outputs_kscreen` backstop and the operator's physical monitor stayed dark. Now counts
  staged outputs and returns `ok && matched == outputs.len()`, and refuses to apply an empty
  configuration at all.

* **The kscreen restore logged "restored the physical/bootstrap outputs" unconditionally**, with
  both call results discarded — including when `kscreen_ok` returned false on its 5 s budget, which
  is exactly the wedged state that fallback exists for.

* **`Session::open` swallowed every failure reason** — connect error, barrier timeout, missing global
  — and three of four callers degraded to kscreen-doctor with zero log. This is the class that hid
  the KWin >= 6.7 registry regression: a shipped fallback firing silently on every machine. It now
  logs at warn with the reason and the caller's operation name.

* `last_name` was seeded with a name kscreen-doctor can never resolve (KWin's address is
  `Virtual-punktfunk…`), so the intended default was guarded by an `is_none()` that could never hold
  and `apply_position` ran against no output. `our_uuid` was never reset per `create` and only
  assigned under `outcome.handled`, so a supersede positioned the *previous* output and never fell
  back.

* `probe()`'s `roundtrip` was the only unbudgeted compositor wait in the crate — every sibling path
  is budgeted — and it is reached from an async mgmt handler. Now bounded at 3 s. The pre-`created`
  dispatch loops gained deadlines and now set `stop` on the timeout arm.

* Every `wl_output` global was bound for the session's life with no `GlobalRemove` arm and no
  `release()`, on the virtual-output path too, which never reads them: unbounded growth on a
  hotplugging session.

* `monitors::list` was the one KWin call site with no kscreen fallback at all, despite `list_monitors`
  failing on exactly the condition the other four fall back for. It has one now.

* `CVT_H_GRANULARITY` and `MANAGED_PREFIX` existed as two literals under prose asserting they match;
  the second copy now imports the first.

The wider facade extraction (item 9.1) is deliberately not in this commit, but its two prerequisites
are — a comment at the restore seam records why they had to come first: a fallback arm that returns a
value the helper never checked re-introduces the silent success, behind a seam whose selling point is
one honest log per decline.

Also corrects the `PhysicalMonitor` type doc, which claimed "logical geometry throughout" while
`width`/`height` are the mode's PIXELS and `x`/`y` are logical, and adds the `logical_size()` helper
that is the only correct way to compare an extent against a position.
2026-08-11 09:22:11 +02:00
enricobuehler b2c03f1904 fix(pf-vdisplay): a managed launch blocked the shutdown restore that was meant to rescue it, and re-moding could flip the operator's own screen
The gamescope subsystem — the crate's largest and fastest-churning area, and the one the 2026-07-28
sweep predates most of.

* **`MANAGED_SESSION` was held across the ~90 s managed launch**, and the shutdown/idle restore
  blocks on that same lock — *after* it has already stopped our unit. So the display-manager restore
  never ran and the box was left with no session at all. `create_managed_session` now decides under
  the guard and acts outside it, re-acquiring only to store the result; `do_restore_tv_session`
  consumes the record in a short scope at the top. Same shape the SteamOS twin already used.

* **The physical-display guard was bypassed whenever no gamescope node happened to be published.**
  `if physical_display_connected() { if let Some(node) = find_gamescope_node() { … } }` fell through
  to `set-environment SCREEN_WIDTH/HEIGHT/CUSTOM_REFRESH_RATES` + `restart` when the node was
  momentarily absent — gamescope restarting between titles, or built without PipeWire — flipping the
  operator's own screen to the client's resolution and bouncing a DM-driven login session. The guard
  now refuses instead of falling through, and the forced `SCREEN_*` values (which were never unset,
  so every later session on the box inherited them) are tracked and `unset-environment`ed on restore.

* **`current_gamescope_output_size()` reported an arbitrary gamescope's `-W`/`-H`** — whichever
  `/proc` enumerated first — and four consumers treated it as this session's output size. It now
  answers only when every gamescope on the box agrees, and `None` ("cannot tell") when they differ.
  `heads.rs` no longer takes it at all: it reads the size off the DRM-backed argv it already
  selected. Its test previously passed `None`, which is why the hazard was invisible.

Resource and honesty fixes: the ATTACH path armed the box's own session-unit bind drop-in and no
in-process path ever removed it (now tracked and disarmed on both restore arms); `wait_for_node`
never called `try_wait`, so a gamescope that died at `vkCreateDevice` was polled for the full 15 s
and the error then blamed headless capture support; `do_restore_tv_session` deleted its crash-recovery
state *before* the unbounded work that state records, so a grace-period expiry in that window left
the DM down with nothing on disk to heal it; the SteamOS takeover's two failure arms never armed the
TV restore though the session-plus twin does; the TV-session restore logged success with the
`systemctl` status discarded; the `steam -shutdown` child was dropped un-reaped; and a managed
session that took nothing over was never persisted, so a host crash orphaned the transient unit.

Item 8.1: the unbounded `pw-dump`, `systemctl`, `loginctl` and `pkexec` calls in this subsystem now
go through `proc::{status_within, output_within}` with per-call budgets. `pw-dump` is polled from
three separate 45 s loops against the very daemon this file documents gamescope as head-blocking,
and until now a hang there pinned the session's stream thread forever.
2026-08-11 09:22:09 +02:00
enricobuehler db65980979 fix(pf-vdisplay): the ghost-monitor reap fed live devices to pnputil, and two unsafe fns had no unsafe in them
Windows half of the sweep — the reap bug, a panic that poisons two locks, and a round of unsafe
reduction.

* **The ghost reap selected the wrong devices.** It filtered `Status -ne 'OK'`, a HEALTH field: that
  matches devices that are PRESENT but in Error/Degraded/Unknown, not the ABSENT ones the reap is
  for — and it handed them to `pnputil /remove-device`, contradicting its own documented contract.
  It runs from `add_monitor`'s mid-session slot-exhaustion recovery, so the blast radius is a live
  session. Now filters on `-not $_.Present`.

* **`ensure_pinger` still used the panicking `thread::spawn` while holding two locks**, poisoning
  both — the un-fixed twin of a fix that already landed for `ensure_exclusive_watch`. Same shape
  applied.

Unsafe reduction, continuing the program that made pf-win-display's CCD helpers safe fns:

* `resolve_target_gdi` and `reisolate_after_swap` were `unsafe fn`s containing zero unsafe
  operations, and the three call-site SAFETY proofs described FFI they no longer perform. Both are
  now safe fns and those blocks are gone.
* `VdisplayDriver::open`'s `# Safety` section named no caller obligation — the same empty shape an
  earlier phase already removed from `open_device`.
* `(*detail).DevicePath.as_ptr()` derived a pointer from a `[u16; 1]` field and handed it to
  `CreateFileW`, which reads the whole flexible-array path beyond it. Now taken with `&raw const`
  from the full struct, so the pointer carries the provenance of the bytes actually read — the same
  correction already made for `MONITORINFOEXW` in ddc.rs.

Comment fixes, all verified against the code: three intra-doc links to a type this crate does not
have; a doc-comment run merged so that `shrink_action` — the gate that keeps a `Primary` group's
physical panels lit — read as undocumented while its rationale sat on an unrelated polling helper;
and the backend module header, which documented itself against a `sudovda` module that does not
exist and a fallback the crate says was removed.

Adds the first tests for `knobs.rs`, `instance.rs` and `driver.rs` — including `is_privileged_sid`,
the security-relevant predicate that decides whether an existing single-instance name is another
host or a squat, which had no coverage on any platform.
2026-08-11 08:49:06 +02:00
enricobuehler a1ff0dde0c fix(pf-vdisplay): the host promised HDR and cursor forwarding for gamescope sessions it did not start
`gamescope_ours_and` answered "did WE spawn this gamescope?" by reading `PUNKTFUNK_GAMESCOPE_NODE`.
Phase 2.3 deleted the code that published that key — routing.rs's own doc says "Nothing is written
back to the two knobs" — but this consumer was never migrated, so the read now returns "not
attaching" for every attach.

Both consumers then answer for a session this host has no flags on. On a plain box with a foreign
gamescope already running, `pick_gamescope_mode` resolves Attach at its fifth rung while the env key
stays unset, and the probe half only inspects the resolved BINARY, which is our patched build:

* `gamescope_composites_cursor()` returns true, so the host attaches no XFixes reader and blends
  nothing — while the stock gamescope actually running was never given
  `--pipewire-composite-cursor`, so the stream carries no pointer at all.
* `gamescope_hdr_available()` returns true, so the Welcome fixes `bit_depth` at 10 and the session
  negotiates BT.2020/PQ over an 8-bit SDR composite. The Welcome cannot take that back.

The same two failures hit the `capture_monitor` mirror route on any Bazzite or SteamOS box, where
the running Game Mode gamescope is by definition not one this host spawned.

The question is now asked of the resolved route rather than the environment, via a pure
`session_is_a_foreign_gamescope` that runs — and is tested — on every platform. The residual gap is
named in the doc rather than papered over: `create_managed_session`'s create-time degrade to a
foreign attach is still invisible to a ladder re-run.

Also in this commit:

* Two unguarded session-env reads now take `ENV_LOCK` (`detect()`'s `XDG_CURRENT_DESKTOP` fallback
  and `effective_topology()`'s legacy pins). `apply_session_env` `set_var`s those same keys from
  another thread, which is the glibc setenv/getenv race this crate's own lib.rs documents as UB.
* `mirror.rs`'s `names_ours_conclusively` was a `matches!` whose omitted default was the UNSAFE
  direction — a new backend would silently get its own virtual displays mirrored. Now exhaustive, so
  adding a `Compositor` is a compile error at the one site where the answer is a safety decision.
* `MirrorDisplay` overrides `poolable_now() -> false`; its `create` always reports `External`, so
  the trait's `true` default was a pre-create claim contradicting the post-create fact. The trait
  doc now says plainly that the default is a default and not a fact.
* The crate front-door doc listed 3 of 7 backends and quoted line counts half the size of the
  current crate; `routing.rs`'s summary was attached to the wrong item and described a published env
  channel that no longer exists; `available()` is no longer documented as cheap when it forks
  `gamescope --version` and does an unbudgeted Wayland roundtrip per call.
2026-08-11 08:48:49 +02:00
enricobuehler 9d58f4c170 fix(pf-vdisplay): one unreadable byte reverted the host to built-in display defaults, and one bad preset dropped the whole catalog
The policy layer folded every failure into "unconfigured", then wrote that emptiness back.

* **Any parse error reverted the WHOLE policy.** An unknown enum variant, a mistyped scalar, an
  EACCES or EIO — all became `Err(_) => None`, i.e. the host silently ran on built-in defaults with
  the operator's `display-settings.json` still sitting on disk. Parsing is now layered: strict
  first, then per-axis salvage so one unreadable axis costs only that axis, and only `NotFound` is
  quiet — EACCES/EIO warn loudly that the host is on defaults. `version` is read instead of being
  blindly rewritten to 1.

* **One malformed entry dropped the entire custom-preset catalog**, and the next CRUD atomically
  renamed the empty vector over the file. Entries are parsed one at a time now; a lossy load is
  flagged and refuses to overwrite.

* `sanitized()` clamped `max_displays` but never `KeepAlive::Duration.seconds`, so a PUT could pin a
  display for ~136 years — a deadline the reaper never reaches and a nonsense `expires_in_ms` in
  `/display/state`. Clamped to a day, in both `sanitized()` and `sanitize_preset_fields`, and
  sanitization now runs on LOAD as well as on write.

* The two stores' temp files had fixed names and no write lock, so concurrent saves could interleave
  serialize -> rename -> in-memory update. Unique suffixes, a lock, and the in-memory update ordered
  after the rename.

* `new_preset_id` never consulted the loaded entries for collisions.

* **Manual layout could place an unpinned display exactly on top of a pinned one**: the fallback was
  the unconditional auto-row prefix sum, blind to where prior members were pinned. Unpinned members
  now pack clear of the pins. Layout keys are canonicalized and unusable ones dropped at write time
  rather than persisted-and-ignored.

Adds 20 tests, all running on macOS: a 20k-round randomized property test asserting no unpinned
member ever overlaps a sibling (verified to fail against the pre-fix `arrange_manual`), the salvage
and quarantine paths, the clamps, and a field-count guard that fails the moment a 13th policy axis
appears without being wired into the merge path.

Note: `partial_json_fills_defaults` was renamed to `serde_defaults_fill_a_partial_document` with no
assertion weakened — it pins the FILE contract (an old settings file must still load), which is not
the mgmt PUT contract that sweep item 11.1 is about.
2026-08-11 08:48:47 +02:00
enricobuehler 61ff543acc fix(pf-vdisplay): a new client could be handed a streaming client's display, and a blind /proc scan tore every backend down
Five defects in the registry/identity half, plus the restructure that finally makes them testable.

* **A new client could be assigned a LIVE client's identity slot.** `DisplayIdentityMap::resolve`
  LRU-evicted purely on its `seen` stamp, with no knowledge of which ids are streaming. On Windows
  that id keys the manager's slot map, so the newcomer took the plain-JOIN branch and inherited the
  other client's monitor, capture target and stop flag. `resolve` now takes the live set, never
  evicts a live id, and REFUSES rather than hand one over — degrading to the shared/auto identity.

* **A transient `ActiveKind::None` invalidated every backend entry, including live streaming ones.**
  A `read_dir("/proc")` that happened to fail satisfied the change test and bumped the session
  epoch. A `None` observation is no longer evidence a desktop went away, and no longer overwrites
  the baseline (which would have bumped the epoch on the next poll anyway).

* **The Linux pool had no display ceiling at all** — `max_displays` was enforced only on Windows,
  while the pool keys on the CLIENT-SUPPLIED mode, so each distinct requested resolution minted a
  new display. Now capped in `linux::acquire`, gated on `poolable_now` so a gamescope attach or
  managed session (which consumes no pool slot) is not refused.

* **Two different definitions of "display group"** — `group_key` and a bare backend-name compare —
  and only one separated gamescope spawns. Unified as `pool::in_group`. The `position_for_new`
  collection also lacked the supersede exclusion the topology check 70 lines earlier had, so a
  mid-stream resize auto-rowed the replacement past its own dying predecessor, walking the display
  one width to the right on every mode switch.

* **Lifecycle events were wrong in both directions**: `Created` fired on keep-alive reuse, and
  `Released` fired only from the mgmt endpoint — never from a lease drop, the linger reaper,
  `mark_failed`, `retire` or `invalidate_backend`. All six now emit.

Also: `Release::Noop` no longer runs a full teardown (the one outcome the state machine defines as
"do nothing"); a failed linger-reaper spawn logs and retries instead of consuming its `Once` and
never tearing a kept display down again; group ids are a monotonic per-key counter instead of an
index into the currently-live sorted set, so an unrelated group appearing no longer renumbers a
display; and a corrupt `display-identity.json` is renamed to `.bad` with a warning rather than
silently overwritten, which used to reset every client's stable id and its saved DPI.

The pure half of the pool (`Entry`, `group_key`, `epoch_matches`, `take_expired`, `at_display_budget`,
`position_for_new`, `assign_group_ids`, `assemble_displays`) is now a non-cfg'd `mod pool`, so the
registry's decisions are exercised on every platform's CI instead of only on a Linux box. Crate test
count 53 -> 94.
2026-08-11 08:48:15 +02:00
enricobuehler dd9bbaf1c5 fix(pf-vdisplay): a helper that outran the pipe buffer had its output thrown away as a timeout
`output_within` read stdout/stderr only after the child exited, and its doc justified that with
"these helpers emit at most a few hundred KiB, well under any real pipe pressure". A pipe holds
64 KiB. Anything past that blocks the helper in `write()`, so it never exits, the budget kills it,
and a successful query is reported to the caller as `TimedOut` with its answer discarded.

The busiest caller is the one that trips it: `pw-dump` on a populated PipeWire graph clears 64 KiB
routinely and is polled from the 45 s gamescope loops. Confirmed empirically — a child writing
1 MiB into an undrained pipe never exits.

Both pipes are now drained on their own threads, concurrently with the wait.

That makes the joins load-bearing, which exposed the second half: the Unix `tree::Guard` was an
empty stub whose doc claimed `Child::kill` "already ends the only process there is". It never did
for this crate's Linux helpers — `pkexec`, `systemd-run`, `systemctl --user` and the `sh -c`
wrappers all fork — and a surviving grandchild holds the pipes' write ends, so a reader would wait
for an EOF that never arrives. The child is now the leader of its own process group and the guard
`killpg`s it, which is the Unix shape of the Job object the Windows half already used.

Also gates `pf-frame`, `pf-gpu` and `pf-encode` to Windows: every use site of all three is
`cfg(windows)`, and between them they dragged FFmpeg, ash and openh264 into the Linux build for
nothing (sweep item 13.19).
2026-08-11 08:22:16 +02:00
126 changed files with 9987 additions and 2028 deletions
+15
View File
@@ -114,6 +114,21 @@ jobs:
- name: Clippy (deny warnings)
run: cargo clippy --workspace --all-targets --locked -- -D warnings
# WP19 (rust-safety): the hardened NATIVE-ONLY host — no Moonlight-compat planes, no
# `rusty_enet` (transpiled C ENet), no `rsa`. Kept compiling here so the cfg boundary can't
# rot, and the dependency claim is ASSERTED, not assumed: `cargo tree -i` must find neither
# crate in the native-only graph (it exits non-zero with "nothing depends on" — inverted).
- name: Clippy + tree (native-only host, no gamestream feature)
run: |
cargo clippy -p punktfunk-host --no-default-features --features pyrowave \
--all-targets --locked -- -D warnings
if cargo tree -p punktfunk-host --no-default-features --features pyrowave \
--locked -i rusty_enet 2>/dev/null | grep -q rusty_enet; then
echo "native-only build still depends on rusty_enet"; exit 1; fi
if cargo tree -p punktfunk-host --no-default-features --features pyrowave \
--locked -i rsa 2>/dev/null | grep -q "^rsa"; then
echo "native-only build still depends on rsa"; exit 1; fi
- name: Build
run: cargo build --workspace --locked
+142
View File
@@ -14,6 +14,98 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.27.1 — in development
### GameStream is now opt-in on EVERY route (⚠ packager-visible default change)
The secure native-only host is the default everywhere; the Moonlight-compat planes (plain-HTTP
pairing + the legacy GCM path, security-review #5/#9) are enabled only by an explicit choice:
- **The shipped systemd user unit** (`scripts/punktfunk-host.service`, installed by deb/RPM/Arch/
sysext) runs bare `serve``--gamestream` is no longer baked into `ExecStart`. Opt in via the
new **`PUNKTFUNK_GAMESTREAM=1`** knob in `host.env` (pf-host-config; equivalent to the flag —
either source enables), so no unit editing survives-upgrades dance is needed.
**Upgrade note:** a packaged host that served Moonlight by default becomes native-only until
the operator sets the knob (a hand-made `ExecStart` drop-in keeps winning as before).
- **NixOS module**: `services.punktfunk.host.gamestream` default flipped `true``false`
(module-check gained a "default is native-only" assertion); enabling it still opens the
GameStream firewall ports.
- **Steam Deck installer**: `--gamestream` opts in (was on-by-default with `--no-gamestream`;
the old flag is still accepted as explicit-off).
- Windows was already opt-in (unchecked installer task) and is unchanged.
### The ENet control port now exists only while a pairing does (rust-safety WP0)
`rusty_enet` — a c2rust-style transpile of C ENet, and the host's only pre-auth-reachable unsafe
surface — no longer listens unconditionally: UDP 47999 binds when the paired-client list becomes
non-empty and is torn down when the last pairing is removed (a live client gets the same
TERMINATION+disconnect farewell as a host-side session end). Pairing itself is HTTPS on nvhttp and
never touches the port, so a never-paired `--gamestream` host exposes no ENet at all. En route:
the management API's unpair endpoint never persisted (`save_paired` was missing), so an unpair
lasted only until the next restart — fixed. `rusty_enet` is now pinned `=0.4.0`.
**Unpair is now a complete revocation, on both planes.** Beyond the persistence fix above, an
unpair used to leave the revoked client's LIVE session streaming until the client chose to
leave. Now: unpairing a GameStream client whose certificate owns the active launch ends that
session (the client gets the standard TERMINATION+disconnect, and unpair-all still closes the
ENet port); unpairing a native client deliberately stops its live punktfunk/1 session(s)
(matched by certificate fingerprint — anonymous/TOFU sessions are unaffected, they have no
pairing to revoke). The unpair endpoint's long-standing docstring caveat ("removes the client
from the listing without severing its ability to reconnect") is retired: TLS-level handshakes
still complete by design, but authorization is per-request and a live session no longer
survives its own revocation.
### GameStream is now a cargo feature (compile-time isolation — packager-visible)
The Moonlight-compat planes (nvhttp pairing, RTSP, the ENet control stream, `_nvstream` mDNS,
the compat media path) are gated behind a new **`gamestream` cargo feature — default ON**, so
every stock package is behaviorally identical (GameStream stays runtime-opt-in via
`--gamestream` / `PUNKTFUNK_GAMESTREAM`). Building with
`--no-default-features --features pyrowave` produces the **hardened native-only host**:
- **no `rusty_enet`** — the c2rust-transpiled C ENet stack (158 unsafe sites) is absent from
the binary, provably (`cargo tree -i rusty_enet` finds nothing; CI asserts it);
- **no `rsa`** — the native planes run on the P-256 identity (above), and the legacy-identity
fallback is a pem-only read (rustls/ring serves an existing RSA cert without the crate), so
the accepted Marvin advisory (RUSTSEC-2023-0071) no longer applies to native-only builds;
- ~6,700 lines of Moonlight protocol code gone; `serve --gamestream` (or the env knob) against
such a binary **refuses to start** with a clear error rather than serving less than asked;
- the native-only management API (and its OpenAPI document) has no GameStream PIN endpoints
(`/api/v1/pair`, `/api/v1/pair/pin`); everything else — including the paired-client list and
unpair — is identical, so consoles work unchanged.
The checked-in `api/openapi.json` remains the default-features document.
### The identity split — the native planes get their own (P-256) host identity
One RSA-2048 identity historically served every plane, because Moonlight mandates RSA and the
planes grew out of the GameStream host. The native punktfunk/1 QUIC plane and the management API
now share a separate **ECDSA P-256** identity (`native-cert.pem`/`native-key.pem`): generated by
ring via rcgen, browser-compatible (Ed25519 server certs are not), carrying real SANs
(localhost, loopback, the machine hostname — the legacy cert had none), and free of the accepted
`rsa`-crate Marvin advisory. The GameStream plane keeps the RSA identity untouched.
**Migration is pin-preserving by construction**: clients TOFU-pin the leaf-cert SHA-256 at
pairing and use that one pin for both QUIC and the mgmt/library API, so the new identity is
adopted **only when the native trust store is empty** (fresh installs, or after an explicit
unpair-all + restart). An upgraded host with live native pairings keeps presenting the legacy
RSA cert those clients pinned, and logs the migration path. Fingerprint pinning is
algorithm-agnostic, so existing shipped clients pair against P-256 hosts unchanged.
Follow-the-identity consumers updated in-tree: the tray's loopback pin and the plugin SDK's
mgmt CA now prefer `native-cert.pem` (falling back to `cert.pem`), and the Windows runner ACL
grant covers both. ⚠ A plugin bundling an **older** `@punktfunk/host` SDK on a **fresh**
(P-256) host trusts the wrong cert — set `PUNKTFUNK_MGMT_CA=<config>/native-cert.pem` in its
environment or rebuild against the current SDK.
### Memory-safety, compiler-enforced (embedder-visible lint tightening)
`punktfunk-core` now carries `#![deny(unsafe_code)]` crate-wide: everything that parses network
bytes is safe Rust by compiler-enforced invariant. The documented `#![allow]` carve-outs are the
client surface (`abi`, `client`) and the platform syscall-batching shims under `transport`
(`udp/{apple,linux,windows}`, `qos_windows`) — none of which interpret attacker bytes. In
`punktfunk-host`, the modules a secure-default host exposes (`native`, `native_pairing`, `mgmt`,
`mgmt_token`, `discovery`, `wol`) are `#[forbid(unsafe_code)]`. If you embed `punktfunk-core` and
patch it, new unsafe outside the carve-outs is now a compile error.
### NixOS + KDE — session detection, the other half
🛑 **v0.27.0's NixOS session-detection fix did not reach a stock NixOS + Plasma 6 box.** It resolved
@@ -42,6 +134,56 @@ availability probe. The `comm` fast path is still one read for every ordinary di
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
### Game Mode on Nobara — the WSI opt-out never reached the games
🛑 **v0.27.0's fix for the distro Vulkan WSI layer was clobbered by the session script, so games ran
on a black screen** while the host's own log claimed the layer had been disabled. Steam Big Picture
came up, showed the right mode, showed the perf overlay — and then every game played sound and took
input over a black picture, with no error on either side.
The layer (`VkLayer_FROG_gamescope_wsi`) ships with the *distro's* gamescope and speaks its
`gamescope_swapchain` protocol; ours disagrees, so the compositor rejects the client's
`swapchain_feedback` and kills it. v0.27.0 turned the layer off with `ENABLE_GAMESCOPE_WSI=0` on the
session unit. `gamescope-session-plus` then runs an unconditional `export ENABLE_GAMESCOPE_WSI=1`
near the top of the script — before it launches anything — so the opt-out survived exactly as long
as it took the script to start, and every process the session spawned got the layer back. Nothing
looked wrong because the casualty is Vulkan clients specifically: Steam's own UI is not one.
The opt-out is now `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader reads an implicit layer's
two manifest knobs in a fixed order: `enable_environment` must equal `"1"` to switch the layer on,
and `disable_environment` is then consulted last and wins on **presence alone**, at any value. The
session script never mentions that second variable, so it is the one that survives. Both spellings
go out, on the transient unit and on the box's own session drop-in.
### punktfunk-gamescope `+pfhdr6` — a NO_FOCUS window can no longer steal the composite
🛑 **A mapped-but-unpainted window carrying `GAMESCOPE_NO_FOCUS=1` could win gamescope's focus
selection and turn the composite — and the stream fed from it — black while every health signal
stayed green.** Bazzite's hhd-ui (Handheld Daemon overlay) sets that atom once at init, stamps
Steam's appid, and crash-loops under a headless takeover; each respawn remapped a fullscreen black
window that steamcompmgr then chose over Big Picture (observed on a Bazzite box: client stats
happily decoding 60 fps at 0.1 Mb/s of black; killing hhd-ui restored the picture instantly). No
gamescope — upstream or Bazzite's fork — ever consumed the atom; its setters (hhd-ui, MangoHud)
show and hide via the `STEAM_OVERLAY` protocol and rely on never being focusable. Patch 0008 wires
`GAMESCOPE_NO_FOCUS` exactly like `GAMESCOPE_EXTERNAL_OVERLAY` (read at map, PropertyNotify-tracked,
skipped by both focus-candidate collectors) without touching compositing or `appID`. Banner
`+pfhdr5``+pfhdr6`; no new capability — the bump is so a field box's banner tells the two
behaviors apart.
### Linux capture — the truncated first attempt no longer latches sticky downgrades
🛑 **The pipeline retry loop's deliberately short (2.5 s) first-frame attempt could permanently
downgrade the whole host process.** On expiry, the portal capturer's timeout diagnosis latched
whichever offer it implicated — HDR capture off (per source), the raw-dmabuf offer off, the
EGL→CUDA offer off — as if the compositor had refused it, when the budget was truncated by design
and a gamescope cold start routinely needs longer before delivering anything. One lost race at
connect then pinned every later session to SDR and/or CPU capture until the host restarted. The
truncated attempt is now declared provisional end to end
(`Capturer::next_frame_within_provisional`): its expiry names the same suspect in the error text
but latches nothing; only the full-length attempts that follow hand down negotiation verdicts. The
classification is a pure function with tests
(`pf_capture::linux::first_frame_timeout_tests`).
## v0.27.0
87 commits since v0.26.0.
+10 -9
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.26.0"
"version": "0.27.0"
},
"paths": {
"/api/v1/clients": {
@@ -53,7 +53,7 @@
"clients"
],
"summary": "Unpair a client",
"description": "Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer\ndoes not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
"description": "Removes the client's certificate from the pairing store (persisted — the removal survives a\nhost restart). Removing the last pairing also closes the GameStream ENet control port\n(UDP 47999), which is only bound while at least one pairing exists. Caveat: the nvhttp TLS\nlayer does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed\nclient cert — a planned hardening step), so until that lands this removes the client\nfrom the listing without severing its ability to reconnect.",
"operationId": "unpairClient",
"parameters": [
{
@@ -4788,7 +4788,7 @@
"version": {
"type": "integer",
"format": "int32",
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject. Read\nat load time ([`DisplayPolicyStore::load_from`] warns when a file claims a version this host\ndoes not know, then reads it best-effort) and pinned back to the current version on write.",
"minimum": 0
}
}
@@ -4857,7 +4857,7 @@
},
"EffectivePolicy": {
"type": "object",
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the policy call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].\n\n**Every field is required on the wire, deliberately.** Unlike [`DisplayPolicy`] — which is only\never a *file* — this shape is also the `fields` member of [`CustomPresetInput`], i.e. the request\nbody of `POST /display/presets` and `PUT /display/presets/{id}`, and a *response* member three\ntimes over (`DisplaySettingsState.effective`, `PresetInfo.fields`, `CustomPreset.fields`).\n`#[serde(default)]` here would (a) turn `{\"name\":\"Kiosk\",\"fields\":{}}` — or any camelCase typo —\nfrom a serde rejection into a 201 storing a preset that expands to six axes nobody chose, and\n(b) make all six OPTIONAL in the generated OpenAPI schema, so every codegen'd client has to\nnull-check them. The *persisted* catalog's tolerance for an entry written before an axis existed\nis bought where it belongs, on the read path only: see [`StoredEffectivePolicy`].",
"required": [
"keep_alive",
"topology",
@@ -5915,7 +5915,7 @@
},
"Identity": {
"type": "string",
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). The\nslot this resolves to is carried per backend: the Windows EDID serial + IddCx connector index,\nKWin's per-slot output name, and the host-persisted Mutter scale map.",
"enum": [
"shared",
"per-client",
@@ -6132,14 +6132,14 @@
"seconds": {
"type": "integer",
"format": "int32",
"description": "Linger window in seconds.",
"description": "Linger window in seconds, clamped to `0..=86400` on write (see\n[`DisplayPolicy::sanitized`]): a window longer than a day is `forever` by any honest\nreading, and `u32` seconds is ~136 years — a deadline the reaper would never reach and a\nnonsense `expires_in_ms` in `/display/state`.",
"minimum": 0
}
}
},
{
"type": "object",
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\nHonored end-to-end: the registry resolves it to `Release::Pin`, so the display survives every\ndisconnect — free it with `POST /display/release` (which force-releases `Pinned` exactly like\na `Lingering` display). This is what the `gaming-rig` preset selects.",
"required": [
"mode"
],
@@ -6183,6 +6183,7 @@
},
"positions": {
"type": "object",
"description": "Keys are the **canonical decimal** identity-slot id (`\"1\"`..`\"15\"`) — the exact string\n`arrange` looks a member up by. [`DisplayPolicy::sanitized`] re-canonicalizes them on write\n(`\"01\"` → `\"1\"`) and drops anything that is not a slot id, because a key that never matches is\na pin the operator can see in the console and in `GET /display/settings` while every session\nsilently auto-rows past it.",
"additionalProperties": {
"$ref": "#/components/schemas/Position"
},
@@ -6194,7 +6195,7 @@
},
"LayoutMode": {
"type": "string",
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
"description": "How group members are arranged in the desktop coordinate space, resolved by `layout::arrange` —\nwhich both the `/display/state` readout and (on Linux, KWin only) the per-backend position apply\nconsume, so the answer is computed in exactly one place.",
"enum": [
"auto-row",
"manual"
@@ -6354,7 +6355,7 @@
},
"ModeConflict": {
"type": "string",
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Enforced by [`super::admission`] before the Welcome is sent, so a `reject` is a\nclean handshake error rather than a half-built session.",
"enum": [
"separate",
"steal",
@@ -56,6 +56,7 @@ import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.models.Tab
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@@ -74,7 +75,7 @@ fun App(forceGamepadUi: Boolean = false) {
// whose library the console shell should come back to. Held HERE because the shell's own
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
var reopenLibrary by remember { mutableStateOf<LibraryReturn?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
@@ -139,9 +140,9 @@ fun App(forceGamepadUi: Boolean = false) {
// than all the way out to host selection. The console shell's own screen state does
// not survive the stream (StreamScreen replaces it in the composition, discarding
// its `remember`s), so the intent is hoisted here and handed back on the way in.
reopenLibraryHostId =
reopenLibrary =
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
active.hostId
active.hostId?.let { LibraryReturn(it, active.libraryProfileId) }
} else {
null
}
@@ -154,8 +155,8 @@ fun App(forceGamepadUi: Boolean = false) {
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
reopenLibraryHostId = reopenLibraryHostId,
onReopenLibraryHandled = { reopenLibraryHostId = null },
reopenLibrary = reopenLibrary,
onReopenLibraryHandled = { reopenLibrary = null },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -282,15 +283,19 @@ fun GamepadShell(
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
/**
* Open this saved host's library instead of Home on the way in set when a game launched from
* it has just exited. Null (the default) starts on Home exactly as before.
* Open this library shelf instead of Home on the way in set when a game launched from it has
* just exited. Null (the default) starts on Home exactly as before.
*/
reopenLibraryHostId: String? = null,
reopenLibrary: LibraryReturn? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Which of that host's shelves is open: the pinned card's profile id, or null for the host's
// own tile (design §5.2a). Held beside `libraryHost` because it is the same navigation fact —
// a pinned card and its host are two tiles, and the library belongs to whichever you pressed.
var libraryPinId by remember { mutableStateOf<String?>(null) }
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
@@ -301,15 +306,21 @@ fun GamepadShell(
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
// A host that has since been forgotten simply leaves us on Home rather than failing.
LaunchedEffect(reopenLibraryHostId) {
val id = reopenLibraryHostId ?: return@LaunchedEffect
LaunchedEffect(reopenLibrary) {
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
// either order happens to work — but this one cannot be broken by a later edit that adds a
// suspending call. A host that has since been forgotten just leaves us on Home.
KnownHostStore(context).all()
.firstOrNull { it.id == id }
?.let { libraryHost = it; screen = GamepadScreen.Library }
// A pin unpinned while the game was running is no longer a shelf: fall back to the
// host's own, rather than a card that no longer exists.
?.let { kh ->
libraryHost = kh
libraryPinId = pinId?.takeIf { it in kh.pinnedProfileIds }
screen = GamepadScreen.Library
}
onReopenLibraryHandled()
}
@@ -377,7 +388,11 @@ fun GamepadShell(
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
onOpenLibrary = { host, pinId ->
libraryHost = host
libraryPinId = pinId
screen = GamepadScreen.Library
},
navGate = s == screen,
)
GamepadScreen.Settings -> GamepadSettingsScreen(
@@ -407,8 +422,9 @@ fun GamepadShell(
host = host,
settings = settings,
onLaunched = onConnected,
onBack = { screen = GamepadScreen.Home; libraryHost = null },
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
navActive = s == screen,
pinnedProfileId = libraryPinId,
)
} ?: run { screen = GamepadScreen.Home }
}
@@ -42,7 +42,7 @@ internal fun ConnectPrompts(
optionsTarget: HostCardEntry?,
onDismissOptions: () -> Unit,
libraryEnabled: Boolean,
onOpenLibrary: (KnownHost) -> Unit,
onOpenLibrary: (KnownHost, String?) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
@@ -119,9 +119,11 @@ internal fun ConnectPrompts(
canWake = kh.mac.isNotEmpty() && offline,
onWake = { onDismissOptions(); onWake(kh) },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (libraryEnabled && pin == null) {
{ onDismissOptions(); onOpenLibrary(kh) }
// so a TV remote reaches the library here instead of via the Y face button. A PIN card
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
// start the card, not a property of the host.
onLibrary = if (libraryEnabled) {
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
} else {
null
},
@@ -100,7 +100,9 @@ fun ConnectScreen(
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
gamepadUi: Boolean = false,
onOpenSettings: () -> Unit = {},
onOpenLibrary: (KnownHost) -> Unit = {},
// (host, pinned profile id) — a pinned host+profile card opens ITS shelf, and the id is the
// one-off every launch off that shelf runs with (design §5.2a). Null = the host's own tile.
onOpenLibrary: (KnownHost, String?) -> Unit = { _, _ -> },
navGate: Boolean = true, // false while the console home is cross-fading out
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
@@ -772,7 +774,7 @@ fun ConnectScreen(
awaiting == null && editTarget == null && optionsTarget == null &&
speedTest == null && waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
onOpenSettings = onOpenSettings,
onOptions = { tile ->
tile.knownHost?.let { kh ->
@@ -86,8 +86,10 @@ class HomeTile(
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
/**
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget, library)
* belong to the host's own tile, and this one offers only Unpin.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget) belong
* to the host's own tile, and this one offers only Unpin. The library is NOT one of those
* it is a way to start this card (a connect with a title picked first), so a pinned tile opens
* its own shelf and every launch off it carries this profile.
*/
val pinnedProfileId: String? = null,
/**
@@ -101,9 +103,10 @@ class HomeTile(
val profileAccent: Color? = null,
val activate: () -> Unit,
) {
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
// first" message if the host hasn't authorized this device for its management API.
val hasLibrary: Boolean get() = knownHost != null && pinnedProfileId == null
// Any SAVED host offers the library (matches Apple), pinned cards included — the fetch itself
// returns a clear "pair first" message if the host hasn't authorized this device for its
// management API.
val hasLibrary: Boolean get() = knownHost != null
}
/**
@@ -94,6 +94,13 @@ fun LibraryScreen(
onLaunched: (ActiveSession) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
/**
* The profile this shelf launches with, when it was opened from a PINNED host+profile card
* (design §5.2a) rather than the host's own tile: a one-off, exactly like the card's plain
* connect. Null = the host's tile, and the host's binding decides the same rule
* [ProfileStore.resolveFor] applies to every other connect.
*/
pinnedProfileId: String? = null,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onBack)
@@ -104,6 +111,14 @@ fun LibraryScreen(
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
var launching by remember { mutableStateOf(false) }
// The profile every launch off this shelf runs with, resolved ONCE per shelf by the same rule
// the host-list connect uses: this card's pin as the one-off, else the host's binding, else the
// globals. Resolved here rather than per launch so a profile edited mid-browse cannot make two
// titles on one shelf stream differently.
val profile = remember(host.id, pinnedProfileId) {
ProfileStore(context).resolveFor(host, pinnedProfileId)
}
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
LaunchedEffect(host.address, host.port, host.fpHex) {
state = LibState.Loading
@@ -133,7 +148,16 @@ fun LibraryScreen(
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ConsoleHeader("${host.name} — Library")
// A pinned card's shelf says so, in the card's own `host · profile` shape: what a
// launch here will use is a property of the shelf, not something to remember from
// the tile two screens back.
ConsoleHeader(
if (pinnedProfileId != null && profile != null) {
"${host.name} · ${profile.name} — Library"
} else {
"${host.name} — Library"
},
)
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
when (val s = state) {
is LibState.Loading -> LoadingState()
@@ -145,7 +169,7 @@ fun LibraryScreen(
// Dial the host over the same pinned mTLS trust, booting straight
// into this title (the host resolves `launch` = its library id).
val handle = connectToHost(
context, settings, s.identity,
context, streamSettings, s.identity,
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
@@ -153,11 +177,14 @@ fun LibraryScreen(
onLaunched(
ActiveSession(
handle,
settings,
streamSettings,
host.clipboardSync,
profileName = profile?.name,
hostId = host.id,
// Where to come back to when this game exits.
// Where to come back to when this game exits
// this shelf, pin and all, not the host's default one.
launchedFromLibrary = true,
libraryProfileId = pinnedProfileId,
),
)
}
@@ -71,8 +71,24 @@ data class ActiveSession(
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
*/
val launchedFromLibrary: Boolean = false,
/**
* Which of [hostId]'s shelves that library launch came off: the pinned host+profile card's
* profile id (design §5.2a), or null for the host's own tile. Carried purely so the return
* trip above lands back on the SAME shelf a player who launched from a pinned card is still
* on that card when the game exits, and coming back to the host's default shelf would silently
* change what the next title streams with.
*/
val libraryProfileId: String? = null,
)
/**
* The library shelf a finished game launch should return to: the saved host's id, and the pinned
* profile card it was opened from (null = the host's own tile). One value rather than two parallel
* ones, because a hostId that arrives without its profile is not "the same shelf" it is the
* default one wearing the same name.
*/
data class LibraryReturn(val hostId: String, val profileId: String? = null)
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),
@@ -4,7 +4,6 @@ import androidx.compose.ui.graphics.Color
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -177,9 +176,10 @@ class HomeTilesTest {
assertTrue(it.paired)
assertNotNull(it.knownHost)
}
// Host tile library (Y); pin tile → none, because a pin is a shortcut, not a second host.
// Both tiles reach the library (Y): a pin card opens its OWN shelf, whose launches carry
// the pinned profile — the library is a way to start a card, not a host-level action.
assertTrue(result[0].hasLibrary)
assertFalse(result[1].hasLibrary)
assertTrue(result[1].hasLibrary)
}
/**
@@ -71,7 +71,7 @@ struct ContentView: View {
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
@State private var awaitingApproval: ApprovalRequest?
@State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost?
@State private var libraryTarget: LibraryTarget?
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
/// "Waking" phase of the connect overlay). Available on every platform now that the iOS/tvOS
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
@@ -412,10 +412,10 @@ struct ContentView: View {
// (like the sheets below) so it survives the streaming home transition the disconnect
// drives, and consumed here the model hands the host over once and we clear it, so a
// later manual dismiss of the library can't be undone by a stale value.
.onChange(of: model.returnToLibrary) { _, host in
guard let host else { return }
.onChange(of: model.returnToLibrary) { _, shelf in
guard let shelf else { return }
model.returnToLibrary = nil
libraryTarget = host
libraryTarget = shelf
}
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
@@ -448,9 +448,9 @@ struct ContentView: View {
// (the coverflow is a GeometryReader, ideal zero), so without a frame it collapses to a
// tiny panel.
#if os(macOS)
.sheet(item: $libraryTarget) { host in
.sheet(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.frame(minWidth: 940, minHeight: 620)
}
@@ -461,9 +461,9 @@ struct ContentView: View {
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
// controller arriving or leaving mid-browse hands the open library to whichever
// presentation the new mode owns.
.fullScreenCover(item: touchLibraryTarget) { host in
.fullScreenCover(item: touchLibraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
}
#endif
@@ -573,7 +573,7 @@ struct ContentView: View {
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
/// the library in place (see the cover's comment).
private var touchLibraryTarget: Binding<StoredHost?> {
private var touchLibraryTarget: Binding<LibraryTarget?> {
Binding(
get: { gamepadUIActive ? nil : libraryTarget },
set: { libraryTarget = $0 })
@@ -743,6 +743,25 @@ struct ContentView: View {
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
/// is saved (the notice says what to do instead).
private func openLibrary(from link: DeepLink) {
// A `profile=` on a browse link picks the shelf, exactly as it picks the settings on a
// connect link and refuses the same way (§10.6): an unknown or ambiguous reference must
// never quietly degrade to the host's binding, which is a different shelf wearing the same
// host's name.
var selection = ProfileSelection.inherit
if let reference = link.profile {
let (profile, resolution) = profiles.catalog.resolve(reference)
switch resolution {
case .found:
selection = .profile(profile?.id ?? "")
case .notFound:
deepLinkNotice = "No settings profile called “\(reference)” on this device."
return
case .ambiguous:
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
+ "Rename one, or link to it by its id."
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
@@ -755,7 +774,7 @@ struct ContentView: View {
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
libraryTarget = host
libraryTarget = LibraryTarget(host: host, profile: selection)
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button first — a library can only be browsed on a saved host."
@@ -833,9 +852,9 @@ struct ContentView: View {
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
}
.fullScreenCover(item: $libraryTarget) { host in
.fullScreenCover(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.onExitCommand { libraryTarget = nil }
}
@@ -1234,6 +1253,10 @@ struct ContentView: View {
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
launchID: launchID,
// Where a game exit returns to, when this connect launched a title: the shelf that
// title was picked on the host's own, or the pinned card whose profile this connect
// is using. Ignored by the model unless there is a launchID.
shelf: LibraryTarget(host: host, profile: profile),
allowTofu: allowTofu,
requestAccess: requestAccess,
onUnreachable: onUnreachable)
@@ -1289,9 +1312,13 @@ struct ContentView: View {
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
/// asks the host to launch it.
private func launchTitle(_ host: StoredHost, _ id: String) {
/// A title picked on a library shelf: dial its host, booting straight into that title with
/// the shelf's profile. A pinned card's shelf carries its card's profile as the one-off, so a
/// launch made there streams with the profile the card promises; the host's own shelf carries
/// `.inherit` and the binding decides, exactly as a plain card tap does.
private func launchTitle(_ shelf: LibraryTarget, _ id: String) {
libraryTarget = nil
connect(host, launchID: id)
connect(shelf.host, launchID: id, profile: shelf.profile)
}
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
@@ -75,7 +75,7 @@ struct GamepadHomeView: View {
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
@Binding var libraryTarget: LibraryTarget?
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
@@ -90,7 +90,7 @@ struct GamepadHomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
let launchTitle: (LibraryTarget, String) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
@@ -263,7 +263,7 @@ struct GamepadHomeView: View {
if let host = pairingTarget { return .pair(host) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
if let shelf = libraryTarget { return .library(shelf) }
return nil
}
@@ -289,10 +289,10 @@ struct GamepadHomeView: View {
onPaired: { onPaired(host, $0) },
close: { if !transitioning { pairingTarget = nil } },
controllerActive: active)
case .library(let host):
case .library(let shelf):
GamepadLibraryScreen(
store: store, host: host,
onLaunch: { launchTitle(host, $0) },
store: store, target: shelf,
onLaunch: { launchTitle(shelf, $0) },
close: { if !transitioning { libraryTarget = nil } },
controllerActive: active)
}
@@ -501,9 +501,10 @@ struct GamepadHomeView: View {
isPaired: host.pinnedSHA256 != nil,
isConnecting: connecting,
filled: true,
// A pinned card is a shortcut, not a second host Y (library) stays on the
// host's own tile, where the host-level actions live.
hasLibrary: profile == nil,
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
// this card's connect with a title picked first, not a host-level action like
// wake or forget.
hasLibrary: true,
osChain: host.osChain,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !online && !host.wakeMacs.isEmpty,
@@ -539,12 +540,14 @@ struct GamepadHomeView: View {
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
/// is what its launches run with.
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil,
guard libraryEnabled, case .saved(let id, let profileID) = selection,
let host = store.hosts.first(where: { $0.id == id })
else { return }
libraryTarget = host
libraryTarget = LibraryTarget(host: host, profile: ProfileSelection(profileID: profileID))
}
}
@@ -12,24 +12,26 @@ import SwiftUI
struct GamepadLibraryScreen: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
let host: StoredHost
let target: LibraryTarget
let onLaunch: (String) -> Void
let close: () -> Void
var controllerActive = true
/// `.compact` in a landscape phone window tighter chrome, like every gamepad screen.
@Environment(\.verticalSizeClass) private var vSizeClass
/// Resolves a pinned shelf's profile name for the title.
@ObservedObject private var profiles = ProfileStore.shared
private var compact: Bool { vSizeClass == .compact }
var body: some View {
LibraryView(
store: store, host: host, onLaunch: onLaunch,
store: store, target: target, onLaunch: onLaunch,
onClose: close, controllerActive: controllerActive)
.safeAreaInset(edge: .top, spacing: 0) {
// Leading, like every gamepad heading no close chrome, B is the exit (the
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
Text("\(host.displayName) — Library")
Text("\(target.title(in: profiles)) — Library")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
@@ -22,14 +22,16 @@ enum GamepadScreen: Identifiable {
case settings
case addHost
case pair(StoredHost)
case library(StoredHost)
case library(LibraryTarget)
var id: String {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
case .pair(let host): return "pair-\(host.id.uuidString)"
case .library(let host): return "library-\(host.id.uuidString)"
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
// libraries, and sharing an id would let one stand in for another mid-transition.
case .library(let shelf): return "library-\(shelf.id)"
}
}
@@ -24,7 +24,7 @@ struct HomeView: View {
@Binding var showAddHost: Bool
@Binding var pairingTarget: StoredHost?
@Binding var speedTestTarget: StoredHost?
@Binding var libraryTarget: StoredHost?
@Binding var libraryTarget: LibraryTarget?
#if !os(macOS)
@Binding var showSettings: Bool
#endif
@@ -34,8 +34,9 @@ struct HomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Pairing succeeded (tvOS PairSheet route) pin + connect (ContentView guards staleness).
let onPaired: (StoredHost, Data) -> Void
/// Picked a title in the (experimental) library start a session that launches it.
let onLaunchTitle: (StoredHost, String) -> Void
/// Picked a title in the (experimental) library start a session that launches it, with the
/// shelf's profile (a pinned card's own; the host's binding on its primary card).
let onLaunchTitle: (LibraryTarget, String) -> Void
/// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online
/// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void
@@ -154,8 +155,8 @@ struct HomeView: View {
.navigationDestination(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
.navigationDestination(item: $libraryTarget) { host in
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
.navigationDestination(item: $libraryTarget) { shelf in
LibraryView(store: store, target: shelf, onLaunch: { onLaunchTitle(shelf, $0) })
}
#endif
#if !os(tvOS)
@@ -263,9 +264,13 @@ struct HomeView: View {
}
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
// A pinned card connects with ITS profile; the primary card follows the binding.
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
// and browsing is that same connect with a title picked first, so a pinned card opens its
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
let onBrowseLibrary: (() -> Void)? = libraryEnabled
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
: nil
return HostCardView(
host: host,
isOnline: isOnline(host),
@@ -219,6 +219,13 @@ struct HostCardView: View {
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
// the host's default binding.
connectWithMenu(menu)
// Browsing IS a connect-shaped action it is this card's connect with a title picked
// first so a pinned card offers it and opens its own shelf, whose launches carry the
// pinned profile. (Pair / speed test / wake / forget stay on the host's card: those
// are about the machine, and a shortcut has no business claiming them.)
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(pinned.id) }
}
@@ -6,11 +6,53 @@
import PunktfunkKit
import SwiftUI
/// Which library shelf is open: a host, and when it was opened from a PINNED host+profile card
/// (design/client-settings-profiles.md §5.2a) that card's profile, which every title launched off
/// the shelf then runs with, exactly as the card's own tap would.
///
/// One value rather than a host plus a profile carried beside it: a host and its pinned cards are
/// different cards on the grid, so "which library" is not answered by the host alone. That is also
/// why `id` folds the profile in a presentation keyed on the host would not re-present when you
/// move between a host's own shelf and one of its pins.
struct LibraryTarget: Identifiable, Hashable {
let host: StoredHost
/// `.inherit` from the host's own card (its binding decides, as it always has); `.profile` from
/// a pinned card. `.defaults` never reaches here nothing opens a library "with the globals".
var profile: ProfileSelection = .inherit
var id: String {
switch profile {
case .inherit: host.id.uuidString
case .defaults: "\(host.id.uuidString)#defaults"
case .profile(let id): "\(host.id.uuidString)#\(id)"
}
}
/// The pinned profile's id, if this shelf belongs to a pinned card.
var pinnedProfileID: String? {
if case .profile(let id) = profile { return id }
return nil
}
/// What the screen calls itself: the host, and the profile when a pinned card opened it the
/// same `host · profile` shape that card wears, so which shelf you are on is on screen rather
/// than remembered from the card you pressed. A pin whose profile has since been deleted
/// resolves as no profile everywhere else, and reads as the plain host here.
@MainActor func title(in catalog: ProfileStore) -> String {
guard let id = pinnedProfileID, let profile = catalog.profile(id: id) else {
return host.displayName
}
return "\(host.displayName) \u{b7} \(profile.name)"
}
}
struct LibraryView: View {
@ObservedObject var store: HostStore
let host: StoredHost
/// The shelf being browsed the host, plus the pinned profile when a pinned card opened it.
let target: LibraryTarget
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
/// through). `nil` browse-only (cards aren't tappable).
/// through). `nil` browse-only (cards aren't tappable). The PROFILE a launch runs with is the
/// caller's to apply: it holds `target` and connects with `target.profile`.
var onLaunch: ((String) -> Void)? = nil
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil every sheet/cover
/// presentation falls back to the environment dismiss.
@@ -20,6 +62,12 @@ struct LibraryView: View {
/// default (their being up IS the launcher's gate).
var controllerActive = true
@Environment(\.dismiss) private var dismiss
/// Resolves a pinned shelf's profile NAME for the title (the target carries only its id).
@ObservedObject private var profiles = ProfileStore.shared
/// The host this shelf belongs to every fetch, every poster URL and the launch itself address
/// it, and a pinned shelf is the same host seen through one of its cards.
private var host: StoredHost { target.host }
@State private var games: [GameEntry] = []
@State private var loading = false
@@ -50,7 +98,7 @@ struct LibraryView: View {
var body: some View {
content
.navigationTitle("\(host.displayName) — Library")
.navigationTitle("\(target.title(in: profiles)) — Library")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -70,9 +70,14 @@ final class SessionModel: ObservableObject {
/// session ends depends on where it came FROM: a title launched out of the library belongs back
/// in that library when its game exits, not on the host-selection screen.
private var launchedTitleID: String?
/// Set when a session ended because its game exited and it began as a library launch: the host
/// whose library to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: StoredHost?
/// WHICH library shelf that title was launched from a host's own, or one of its pinned
/// host+profile cards (§5.2a). The host alone would not answer it: a pinned card's shelf
/// launches with that card's profile, so returning to the host's default shelf would quietly
/// change what the next title streams with.
private var launchedShelf: LibraryTarget?
/// Set when a session ended because its game exited and it began as a library launch: the
/// shelf to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: LibraryTarget?
/// The settings THIS session runs on the globals with its profile overlaid, resolved once at
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
/// the readers that live in PunktfunkKit and can't see this model.
@@ -275,6 +280,9 @@ final class SessionModel: ObservableObject {
func connect(to host: StoredHost, effective: EffectiveSettings,
gamepad: PunktfunkConnection.GamepadType = .auto,
launchID: String? = nil,
/// The library shelf `launchID` was picked on, so a game exit can return to it.
/// Only meaningful alongside a `launchID`; nil for a plain desktop connect.
shelf: LibraryTarget? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
requestAccess: Bool = false,
@@ -283,6 +291,7 @@ final class SessionModel: ObservableObject {
phase = .connecting
activeHost = host
launchedTitleID = launchID
launchedShelf = shelf
errorMessage = nil
settings = effective
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
@@ -663,6 +672,7 @@ final class SessionModel: ObservableObject {
activeHost = nil
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
launchedTitleID = nil
launchedShelf = nil
phase = .idle
fps = 0
mbps = 0
@@ -692,13 +702,16 @@ final class SessionModel: ObservableObject {
// a plain desktop session has no library to return to.
let host = activeHost
let cameFromLibrary = launchedTitleID != nil
// The shelf it came off falling back to the host's own if a caller launched a title
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
switch reason {
case .gameExited:
// The player quit their own game. Not a failure, and they are probably after the next
// title so no banner, and back to the library it came from.
if cameFromLibrary, let host {
returnToLibrary = host
if cameFromLibrary, host != nil, let shelf {
returnToLibrary = shelf
}
case .hostEnded, .local:
// Someone asked for this: an operator "End" on the host, or our own close racing in.
@@ -229,7 +229,7 @@ public struct EffectiveSettings: Equatable, Sendable {
/// through to the binding. Collapsing the two would make the menu item that says "Default
/// settings" silently connect with the host's profile. It is the same distinction the session
/// binary's `--profile ""` reserves on the desktop clients.
public enum ProfileSelection: Equatable, Sendable {
public enum ProfileSelection: Hashable, Sendable {
/// No pick the host's default binding applies (a plain click/tap).
case inherit
/// Force the global defaults for this one connect, whatever the host is bound to.
+7
View File
@@ -466,6 +466,13 @@ impl relm4::factory::FactoryComponent for HostCard {
// offering them here would blur what the card is.
let launch = gio::Menu::new();
launch.append(Some("Connect"), Some("card.connect"));
// …and the same stream with a title picked first. The library is a way to
// START this card, not a property of the host, so it belongs to a shortcut
// as much as Connect does — and the card's request carries its profile, so
// what launches from that grid is this card's profile, not the binding.
if *library_enabled {
launch.append(Some("Browse library\u{2026}"), Some("card.library"));
}
menu.append_section(None, &launch);
let links = gio::Menu::new();
+21 -1
View File
@@ -44,6 +44,26 @@ struct State {
mock: Cell<bool>,
}
/// What the page calls the host it is browsing. A request that carries a one-off profile
/// came from a PINNED card (design §5.2a), and every title launched off this grid inherits
/// it — so the page names it, the same `host · profile` shape the card wears. A plain card
/// says nothing extra: its binding is the host's own default, not a second thing to read.
/// A one-off whose profile has since been deleted resolves as no profile everywhere else,
/// and reads as a plain host here.
fn page_host_label(req: &ConnectRequest) -> String {
let Some(id) = req.profile.as_deref().filter(|id| !id.is_empty()) else {
return req.name.clone();
};
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.find(|p| p.id == id)
.map_or_else(
|| req.name.clone(),
|p| format!("{} \u{b7} {}", req.name, p.name),
)
}
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
pub fn open(
@@ -194,7 +214,7 @@ fn build(
toolbar.set_content(Some(&stack));
let page = adw::NavigationPage::builder()
.title(format!("{} — Library", req.name))
.title(format!("{} — Library", page_host_label(&req)))
.child(&toolbar)
.build();
+69 -4
View File
@@ -913,9 +913,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
// …then this host's pinned host+profile tiles, in the order they were pinned
// (design §5.2a). They share the host's live status because they read the same
// record, and a pin whose profile is gone simply doesn't render. No menu of their
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
// on the primary tile's menu — the one place you decide it.
// record, and a pin whose profile is gone simply doesn't render. Their menu is
// deliberately short: a pinned tile is a shortcut, not a second host, so it carries
// only what STARTS it (the library — this tile's connect with a title picked first,
// which is why the grid it opens launches with the tile's profile), the link that
// reproduces it, and the way to remove it. Everything that configures the machine —
// pair, speed test, wake, edit, forget, and pinning itself — stays on the primary
// tile's menu, the one place you decide it.
for id in &k.pinned_profiles {
let Some((id, name, accent)) = profiles.iter().find(|(pid, ..)| pid == id) else {
continue;
@@ -923,6 +927,67 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
let mut pinned_target = pinned_base.clone();
pinned_target.profile = Some(id.clone());
let pinned_menu = {
let (svc, target) = (props.svc.clone(), pinned_target.clone());
let (fp, pin_id) = (k.fp_hex.clone(), id.clone());
let (hosts_rev, set_hosts_rev) = (props.hosts_rev, props.set_hosts_rev.clone());
let link_host = k.clone();
let link_profile = id.clone();
let unpin_label = format!("{MENU_UNPIN}{name}");
let unpin_item = unpin_label.clone();
button("")
.icon(Symbol::More)
.subtle()
.tooltip("More options")
.automation_name("More options")
.menu_flyout({
let mut items = Vec::new();
// Same gate as the primary tile's: the mgmt API needs the paired
// identity, and the page is behind the experimental toggle.
if library_enabled && k.paired {
items.push(menu_item(MENU_LIBRARY));
}
items.push(menu_item(MENU_COPY_LINK));
items.push(menu_separator());
items.push(menu_item(unpin_label));
items
})
.on_item_clicked(move |item: String| match item.as_str() {
MENU_LIBRARY => {
// The shared target IS what the library page launches through, so
// parking THIS tile's target here is what makes its grid launch
// with the pinned profile.
*svc.ctx.shared.target.lock().unwrap() = target.clone();
super::library::start_fetch(&svc.ctx, &svc.set_library);
svc.set_screen.call(Screen::Library);
}
MENU_COPY_LINK => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&link_host,
None,
Some(link_profile.as_str()),
)
.to_url();
pf_client_core::clipboard::set_text(&url);
}
other if other == unpin_item => {
tracing::info!(pin = %pin_id, host = %fp, on = false, "pin toggle");
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.pinned_profiles.retain(|x| x != &pin_id);
if let Err(e) = known.save() {
tracing::warn!(
error = %format!("{e:#}"), "saving a pin"
);
}
}
// Same reason as the primary tile's toggle: nothing the page reads
// as state changed, so the bump is what makes this tile vanish NOW.
set_hosts_rev.call(hosts_rev + 1);
}
_ => {}
})
};
tiles.push(host_tile(
// Its own hover key: two tiles for one host must not light up together.
&format!("{}#{id}", k.fp_hex),
@@ -935,7 +1000,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
(!k.paired).then_some(("Trusted", Pill::Info)),
Some((name.as_str(), accent.clone())),
),
None,
Some(pinned_menu),
Some(Box::new(move || {
if can_wake {
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
+15
View File
@@ -43,6 +43,21 @@ pub trait Capturer: Send {
self.next_frame()
}
/// [`next_frame_within`](Self::next_frame_within), but the caller declares the budget
/// PROVISIONAL: its expiry is the retry schedule firing (the deliberately truncated first
/// attempt), not a verdict on anything this capture offered. The portal backend must NOT
/// latch its sticky process-wide downgrades (HDR capture, either dmabuf-only offer) from a
/// provisional expiry — a gamescope cold start routinely outlives the short window while it
/// would have accepted every offer, and one latched race used to pin the whole host process
/// to SDR/CPU capture. The full-length attempt that follows delivers the honest verdict.
/// Backends that latch nothing from a timeout just delegate.
fn next_frame_within_provisional(
&mut self,
budget: std::time::Duration,
) -> Result<CapturedFrame> {
self.next_frame_within(budget)
}
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
/// just produces a frame each call — fine for instant synthetic sources; the portal
+283 -72
View File
@@ -1,4 +1,4 @@
//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`), CPU-copy path.
//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`).
//!
//! Two dedicated threads, because both stacks are tied to their thread:
//! * **portal thread** drives the async ashpd handshake on a multi-thread tokio runtime
@@ -7,9 +7,13 @@
//! drops; ashpd's `Session` has no `Drop`);
//! * **pipewire thread** owns the (`!Send`) MainLoop/Stream and pumps frames.
//!
//! The portal hands the PipeWire remote fd + node id to the pipewire thread; decoded BGRx
//! frames leave the pipewire thread over a bounded channel. The authoritative frame size
//! comes from the negotiated PipeWire format, not the portal's size hint.
//! The portal hands the PipeWire remote fd + node id to the pipewire thread; frames leave that
//! thread through a ONE-DEEP OVERWRITING slot (`FrameSlot`) plus a wakeup edge — not the bounded
//! `sync_channel(8)` this once used, which was drop-NEWEST and so handed a stalled consumer stale
//! frames (see `FrameSlot`'s own note). The payload is not necessarily BGRx either: the negotiation
//! can settle on packed RGB, NV12, YUV444 or 10-bit PQ, and on a dmabuf passthrough it never touches
//! the CPU. The authoritative frame size comes from the negotiated PipeWire format, not the portal's
//! size hint.
//!
//! Cleanup: BOTH threads are stopped deterministically — [`PortalCapturer`]'s `Drop` sends a
//! pipewire `channel` quit and joins that thread (releasing its EGL importer / CUDA context
@@ -18,7 +22,9 @@
//! connection and so ENDS the compositor's ScreenCast session. Dropping a capturer (session end,
//! or a retried/failed pipeline build) therefore leaves nothing behind on either side.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
// Every `unsafe` block in this module TREE carries a `// SAFETY:` proof; enforce it (unsafe-proof
// program). This file itself has none — the FFI lives in the child modules declared at the bottom
// (`pipewire`, `pw_cursor`, `pw_pods`, `portal`, `xfixes_cursor`), which this inner attribute covers.
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
@@ -173,8 +179,9 @@ pub struct PortalCapturer {
/// capture, not per frame.
negotiation_confirmed: bool,
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the SDR
/// downgrade for THIS [`Self::hdr_source`] only, not process-wide) and by
/// [`hdr_meta`](Capturer::hdr_meta).
hdr_offer: bool,
/// Which HDR source this capturer is — the latch a failed [`hdr_offer`](Self::hdr_offer)
/// belongs to. See [`super::HdrSource`] for why the latch is not one process-wide flag.
@@ -463,7 +470,10 @@ fn spawn_pipewire(
let zerocopy = allow_zerocopy && pf_zerocopy::enabled();
// HDR cannot ride the SHM path (see `want_hdr` above): under PUNKTFUNK_FORCE_SHM the HDR
// offer is dropped — SDR capture, loudly.
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
// The shared parser, not a bare `== "1"` compare — matching `PUNKTFUNK_PIPEWIRE_NV12` below.
// A bare compare silently ignored `PUNKTFUNK_FORCE_SHM=true`/`=on`/`=yes`, so the knob looked
// set and did nothing.
let force_shm = pf_host_config::env_on("PUNKTFUNK_FORCE_SHM").unwrap_or(false);
let want_hdr = if want_hdr && force_shm {
tracing::warn!(
"HDR capture requested but PUNKTFUNK_FORCE_SHM=1 — the SHM path is 8-bit only; \
@@ -533,7 +543,7 @@ fn spawn_pipewire(
impl Capturer for PortalCapturer {
fn next_frame(&mut self) -> Result<CapturedFrame> {
self.frame_within(Duration::from_secs(10))
self.frame_within(Duration::from_secs(10), TimeoutVerdict::Conclusive)
}
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
@@ -555,6 +565,14 @@ impl Capturer for PortalCapturer {
// every nested Xwayland the provider reports, RE-RUNS the provider so a game's Xwayland
// that appears later is adopted, and follows whichever one gamescope draws the pointer on.
// `frame_size` lets it map root-space coordinates into frame space.
//
// Idempotent by construction. The contract says "called once", but nothing enforced it, and a
// second call evaluated `spawn` BEFORE dropping the old source: two readers then published
// into the same slot for the construction window, and a `spawn` that returned `None` destroyed
// a perfectly good reader outright.
if self._gs_cursor.is_some() {
return;
}
self._gs_cursor = xfixes_cursor::XFixesCursorSource::spawn(
targets,
Arc::clone(&self.signals.cursor_live),
@@ -563,7 +581,13 @@ impl Capturer for PortalCapturer {
}
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
self.frame_within(budget)
self.frame_within(budget, TimeoutVerdict::Conclusive)
}
fn next_frame_within_provisional(&mut self, budget: Duration) -> Result<CapturedFrame> {
// The retry loop's truncated first attempt: its expiry re-runs the schedule, it does not
// convict an offer — see `TimeoutVerdict` and the latch arms in `next_frame_timed_out`.
self.frame_within(budget, TimeoutVerdict::Provisional)
}
fn supports_arrival_wait(&self) -> bool {
@@ -655,6 +679,11 @@ impl Capturer for PortalCapturer {
if let Ok(mut slot) = self.slot.lock() {
*slot = None;
}
// Clear the stall clock for the same reason the mailbox is flushed: a pooled capturer
// whose previous stream ended mid-stall carried that `Instant` into the next one, so the
// first `try_latest` that saw `!streaming` found the 1500 ms grace already expired and
// reported capture loss on a stream that had been running for microseconds.
self.stall_since = None;
}
}
@@ -699,12 +728,73 @@ impl Capturer for PortalCapturer {
}
}
/// Whether an expired first-frame budget is allowed to CONVICT an offer. The retry loop's
/// deliberately truncated first attempt passes `Provisional`: its expiry means the schedule
/// moved on, not that the compositor refused anything — a gamescope cold start regularly needs
/// longer than that window to accept every offer it would have accepted. Latching from it pinned
/// the whole host process to SDR + CPU capture off a race the attempt lost by design; only a
/// full-length wait carries a verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeoutVerdict {
Conclusive,
Provisional,
}
/// Which offer a first-frame timeout implicates — the diagnosis behind
/// [`PortalCapturer::next_frame_timed_out`], split out pure so the latch policy is testable.
/// Mirrors the negotiation state exactly: a negotiated format clears every offer (the compositor
/// accepted, it just produced nothing), and a forced `PUNKTFUNK_ZEROCOPY=1` keeps both dmabuf
/// arms erroring loudly instead of implicating them (the operator asked for exactly that path).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeoutOffer {
/// Format negotiated; no offer implicated — the compositor produced no buffers.
NoBuffers,
/// The 10-bit PQ/BT.2020 (HDR) dmabuf offer was never accepted.
Hdr,
/// The dmabuf-only raw-passthrough offer was never accepted.
RawDmabuf,
/// The dmabuf-only EGL→CUDA offer was never accepted.
GpuDmabuf,
/// Nothing negotiated and no offer implicated — format/modifier mismatch.
NoFormat,
}
fn classify_first_frame_timeout(
negotiated: bool,
hdr_offer: bool,
vaapi_dmabuf: bool,
gpu_dmabuf_offer: bool,
zerocopy_forced: bool,
) -> TimeoutOffer {
if negotiated {
TimeoutOffer::NoBuffers
} else if hdr_offer {
TimeoutOffer::Hdr
} else if vaapi_dmabuf && !zerocopy_forced {
TimeoutOffer::RawDmabuf
} else if gpu_dmabuf_offer && !zerocopy_forced {
TimeoutOffer::GpuDmabuf
} else {
TimeoutOffer::NoFormat
}
}
/// The latch policy: only a conclusive expiry of an offer-implicating timeout fires the offer's
/// sticky process-wide downgrade.
fn timeout_convicts(offer: TimeoutOffer, verdict: TimeoutVerdict) -> bool {
verdict == TimeoutVerdict::Conclusive
&& matches!(
offer,
TimeoutOffer::Hdr | TimeoutOffer::RawDmabuf | TimeoutOffer::GpuDmabuf
)
}
impl PortalCapturer {
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
fn frame_within(&mut self, budget: Duration, verdict: TimeoutVerdict) -> Result<CapturedFrame> {
let deadline = std::time::Instant::now() + budget;
loop {
if self.signals.broken.load(Ordering::Relaxed) {
@@ -730,7 +820,7 @@ impl PortalCapturer {
if let Some(f) = self.take_frame() {
return Ok(f);
}
return self.next_frame_timed_out(e, budget);
return self.next_frame_timed_out(e, budget, verdict);
}
}
}
@@ -752,83 +842,118 @@ impl PortalCapturer {
}
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
/// into the diagnosis-bearing error, and fire the offer's sticky downgrade latch when — and
/// only when — the expiry convicts the offer (see [`timeout_convicts`]).
fn next_frame_timed_out(
&self,
err: RecvTimeoutError,
budget: Duration,
verdict: TimeoutVerdict,
) -> Result<CapturedFrame> {
let within = budget.as_secs_f32();
match err {
RecvTimeoutError::Timeout => {
// Split the two black-screen root causes apart so the operator gets a cause, not
// just a symptom: did the format negotiate (compositor produced no buffers) or
// not (no acceptable format / node never emitted a param)?
if self.signals.negotiated.load(Ordering::Relaxed) {
Err(anyhow!(
let offer = classify_first_frame_timeout(
self.signals.negotiated.load(Ordering::Relaxed),
self.hdr_offer,
self.vaapi_dmabuf,
self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed),
pf_zerocopy::zerocopy_forced(),
);
let convicted = timeout_convicts(offer, verdict);
// A provisional expiry names the same suspect but hands down no sentence — the
// full-length retry that follows is the one whose timeout latches.
let sentence = if convicted {
"" // each arm below states its own downgrade
} else {
" (short first-attempt window — nothing is latched; the full-length retry \
decides)"
};
match offer {
TimeoutOffer::NoBuffers => Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiated but no \
buffers arrived the compositor produced no frames (virtual output \
idle/unmapped, capture never started, or a stream bound during a \
compositor (re)start that will never deliver a reconnect fixes that)",
self.node_id
))
} else if self.hdr_offer {
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
// mode between the probe and the negotiation, the compositor pre-dates the
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the process-wide SDR downgrade so the next session (Moonlight
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
super::note_hdr_capture_failed(self.hdr_source);
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
reconnect to stream SDR",
self.node_id
))
} else if self.vaapi_dmabuf && !pf_zerocopy::zerocopy_forced() {
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
// enough to do that.
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (raw-dmabuf passthrough) downgrading \
THIS path to CPU capture for the rest of the process; the pipeline \
rebuild will renegotiate without dmabuf",
self.node_id
))
} else if self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed)
&& !pf_zerocopy::zerocopy_forced()
{
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One timeout is conclusive:
// a compositor that allocates none of the importer's modifiers refuses them
// identically on every retry, so latch the offer off and let the pipeline
// rebuild renegotiate the CPU path instead of re-running this same 10 s
// timeout on every reconnect. A forced PUNKTFUNK_ZEROCOPY=1 keeps erroring
// loudly instead (same rule as the raw arm).
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (EGLCUDA GPU import) downgrading THIS \
offer to the CPU path for the rest of the process; the pipeline rebuild \
will renegotiate without dmabuf",
self.node_id
))
} else {
Err(anyhow!(
)),
TimeoutOffer::Hdr => {
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
// mode between the probe and the negotiation, the compositor pre-dates the
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the SDR downgrade for THIS source (`HdrSource`, not process-wide — one
// shared flag let either Linux HDR source disable the other) so the next session
// (Moonlight auto-reconnects) negotiates SDR instead of re-running this timeout.
if convicted {
super::note_hdr_capture_failed(self.hdr_source);
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \
monitor in HDR mode on GNOME 50+?{}",
self.node_id,
if convicted {
" Downgrading this host to SDR capture; reconnect to stream SDR"
} else {
sentence
}
))
}
TimeoutOffer::RawDmabuf => {
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
// enough to do that.
if convicted {
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (raw-dmabuf passthrough){}",
self.node_id,
if convicted {
" — downgrading THIS path to CPU capture for the rest of the \
process; the pipeline rebuild will renegotiate without dmabuf"
} else {
sentence
}
))
}
TimeoutOffer::GpuDmabuf => {
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One FULL-LENGTH timeout
// is conclusive: a compositor that allocates none of the importer's
// modifiers refuses them identically on every retry, so latch the offer off
// and let the pipeline rebuild renegotiate the CPU path instead of
// re-running this same 10 s timeout on every reconnect. A forced
// PUNKTFUNK_ZEROCOPY=1 keeps erroring loudly instead (same rule as the raw
// arm).
if convicted {
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (EGLCUDA GPU import){}",
self.node_id,
if convicted {
" — downgrading THIS offer to the CPU path for the rest of the \
process; the pipeline rebuild will renegotiate without dmabuf"
} else {
sentence
}
))
}
TimeoutOffer::NoFormat => Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiation never \
completed the compositor offered no format this consumer accepts \
(pixel-format/modifier mismatch) or the node never emitted a Format param",
self.node_id
))
)),
}
}
RecvTimeoutError::Disconnected => Err(anyhow!(
@@ -874,3 +999,89 @@ mod pipewire;
// unit-test without a compositor, which is the point.
mod pw_cursor;
mod pw_pods;
#[cfg(test)]
mod first_frame_timeout_tests {
use super::{classify_first_frame_timeout, timeout_convicts, TimeoutOffer, TimeoutVerdict};
#[test]
fn a_provisional_expiry_convicts_no_offer_whatever_was_on_the_table() {
// The bug this pins down: the retry loop's truncated 2.5 s first attempt latched all
// three sticky process-wide downgrades as if the compositor had refused the offers — a
// gamescope HDR cold start then streamed SDR (and CPU-copied) for the process lifetime.
for offer in [
TimeoutOffer::NoBuffers,
TimeoutOffer::Hdr,
TimeoutOffer::RawDmabuf,
TimeoutOffer::GpuDmabuf,
TimeoutOffer::NoFormat,
] {
assert!(
!timeout_convicts(offer, TimeoutVerdict::Provisional),
"provisional expiry must not latch {offer:?}"
);
}
}
#[test]
fn a_conclusive_expiry_convicts_exactly_the_offer_bearing_diagnoses() {
assert!(timeout_convicts(
TimeoutOffer::Hdr,
TimeoutVerdict::Conclusive
));
assert!(timeout_convicts(
TimeoutOffer::RawDmabuf,
TimeoutVerdict::Conclusive
));
assert!(timeout_convicts(
TimeoutOffer::GpuDmabuf,
TimeoutVerdict::Conclusive
));
// A negotiated-but-idle stream and a plain format mismatch implicate no offer — nothing
// to latch even on a full-length wait.
assert!(!timeout_convicts(
TimeoutOffer::NoBuffers,
TimeoutVerdict::Conclusive
));
assert!(!timeout_convicts(
TimeoutOffer::NoFormat,
TimeoutVerdict::Conclusive
));
}
#[test]
fn classification_mirrors_the_negotiation_state_precedence() {
// A negotiated format clears every offer, whatever else was on the table.
assert_eq!(
classify_first_frame_timeout(true, true, true, true, false),
TimeoutOffer::NoBuffers
);
// The HDR offer outranks the dmabuf arms (it is the offer that failed to negotiate).
assert_eq!(
classify_first_frame_timeout(false, true, true, true, false),
TimeoutOffer::Hdr
);
assert_eq!(
classify_first_frame_timeout(false, false, true, true, false),
TimeoutOffer::RawDmabuf
);
assert_eq!(
classify_first_frame_timeout(false, false, false, true, false),
TimeoutOffer::GpuDmabuf
);
assert_eq!(
classify_first_frame_timeout(false, false, false, false, false),
TimeoutOffer::NoFormat
);
}
#[test]
fn a_forced_zerocopy_keeps_both_dmabuf_arms_erroring_loudly_instead_of_implicated() {
// PUNKTFUNK_ZEROCOPY=1 is the operator insisting on the path — the timeout falls through
// to the generic diagnosis (and so never latches), exactly as the old else-if chain did.
assert_eq!(
classify_first_frame_timeout(false, false, true, true, true),
TimeoutOffer::NoFormat
);
}
}
+14 -1
View File
@@ -1506,7 +1506,20 @@ pub fn pipewire_thread(
{
return;
}
if ud.info.parse(param).is_ok() {
// Parse ONCE — `parse` takes `&mut self` — and report a failure instead of swallowing it.
// On `Err`, `negotiated` stays false and `format`/`modifier`/`frame_size` keep their
// previous values, so the capture dies on the generic "the compositor offered no format
// this consumer accepts" timeout — sending the operator hunting a format mismatch when
// the real fault was a malformed Format pod we DID accept.
let parsed = ud.info.parse(param);
if let Err(e) = &parsed {
tracing::error!(
error = %e,
"pipewire: failed to parse the negotiated Format pod — capture will time out \
with no usable format"
);
}
if parsed.is_ok() {
ud.signals.negotiated.store(true, Ordering::Relaxed);
// A (re)negotiation replaces the buffer pool: every cached per-buffer import
// (stored fds in the worker, the Vulkan bridge's per-fd sources) keys on
+11 -1
View File
@@ -197,6 +197,15 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
if bw == 0 || bh == 0 || bw > 1024 || bh > 1024 {
return;
}
// SPA's second "no image data" signal, distinct from the `bitmap_offset == 0` position-only
// case above: `spa_meta_bitmap.offset` is the offset of the PIXELS within the bitmap struct,
// and 0 means there are none. Without this, `pix_off == 0` made the pixel extent start at the
// `spa_meta_bitmap` header itself, so a producer signalling an invisible pointer got its own
// header words (format/size/stride/offset) decoded and cached as the cursor bitmap. In bounds,
// so not unsound — just garbage pixels blitted into every later frame.
if pix_off == 0 {
return;
}
let row = bw as usize * 4;
let stride = if stride < row { row } else { stride };
let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
@@ -327,7 +336,8 @@ pub(super) fn composite_cursor_rgb10(
}
/// Alpha-blend the cached cursor bitmap into the tightly-packed CPU frame at its latched
/// position. Cheap: a straight-alpha blit over at most ~256×256 pixels, clipped to the frame —
/// position. Cheap: a straight-alpha blit over at most 1024×1024 pixels (the accepted cap; real
/// cursors are ≤96 px), clipped to the frame —
/// the whole point of cursor-as-metadata (no forced full-frame composite on the producer).
pub(super) fn composite_cursor(
tight: &mut [u8],
+2 -1
View File
@@ -377,7 +377,8 @@ pub(super) fn build_dmabuf_buffers() -> Result<Vec<u8>> {
/// Request the compositor attach `SPA_META_Cursor` to each buffer, so the pointer travels as
/// metadata (position + an occasional bitmap) instead of being burned into the frame. Paired
/// with the portal's `CursorMode::Metadata`; producers that don't support it simply don't
/// attach it (harmless). Size is a range up to a 256×256 bitmap — bigger than any real cursor.
/// attach it (harmless). Size is a range up to a 1024×1024 bitmap — see the note on `max` below for
/// why this is not the "bigger than any real cursor" 256² it used to be.
pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
fn meta_size(w: u32, h: u32) -> i32 {
(std::mem::size_of::<spa::sys::spa_meta_cursor>()
+38 -35
View File
@@ -55,16 +55,6 @@ use x11rb::rust_connection::{DefaultStream, RustConnection};
use crate::GamescopeCursorTargets;
/// Serializes the `XAUTHORITY` env swap of the LEGACY connect fallback (the var is process-global).
///
/// The fallback is a last resort now — see [`connect_conn`]. It serialises this source against
/// itself and nothing else: `getenv` needs no lock to be racy, so every OTHER thread's read (libspa
/// plugin load, EGL/CUDA init — concurrent by construction, since `attach_gamescope_cursor` runs
/// while the PipeWire thread is starting) could still observe the swapped value or a torn
/// environ. That is why the primary path parses the cookie itself and never touches the
/// environment.
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
/// The `MIT-MAGIC-COOKIE-1` auth-protocol name, as it appears in an `.Xauthority` entry.
const MIT_MAGIC_COOKIE_1: &[u8] = b"MIT-MAGIC-COOKIE-1";
@@ -267,17 +257,18 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
/// environment.
///
/// `RustConnection::connect` reads `XAUTHORITY` from the env, so the original implementation
/// `set_var`'d it around each connect under [`XAUTH_LOCK`]. That is unsound from a live
/// multithreaded host: the lock serialises this source against itself, but `getenv` takes no lock,
/// so any concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment,
/// since the PipeWire thread is starting up) could read the swapped value or race the environ
/// rewrite outright. The project already has a process-wide env-lock discipline elsewhere, but
/// sharing it would be the wrong layer AND would still not fix `getenv`.
/// `set_var`'d it around each connect under a mutex. That is unsound from a live multithreaded
/// host: the lock serialised this source against itself, but `getenv` takes no lock, so any
/// concurrent reader (libspa's plugin load, EGL/CUDA init — running at exactly this moment, since
/// the PipeWire thread is starting up) could read the swapped value or race the environ rewrite
/// outright. The project already has a process-wide env-lock discipline elsewhere, but sharing it
/// would be the wrong layer AND would still not fix `getenv`.
///
/// So: parse the MIT-MAGIC-COOKIE-1 entry out of the file ourselves and hand it to
/// `connect_to_stream_with_auth_info`, which is what `RustConnection::connect` does internally with
/// the cookie IT found. The env swap survives only as a fallback for a file we cannot parse (an
/// unexpected layout, or an auth family whose entry we decline to guess at).
/// the cookie IT found. Where that finds nothing usable we connect with an explicitly empty token
/// ([`connect_unauthenticated`]) rather than swapping the environment — this process no longer
/// writes `environ` at all.
fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, usize), String> {
let Some(path) = xauthority else {
// No per-display cookie file to inject: the ambient environment is already what this
@@ -289,16 +280,16 @@ fn connect_conn(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection,
Ok(v) => return Ok(v),
Err(e) => tracing::debug!(
dpy = %dpy, xauthority = %path, error = %e,
"gamescope cursor: cookie connect failed — falling back to the XAUTHORITY env swap"
"gamescope cursor: cookie connect failed — retrying unauthenticated"
),
},
None => tracing::debug!(
dpy = %dpy, xauthority = %path,
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — falling back to the \
XAUTHORITY env swap"
"gamescope cursor: no MIT-MAGIC-COOKIE-1 entry for this display — connecting \
unauthenticated"
),
}
connect_via_env_swap(dpy, path)
connect_unauthenticated(dpy)
}
/// Connect to `dpy` and complete the setup handshake with an explicit cookie — the same two steps
@@ -331,19 +322,31 @@ fn connect_with_cookie(
.map_err(|e| format!("setup: {e}"))
}
/// LEGACY fallback (see [`connect_conn`]): swap `XAUTHORITY`, connect, restore. Serialised against
/// this source's own concurrent connects, but NOT against other threads' `getenv` — which is why it
/// is a fallback and not the path taken.
fn connect_via_env_swap(dpy: &str, xauthority: &str) -> Result<(RustConnection, usize), String> {
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("XAUTHORITY");
std::env::set_var("XAUTHORITY", xauthority);
let out = RustConnection::connect(Some(dpy));
match prev {
Some(p) => std::env::set_var("XAUTHORITY", p),
None => std::env::remove_var("XAUTHORITY"),
}
out.map_err(|e| format!("connect: {e}"))
/// Last-resort fallback (see [`connect_conn`]): connect with an EXPLICITLY EMPTY auth token.
///
/// This replaces a `set_var("XAUTHORITY", …)` / connect / restore dance, which was unsound and is
/// not fixable in place. `setenv`/`unsetenv` rewrite the process-global `environ`; glibc
/// *reallocates* that array when a variable is added, and the host is emphatically multithreaded
/// at this moment — `attach_gamescope_cursor` runs while the PipeWire thread is inside `pw_init`'s
/// `dlopen` and a dozen bare `getenv()` calls, with EGL/CUDA init alongside. A mutex here
/// serialised this source against itself and against nothing else, because `getenv` takes no lock.
/// The damaging branch is the one where `XAUTHORITY` is ABSENT and therefore gets *added* — which
/// `scripts/punktfunk-host.service` makes the normal configuration, since the unit deliberately
/// does not import the login shell's environment. And `rediscover` re-runs this every 2 s for the
/// whole session, because a display whose connect fails is never recorded and so is never skipped.
///
/// Connecting with an empty token is what the swap actually achieved. We only reach here when our
/// own lookup found no usable `MIT-MAGIC-COOKIE-1` entry, and x11rb's internal lookup reads the
/// same file with a STRICTER matcher (it also matches family/address, which we deliberately do
/// not) — so where we find nothing, it finds nothing too, and connects unauthenticated. That is
/// precisely why the swap "worked" against a nested Xwayland started without `-auth`.
///
/// The one case this gives up is an `.Xauthority` whose entry uses an auth family we decline to
/// guess at but x11rb would have handled. A gamescope Xwayland writes a single-entry
/// MIT-MAGIC-COOKIE-1 file, so that case is not reachable here — and a cursor overlay that
/// declines to attach is the correct outcome anyway, against a torn `environ` in a live session.
fn connect_unauthenticated(dpy: &str) -> Result<(RustConnection, usize), String> {
connect_with_cookie(dpy, Vec::new(), Vec::new())
}
/// The `MIT-MAGIC-COOKIE-1` `(name, data)` for `dpy` from the `.Xauthority`-format file at `path`.
+5 -3
View File
@@ -554,9 +554,11 @@ impl HdrP010Converter {
let mut ps_uv = None;
device.CreatePixelShader(&uvb, None, Some(&mut ps_uv))?;
let sd = D3D11_SAMPLER_DESC {
// POINT: the Y pass samples a single texel centre exactly, and the UV pass does its OWN
// 2x2 box average via 4 explicit taps at texel centres (offset half a texel). Point
// sampling keeps each tap exact; the averaging is in the shader, not the sampler.
// POINT: the Y pass samples a single texel centre exactly, and the UV pass takes its OWN
// two explicit taps on the 2x2 block's LEFT column (left-cositing) and averages them.
// Point sampling keeps each tap exact; the averaging is in the shader, not the sampler.
// (It was a 4-tap CENTER-sited 2x2 box until that was found to shift chroma by half a
// luma pixel — see `HDR_P010_UV_PS`.)
Filter: D3D11_FILTER_MIN_MAG_MIP_POINT,
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
+17 -9
View File
@@ -337,6 +337,7 @@ use channel::ChannelBroker;
use descriptor::{DescriptorPoller, DisplayDescriptor};
use stall::{StallEvidence, StallWatch};
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
pub struct IddPushCapturer {
device: ID3D11Device,
context: ID3D11DeviceContext,
@@ -652,14 +653,18 @@ impl IddPushCapturer {
}
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven by the DISPLAY's HDR
/// state (like the WGC path) plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
/// state plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
/// 10-bit limited) → NVENC Main10, and the client auto-detects PQ from the HEVC VUI; SDR →
/// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
/// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
/// SM-side CSC, because the video processor can only produce subsampled output). We do NOT
/// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the
/// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 +
/// auto-switch, exactly as on the WGC path. HDR and 4:4:4 now COMPOSE: an HDR display that
/// SM-side CSC, because the video processor can only produce subsampled output). The
/// composition depth DOES follow the session's negotiated `client_10bit` — pinned at open
/// (`open.rs`, the `!client_10bit` force-off and the 10-bit enable) and re-pinned every sample
/// by [`Self::poll_display_hdr`], because a PQ stream sent to a client that advertised SDR-only
/// lands on an SDR desktop and blows out. (The older note here claimed the opposite — that the
/// advertised `VIDEO_CAP_10BIT` was ignored because clients under-report it. That reasoning
/// survives only in the CODEC choice: an HDR-negotiated H.26x session still follows a host
/// "Use HDR" flip in either direction.) HDR and 4:4:4 now COMPOSE: an HDR display that
/// negotiated full chroma emits packed 10-bit BT.2020 PQ RGB (`Rgb10a2`) for NVENC to CSC to
/// YUV 4:4:4 — HEVC Main 4:4:4 10. (Before, HDR won and the stream silently downgraded to
/// 4:2:0 *after* the Welcome had already promised 4:4:4.)
@@ -969,7 +974,7 @@ impl IddPushCapturer {
},
Usage: D3D11_USAGE_DEFAULT,
// RENDER_TARGET: the VIDEO processor (NV12) and the P010 shader passes both write here, and
// NVENC registers it as encode input — matching the WGC YUV ring. (PyroWave uses its own
// NVENC registers it as encode input. (PyroWave uses its own
// shareable two-plane `pyro_ring` instead, so this NVENC/AMF/QSV ring stays unshared.)
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
CPUAccessFlags: 0,
@@ -1970,9 +1975,12 @@ impl Capturer for IddPushCapturer {
fn pipeline_depth(&self) -> usize {
// 2 = one frame deferred: submit N+1 (capture + convert/copy into a fresh out-ring texture) while
// NVENC encodes N on the ASIC. We hand a rotating `OUT_RING` of output textures, so this is safe.
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining; clamp to ≤ OUT_RING so a frame in flight
// always has its own texture).
pf_host_config::config().idd_depth.clamp(1, OUT_RING)
// `PUNKTFUNK_IDD_DEPTH` overrides (1 disables pipelining). The ceiling is `OUT_RING - 1`, NOT
// `OUT_RING`: `d` frames in flight need `d + 1` textures, because the rotation has to hand out a
// slot that is not one of the `d` still being encoded. Clamping to `OUT_RING` admitted depth 3 on
// a 3-slot ring, where `repeat_last`'s rotation lands back on the slot NVENC is reading and the
// convert overwrites it in place — torn frames, silently, with no error anywhere.
pf_host_config::config().idd_depth.clamp(1, OUT_RING - 1)
}
fn capture_target_id(&self) -> Option<u32> {
@@ -160,7 +160,18 @@ impl ChannelBroker {
event: HANDLE,
slots: &[HostSlot],
) -> Result<()> {
debug_assert!(slots.len() <= control::RING_LEN_USIZE);
// An ERROR, not a `debug_assert`: in a release build the assert is compiled out and the
// over-long slice instead panics on `req.texture_handles[k]` in the middle of
// `duplicate_and_deliver` — after handles have already been planted in WUDFHost. That panic
// unwinds straight past the reap below, leaking every duplicate made so far into the driver
// process. Refuse before the first duplication, while there is nothing to reap.
if slots.len() > control::RING_LEN_USIZE {
anyhow::bail!(
"frame channel: {} ring slots exceeds the wire limit of {}",
slots.len(),
control::RING_LEN_USIZE
);
}
let mut req = control::SetFrameChannelRequest {
target_id,
generation,
@@ -42,7 +42,9 @@ impl CursorShared {
/// the section itself (owned by `self`); the caller duplicates it into the WUDFHost.
pub(super) fn create(target_id: u32) -> Result<CursorShared> {
// SAFETY: plain FFI. Unnamed pagefile-backed section, host-lifetime owned; the view is
// mapped once and unmapped never (the capturer's life = the session's life).
// mapped once here and unmapped exactly once by `MappedSection::drop` (which unmaps before
// closing the mapping handle). No borrow into the view outlives the `MappedSection`: every
// access goes through `&self` accessors on the owner.
let section = unsafe {
let map = CreateFileMappingW(
INVALID_HANDLE_VALUE,
@@ -55,8 +55,10 @@ struct Shape {
serial: u64,
}
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` at ~60 Hz, rasterises the `HCURSOR` only
/// when its handle value changes, and publishes a ready [`pf_frame::CursorOverlay`] snapshot; the
/// Off-thread GDI cursor poller. Samples `GetCursorInfo` every [`Self::INTERVAL`] (4 ms, ~250 Hz —
/// see that constant for why 16 ms was the bug), rasterises the `HCURSOR` when its handle value
/// changes and when [`Self::EXTENT_PROBE`] catches a resize under a STABLE handle, and publishes a
/// ready [`pf_frame::CursorOverlay`] snapshot; the
/// capture thread's per-tick cost is one uncontended mutex read + an `Arc` clone
/// (same split as [`DescriptorPoller`], and for the same reason: user32/gdi32 calls have no place
/// on the capture/encode thread).
@@ -186,7 +188,6 @@ fn run(
// against, and this poller outlives all of them. `None` keeps the last good value — a
// transient CCD failure must not park the pointer at a `(0, 0, 0, 0)` rect, which would
// report every position invisible.
//
let fresh = pf_win_display::win_display::source_desktop_rect(target_id);
if let Some(fresh) = fresh {
if fresh != rect {
@@ -302,7 +303,14 @@ fn run(
serial: s.serial,
hot_x: s.hot_x,
hot_y: s.hot_y,
visible: showing && in_rect,
// `handle != 0` is part of "visible", not just of "worth rasterising": `SetCursor(NULL)`
// — how a game or a video player hides the pointer for its own window — leaves
// `CURSOR_SHOWING` set with a NULL `hCursor`. Judging on the flags alone published
// `visible: true` carrying the last shape we rasterised, so the composite path blended a
// ghost arrow into a game that had hidden its cursor, and the forward path told the
// client to draw one too. Every rasterise gate below already tests this; the published
// verdict has to agree with them.
visible: showing && in_rect && handle != 0,
}
});
*slot.lock().unwrap_or_else(|p| p.into_inner()) = overlay;
@@ -6,7 +6,6 @@
use super::*;
/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`].
/// The display descriptor the capture loop follows: live HDR state + active resolution of the
/// virtual target.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -142,7 +142,12 @@ unsafe extern "system" fn on_event(record: *mut EVENT_RECORD) {
(*record).EventHeader.ProcessId,
)
};
let mut ring = RING.lock().unwrap();
// Poison-tolerant, and that is load-bearing rather than tidy: this is an `extern "system"`
// callback invoked from an OS thread, so a panic here unwinds across an FFI boundary and
// ABORTS the host process. `unwrap()` made a single poisoned lock turn every subsequent event
// delivery into a hard abort — a diagnostic taking down capture. Nothing else under this lock
// can panic, so recovering the guard also makes the poison unreachable in the first place.
let mut ring = RING.lock().unwrap_or_else(|e| e.into_inner());
if ring.len() == RING_CAP {
ring.pop_front();
}
@@ -152,8 +152,11 @@ impl IddPushCapturer {
}
/// Open the IDD-push capturer. On success the caller's `keepalive` is attached (the capturer owns the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller can fall back to DDA
/// instead of tearing the display down (audit §5.1 — no more 20 s black bail). "Failure" includes the
/// virtual display); on FAILURE the keepalive is handed BACK so the caller decides the display's fate
/// itself — retire it, or reuse the monitor for a retry — instead of this function tearing it down
/// (audit §5.1 — no more 20 s black bail). There is no second capture path to fall back TO: DDA was
/// removed (see `lib.rs`), and `punktfunk-host`'s caller drops the returned keepalive under
/// `.context("IDD-push capture open (no fallback)")`. "Failure" includes the
/// driver not attaching to the ring within a few seconds (e.g. a hybrid-GPU render mismatch).
#[allow(clippy::too_many_arguments)]
pub fn open(
@@ -666,7 +669,7 @@ impl IddPushCapturer {
// wait for the first compose) until the capturer drops with the session.
_display_wake: pf_frame::session_tuning::DisplayWakeRequest::new(),
// Placeholder; `open()` attaches the real keepalive on success, so a FAILED open can hand
// it back to the caller for the DDA fallback (audit §5.1).
// it back to the caller to retire or reuse the display (audit §5.1).
_keepalive: Box::new(()),
};
// The HDR SDR-white reference for the composited cursor, queried ONCE here rather than
@@ -675,15 +678,15 @@ impl IddPushCapturer {
me.refresh_sdr_white_scale();
// Bounded wait for the driver to ATTACH to the ring AND publish a first frame. An attach
// failure (DRV_STATUS_TEX_FAIL) or an attach-but-no-frames (a game left the display in a
// format/size the ring can't match) becomes an open failure the caller falls back from (→ DDA),
// instead of next_frame's 20 s black-then-bail.
// format/size the ring can't match) becomes an open failure the caller handles by retiring the
// display, instead of next_frame's 20 s black-then-bail.
me.wait_for_attach()?;
Ok(me)
}
}
/// Block (bounded) until the driver has ATTACHED to the host ring (`DRV_STATUS_OPENED`) **and published
/// a first frame**, else fail so the caller can fall back to DDA (audit §5.1 +
/// a first frame**, else fail so the caller can retire the display and rebuild (audit §5.1 +
/// `design/windows-host-rewrite.md` §2.5 — the GB1 game-capture fix).
///
/// Requiring the first frame — not just the attach — catches the *reconnect-into-a-broken-state* case:
@@ -53,7 +53,9 @@ use super::stall::ProbeWindow;
/// One probe's sample ring: `(completed_at, span, value_us)` — `value` is the measurement (a call
/// latency or a frozen-span/overshoot), `span` the wall interval it describes ending at
/// `completed_at`. Capped; ~20 Hz per probe → several minutes of coverage.
/// `completed_at`. Capped at 512 samples: at the fastest producer's ~20 Hz that is ~26 s of
/// coverage, ~51 s for the 100 ms loops — comfortably longer than the seconds-old windows a stall
/// report asks for, but NOT the "several minutes" this used to claim.
struct Ring {
samples: Mutex<VecDeque<(Instant, Duration, u64)>>,
}
@@ -317,7 +317,8 @@ impl StallWatch {
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
const RECENT: usize = 8;
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
/// The RECENT pre-gap frames must all fit in this span (8 frames spanning 400 ms is 7 intervals,
/// so the real bar is ≈ ≥ 17.5 fps flow —
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
+1 -1
View File
@@ -209,7 +209,7 @@ impl Screen {
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
match self {
Screen::Home(_) => "Select a Host".into(),
Screen::Library(s) => s.host_name().to_string(),
Screen::Library(s) => s.title(),
Screen::Settings(_) => "Settings".into(),
Screen::AddHost(s) => s.title(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
+25 -6
View File
@@ -10,7 +10,7 @@ use crate::library::{
StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, POSTER_H, POSTER_W,
RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE,
};
use crate::model::{ConsoleCmd, HostRow};
use crate::model::{ConsoleCmd, HostRow, ProfileChip};
use crate::pointer::{Pointer, PointerKind};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{accent, fg, Fonts, W};
@@ -24,6 +24,11 @@ pub(crate) struct LibraryScreen {
port: u16,
fp_hex: String,
mgmt: u16,
/// `Some` when this library was opened from a PINNED host+profile card (§5.2a) rather
/// than the host's primary tile: every launch off this shelf is that card's connect
/// with a title attached, so it carries the same one-off profile the card's plain
/// A-press would. `None` = the primary tile, where the host's binding decides.
pin: Option<ProfileChip>,
shared: Option<LibraryShared>,
// Synced snapshot of the shared model (re-pulled when the generation bumps).
generation: u64,
@@ -48,6 +53,7 @@ impl LibraryScreen {
port: host.port,
fp_hex: host.fp_hex.clone(),
mgmt: host.mgmt_port,
pin: host.pin.clone(),
shared: None, // adopted from Ctx on the first render (the shell owns it)
generation: u64::MAX,
phase: LibraryPhase::Loading,
@@ -60,8 +66,13 @@ impl LibraryScreen {
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
/// The screen's title: the host, and — when this shelf belongs to a pinned card — the
/// profile every launch off it will use, in the card's own `host · profile` shape.
pub(crate) fn title(&self) -> String {
match &self.pin {
Some(p) => format!("{} \u{b7} {}", self.host_name, p.name),
None => self.host_name.clone(),
}
}
fn fetch_cmd(&self) -> ConsoleCmd {
@@ -123,10 +134,18 @@ impl LibraryScreen {
port: self.port,
fp_hex: self.fp_hex.clone(),
launch: Some(g.id.clone()),
title: g.title.clone(),
// A pinned card's shelf says which profile it is launching with,
// the same way its tile and this screen's title do.
title: match &self.pin {
Some(p) => format!("{} \u{b7} {}", g.title, p.name),
None => g.title.clone(),
},
request_access: false,
// Game launches follow the host's default binding.
profile: None,
// A game launch off a PINNED card's shelf is that card's connect
// with a title attached — it carries the card's profile as the
// one-off. Off the primary tile there is none, and the host's
// default binding decides.
profile: self.pin.as_ref().map(|p| p.id.clone()),
});
Some(MenuPulse::Confirm)
}
+81
View File
@@ -180,6 +180,87 @@ fn finish_motion(s: &mut Shell) {
s.motion = Motion::None;
}
/// A pinned host+profile card's library launches with THAT profile (design §5.2a).
///
/// The card's plain A-press always carried its profile; Y — which the card offers, being
/// paired and saved — opened a library screen that knew only the host, so every title
/// launched off it silently fell back to the host's default binding. The profile a user
/// pinned is the whole reason they pressed that card.
#[test]
fn a_pinned_cards_library_launches_with_its_profile() {
let mut rows = hosts();
let card = HostRow {
key: "aa11\u{0}hdr".into(),
pin: Some(crate::model::ProfileChip {
id: "hdr".into(),
name: "HDR".into(),
accent: None,
}),
..rows[0].clone()
};
rows.insert(1, card);
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
console.set_hosts(rows);
s.sync();
// Focus the pinned card (it sits right after its host's primary tile), then Y.
s.handle_menu(MenuEvent::Move(MenuDir::Right));
s.handle_menu(MenuEvent::Secondary);
finish_motion(&mut s);
match s.stack.last() {
Some(Screen::Library(l)) => assert_eq!(
l.title(),
"Living Room PC \u{b7} HDR",
"the shelf names the profile it will launch with"
),
_ => panic!("Y on a pinned card opens its library"),
}
library.set_games(vec![crate::library::LibraryGame {
id: "steam:570".into(),
title: "Dota 2".into(),
store: "steam".into(),
launcher: false,
icon: String::new(),
}]);
s.handle_menu(MenuEvent::Confirm);
match s.take_action() {
Some(OverlayAction::Launch {
launch, profile, ..
}) => {
assert_eq!(launch.as_deref(), Some("steam:570"));
assert_eq!(
profile.as_deref(),
Some("hdr"),
"the launch carries the pinned card's profile"
);
}
_ => panic!("A on a title raises a launch"),
}
}
/// …and off the host's PRIMARY tile there is no one-off: the host's binding decides,
/// which is what the resolver sees as `None`.
#[test]
fn a_primary_tiles_library_leaves_the_profile_to_the_binding() {
let (mut s, _console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
s.handle_menu(MenuEvent::Secondary); // paired+online host focused first
finish_motion(&mut s);
library.set_games(vec![crate::library::LibraryGame {
id: "steam:570".into(),
title: "Dota 2".into(),
store: "steam".into(),
launcher: false,
icon: String::new(),
}]);
s.handle_menu(MenuEvent::Confirm);
assert!(matches!(
s.take_action(),
Some(OverlayAction::Launch { profile: None, .. })
));
}
#[test]
fn wake_gates_input_in_the_same_press() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
+7 -1
View File
@@ -1712,7 +1712,13 @@ mod tests {
let mut legacy = [0u8; 40];
legacy[..control::ADD_REQUEST_LEGACY_SIZE]
.copy_from_slice(&bytes[..control::ADD_REQUEST_LEGACY_SIZE]);
let old = *bytemuck::from_bytes::<control::AddRequest>(&legacy);
// `pod_read_unaligned`, NOT `from_bytes` — same rule as `ChannelProof::parse` above, and
// for the same reason. `legacy` is a `[u8; 40]` (align 1) but `AddRequest` opens with a
// `u64`, so it is align 8; `from_bytes` takes a REFERENCE into the buffer and panics
// unless the buffer happens to be 8-aligned. A stack `[u8; 40]` usually is, which is why
// this passed everywhere for so long — Miri caught it because Miri does not let an
// accidentally-favourable stack slot stand in for a guarantee.
let old = bytemuck::pod_read_unaligned::<control::AddRequest>(&legacy);
assert_eq!(old.preferred_monitor_id, 7);
assert_eq!(
(
+5 -5
View File
@@ -17,7 +17,7 @@
// child-module shape. External imports are this file's own; `vk_util` is a crate-root sibling,
// so the path is `crate::`, not the parent-relative `super::` the parent uses.
use super::*;
use crate::vk_util::{find_mem, make_plain_image, make_view};
use crate::vk_util::{ext_advertised, find_mem, make_plain_image, make_view};
use anyhow::{bail, Result};
use ash::vk;
use std::ffi::c_void;
@@ -53,10 +53,10 @@ pub(super) unsafe fn probe_rgb_direct(
let Ok(exts) = instance.enumerate_device_extension_properties(pd) else {
return Err("probe-failed(ext-enum)");
};
if !exts
.iter()
.any(|e| std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == vrgb::EXTENSION_NAME)
{
// Route through `vk_util::ext_advertised` rather than open-coding the walk a second time:
// this copy used the same unbounded `CStr::from_ptr` and had the same read-past-the-array
// hazard on a driver that fills all VK_MAX_EXTENSION_NAME_SIZE bytes without a NUL.
if !ext_advertised(&exts, vrgb::EXTENSION_NAME) {
return Err("no-ext(mesa<26.0-or-no-efc)");
}
// 2. Feature bit.
+32 -5
View File
@@ -19,11 +19,15 @@ use pf_frame::PixelFormat;
/// barriers were used without the extension ever being enabled; `pf-presenter/dmabuf.rs` is the
/// in-repo precedent that enables it).
pub(super) fn ext_advertised(exts: &[vk::ExtensionProperties], name: &std::ffi::CStr) -> bool {
exts.iter().any(|e| {
// SAFETY: `extension_name` is a spec-guaranteed NUL-terminated UTF-8 byte array inside
// the driver-filled `VkExtensionProperties` (VK_MAX_EXTENSION_NAME_SIZE bound).
unsafe { std::ffi::CStr::from_ptr(e.extension_name.as_ptr()) == name }
})
// `extension_name_as_c_str()` is ash's BOUNDED accessor: it stops at
// `VK_MAX_EXTENSION_NAME_SIZE` and returns `Err` when the array holds no NUL, so a
// malformed driver entry is a non-match rather than a read past the array. The previous
// `CStr::from_ptr(e.extension_name.as_ptr())` had no in-Rust bound at all — its SAFETY
// comment asserted the spec guarantee instead of enforcing it, so a driver that filled all
// 256 bytes without a terminator ran the walk into the NEXT `ExtensionProperties` and, on
// the last element, past the allocation. Same accessor `pyrowave.rs` already uses for the
// identical job. No unsafe, no unchecked read, same answer on every well-formed driver.
exts.iter().any(|e| e.extension_name_as_c_str() == Ok(name))
}
pub(crate) fn color_range(layer: u32) -> vk::ImageSubresourceRange {
@@ -453,6 +457,29 @@ mod tests {
));
}
/// A driver entry with NO terminator anywhere in `extension_name` must be a non-match, not a
/// read past the array.
///
/// This is the case the old `CStr::from_ptr(e.extension_name.as_ptr())` could not survive:
/// with every one of VK_MAX_EXTENSION_NAME_SIZE bytes non-NUL it walked into the NEXT
/// `ExtensionProperties`, and on the LAST element past the allocation entirely. The old test
/// only ever built well-formed, NUL-terminated entries, so it proved nothing about the bound
/// — which is why the hazard survived a SAFETY comment that asserted the spec guarantee
/// rather than enforcing it.
#[test]
fn ext_advertised_rejects_unterminated_name_without_overrunning() {
let mut bad = ash::vk::ExtensionProperties::default();
bad.extension_name.fill(b'A' as std::ffi::c_char);
// Deliberately LAST, so an unbounded walk would leave the whole array.
let exts = [ash::vk::ExtensionProperties::default(), bad];
assert!(!super::ext_advertised(
&exts,
ash::ext::queue_family_foreign::NAME
));
// And a name that is a prefix of the garbage still must not match.
assert!(!super::ext_advertised(&exts, c"AAAA"));
}
use super::*;
/// CSC mode (`bgra_target = false`): the 3→4 expand is a pure byte shuffle — no channel
+10 -26
View File
@@ -2239,38 +2239,22 @@ impl Encoder for AmfEncoder {
mod tests {
use super::*;
/// The mirrored `AMFVariantStruct` must match the C layout: 4-byte tag + 4 padding + 16-byte
/// union = 24 bytes, align 8, payload at offset 8 (it is passed BY VALUE across the FFI).
// The LAYOUT of `AmfVariant`, `AmfGuid` and `AmfHdrMetadata` is no longer asserted here.
// Those checks moved to `const _: ()` assertions beside the mirrors themselves in
// `amf_sys.rs`, together with per-slot offset guards for the five vtables. As `#[test]`s
// they only ran when someone ran pf-encode's tests, on Windows, with AMF enabled — never in
// a release build, which is precisely where a mis-mirrored `AMFVariantStruct` would do its
// damage. As const assertions they hold on EVERY build that compiles the module.
//
// What stays here is the part a layout check cannot express: that the little-endian packing
// of the union payload matches what the C side will read out of those bytes.
#[test]
fn variant_layout_matches_c() {
assert_eq!(std::mem::size_of::<AmfVariant>(), 24);
assert_eq!(std::mem::align_of::<AmfVariant>(), 8);
assert_eq!(std::mem::offset_of!(AmfVariant, payload), 8);
fn variant_payload_packing_matches_c() {
let v = AmfVariant::from_rate(60, 1);
assert_eq!(v.payload[0], 60u64 | (1u64 << 32));
assert_eq!(AmfVariant::from_i64(-1).payload[0], u64::MAX);
}
/// `AMFGuid` is the flattened Win32-GUID layout (16 bytes).
#[test]
fn guid_layout_matches_c() {
assert_eq!(std::mem::size_of::<sys::AmfGuid>(), 16);
}
/// `AMFHDRMetadata` (components/ColorSpace.h): 8×u16 + 2×u32 + 2×u16 = 28 bytes, no padding.
#[test]
fn hdr_metadata_layout_matches_c() {
assert_eq!(std::mem::size_of::<sys::AmfHdrMetadata>(), 28);
assert_eq!(
std::mem::offset_of!(sys::AmfHdrMetadata, max_mastering_luminance),
16
);
assert_eq!(
std::mem::offset_of!(sys::AmfHdrMetadata, max_content_light_level),
24
);
}
/// A representative HDR10 grade for the live tests (BT.2020 primaries, 1000-nit mastering)
/// in [`HdrMeta`]'s ST.2086 wire units/order (primaries G, B, R).
fn sample_hdr_meta() -> punktfunk_core::quic::HdrMeta {
+120
View File
@@ -409,6 +409,126 @@ pub struct AmfBufferVtbl {
pub remove_observer_buffer: Slot,
}
// -- Layout guards ---------------------------------------------------------------------------
//
// THE CONTRACT, STATED ONCE. Everything above is a hand-written mirror of a C type this crate
// does not own and cannot include. Two classes of drift are possible and NEITHER fails to
// compile on its own:
//
// 1. A POD passed by value (`AmfVariant`, `AmfGuid`, `AmfHdrMetadata`) whose field offsets
// disagree with the C struct. The runtime then reads a tag or a payload out of the wrong
// bytes — `AmfVariant` crosses the FFI by value on EVERY `SetProperty`.
// 2. A vtable slot inserted, removed or reordered. `amf.rs` dispatches BY POSITION through
// these mirrors, so a shifted slot calls an arbitrary function pointer through a
// mismatched signature. There is no compile error, no runtime signal, and the failure is
// whatever the neighbouring AMF entry point happens to do with our arguments.
//
// `AMF_MIN_VERSION` does not defend against either: it checks a version NUMBER, not a layout,
// and it is a floor with no ceiling. The assertions below are the actual defence. They are
// `const _: ()` rather than `#[cfg(test)]` deliberately — the three POD checks below used to
// live only in `amf.rs`'s test module, which means they were verified only when someone ran
// pf-encode's tests, on Windows, with AMF enabled, and NEVER in a release build. This is the
// same hole `a8dd348b` closed for the cuda.h mirrors; it was missed here.
//
// Every slot index below was counted against the vtable declarations above. A slot is asserted
// when `amf.rs` calls it — those are the ones whose displacement is directly exploitable — plus
// the total size of each table, which catches an insertion PAST the last called slot (invisible
// to a per-slot check, but still a sign the mirror has drifted from the header).
/// One vtable slot. Every mirrored table is a flat array of these, so an offset in bytes is
/// always `index * SLOT`.
const SLOT: usize = core::mem::size_of::<Slot>();
/// Byte offset of vtable slot `i`. A `const fn` rather than a bare `i * SLOT` expression because
/// clippy's `erasing_op`/`identity_op` reject `0 * SLOT` and `1 * SLOT` under the `-D warnings`
/// the Windows CI leg runs with — and writing those two as bare `0` and `SLOT` would be the one
/// place the slot INDEX stops being visible, which is the entire readability of these assertions.
const fn slot(i: usize) -> usize {
i * SLOT
}
// Every slot is a plain code pointer, so all five tables are pointer-sized-array-shaped. If this
// ever fails, the tables are not flat arrays any more and every offset below is meaningless.
const _: () = assert!(SLOT == core::mem::size_of::<usize>());
const _: () = assert!(core::mem::align_of::<Slot>() == core::mem::align_of::<usize>());
// -- PODs crossing the FFI by value --
// `AMFVariantStruct`: 4-byte tag + 4 padding + 16-byte union = 24 bytes, payload at 8.
const _: () = assert!(core::mem::size_of::<AmfVariant>() == 24);
const _: () = assert!(core::mem::align_of::<AmfVariant>() == 8);
const _: () = assert!(core::mem::offset_of!(AmfVariant, payload) == 8);
// `AMFGuid`: the flattened Win32 GUID.
const _: () = assert!(core::mem::size_of::<AmfGuid>() == 16);
const _: () = assert!(core::mem::align_of::<AmfGuid>() == 4);
// `AMFHDRMetadata` (components/ColorSpace.h): 8×u16 + 2×u32 + 2×u16 = 28 bytes, no padding.
const _: () = assert!(core::mem::size_of::<AmfHdrMetadata>() == 28);
const _: () = assert!(core::mem::offset_of!(AmfHdrMetadata, max_mastering_luminance) == 16);
const _: () = assert!(core::mem::offset_of!(AmfHdrMetadata, max_content_light_level) == 24);
// -- AMFFactory (7 slots) — `create_context` 0, `create_component` 1 --
const _: () = assert!(core::mem::size_of::<AmfFactoryVtbl>() == slot(7));
const _: () = assert!(core::mem::offset_of!(AmfFactoryVtbl, create_context) == slot(0));
const _: () = assert!(core::mem::offset_of!(AmfFactoryVtbl, create_component) == slot(1));
// -- AMFContext (55 slots) = AMFInterface(3) + AMFPropertyStorage(10) + AMFContext(42) --
const _: () = assert!(core::mem::size_of::<AmfContextVtbl>() == slot(55));
const _: () = assert!(core::mem::offset_of!(AmfContextVtbl, release) == slot(1));
const _: () = assert!(core::mem::offset_of!(AmfContextVtbl, terminate) == slot(13));
const _: () = assert!(core::mem::offset_of!(AmfContextVtbl, init_dx11) == slot(18));
const _: () = assert!(core::mem::offset_of!(AmfContextVtbl, alloc_buffer) == slot(43));
const _: () =
assert!(core::mem::offset_of!(AmfContextVtbl, create_surface_from_dx11_native) == slot(49));
// -- AMFComponent (28 slots) = AMFInterface(3) + PropertyStorage(10) + StorageEx(4) + Component(11) --
const _: () = assert!(core::mem::size_of::<AmfComponentVtbl>() == slot(28));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, release) == slot(1));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, set_property) == slot(3));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, init) == slot(17));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, terminate) == slot(19));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, drain) == slot(20));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, flush) == slot(21));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, submit_input) == slot(22));
const _: () = assert!(core::mem::offset_of!(AmfComponentVtbl, query_output) == slot(23));
// -- AMFData (23 slots) = AMFInterface(3) + AMFPropertyStorage(10) + AMFData(10) --
const _: () = assert!(core::mem::size_of::<AmfDataVtbl>() == slot(23));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, release) == slot(1));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, query_interface) == slot(2));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, set_property) == slot(3));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, get_property) == slot(4));
const _: () = assert!(core::mem::offset_of!(AmfDataVtbl, set_pts) == slot(19));
// -- AMFBuffer (28 slots) = the AMFData prefix (23) + AMFBuffer(5) --
const _: () = assert!(core::mem::size_of::<AmfBufferVtbl>() == slot(28));
const _: () = assert!(core::mem::offset_of!(AmfBufferVtbl, release) == slot(1));
const _: () = assert!(core::mem::offset_of!(AmfBufferVtbl, get_size) == slot(24));
const _: () = assert!(core::mem::offset_of!(AmfBufferVtbl, get_native) == slot(25));
// -- The shared-prefix agreement --
// `AMFBuffer` derives from `AMFData`, and `create_surface_from_dx11_native` hands back an
// `AMFSurface*` that this module drives through the `AmfData` mirror on the strength of that
// single-inheritance prefix (see the comment on that slot). If the two mirrors ever disagree
// about where a shared slot lives, that reinterpretation is silently wrong — so assert the
// agreement rather than restating it in prose.
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, release) == core::mem::offset_of!(AmfBufferVtbl, release)
);
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, set_property)
== core::mem::offset_of!(AmfBufferVtbl, set_property)
);
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, get_property)
== core::mem::offset_of!(AmfBufferVtbl, get_property)
);
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, set_pts) == core::mem::offset_of!(AmfBufferVtbl, set_pts)
);
const _: () = assert!(
core::mem::offset_of!(AmfDataVtbl, get_duration)
== core::mem::offset_of!(AmfBufferVtbl, get_duration)
);
// -- DLL entry points (core/Factory.h; AMF_CDECL_CALL) --------------------------------------
pub type AmfQueryVersionFn = unsafe extern "C" fn(*mut u64) -> AmfResult;
pub type AmfInitFn = unsafe extern "C" fn(u64, *mut *mut AmfFactory) -> AmfResult;
+10
View File
@@ -144,6 +144,13 @@ pub struct HostConfig {
/// text ("Living Room PC"); the DNS-level `<label>.local.` target keeps using a sanitized
/// machine-safe label, so a spacey display name can't produce an invalid mDNS record.
pub host_name: Option<String>,
/// `PUNKTFUNK_GAMESTREAM` — enable the GameStream/Moonlight-compat planes (nvhttp pairing,
/// RTSP, ENet control, `_nvstream` mDNS) from `host.env`, equivalent to the `--gamestream`
/// CLI flag (either source turns it on). **Default OFF** — the secure native-only host: the
/// compat planes carry plain-HTTP pairing + the legacy GCM-nonce path (security-review
/// #5/#9), so stock-Moonlight support is opt-in on every route, and the packaged units ship
/// without the flag so this knob is how a package user opts in.
pub gamestream: bool,
/// `PUNKTFUNK_ENCODER` — explicit encoder-backend override (lowercased; empty = auto-detect by GPU vendor).
pub encoder_pref: String,
/// `PUNKTFUNK_RENDER_ADAPTER` — discrete render-GPU pin by description substring (`Some` even when empty:
@@ -356,6 +363,9 @@ impl HostConfig {
host_name: val("PUNKTFUNK_HOST_NAME")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// Default OFF, explicit-on grammar: the Moonlight-compat planes are opt-in
// everywhere (see the field doc); `--gamestream` on the CLI also turns them on.
gamestream: env_on("PUNKTFUNK_GAMESTREAM").unwrap_or(false),
encoder_pref: std::env::var("PUNKTFUNK_ENCODER")
.unwrap_or_default()
.to_ascii_lowercase(),
+6
View File
@@ -14,6 +14,12 @@
//! and per-platform; it lives with the product that does it (`punktfunk-host::update`,
//! `pf-client-core::update`, and the root helper in `pf-update`).
// This crate parses a SIGNED, NETWORK-FETCHED manifest and, per the header above, "owns the part
// where being wrong is a security bug". Signature verification is worthless if the parser around
// it can be made to read out of bounds, so the absence of unsafe here is a security property and
// is now enforced rather than merely true today.
#![forbid(unsafe_code)]
/// The Ed25519 public keys trusted for update manifests — two slots, so a key rotation is
/// "sign with the new one, ship builds trusting both, retire the old" (the plugin-store
/// `OFFICIAL_KEYS` drill) rather than a flag day. The private half is the
+21 -2
View File
@@ -25,6 +25,12 @@
//! (root-written, world-readable) for the unprivileged caller to read; stdout/stderr land in
//! the unit's journal.
// ROOT RUNS THIS. `deny` rather than `forbid` only because of the single `geteuid` call in
// `linux_main::effective_uid`, which carries the one localized `#[allow(unsafe_code)]` in the
// crate and explains there why it is not worth a dependency to remove. Any NEW unsafe anywhere
// in this helper is a build error.
#![deny(unsafe_code)]
#[cfg(target_os = "linux")]
mod linux_main {
use serde::Serialize;
@@ -313,8 +319,7 @@ mod linux_main {
};
// Effective root is required for every leg; refuse early with a clear message
// rather than half-running.
// SAFETY: geteuid has no preconditions.
if unsafe { libc_geteuid() } != 0 {
if effective_uid() != 0 {
eprintln!("pf-update: must run as root (start punktfunk-update.service)");
std::process::exit(1);
}
@@ -397,6 +402,20 @@ mod linux_main {
#[link_name = "geteuid"]
fn libc_geteuid() -> u32;
}
/// The crate's ONLY unsafe operation, isolated so the crate-level `deny(unsafe_code)` can
/// stand and the exemption is one named function rather than a whole call site.
///
/// Deliberately NOT rewritten to `rustix::process::geteuid()`: this crate's Cargo.toml states
/// that the zero-dependency posture *is* a security invariant of a root helper ("no HTTP
/// client, no TLS, no argument parsing"), so pulling in a general-purpose syscall crate to
/// delete one `unsafe` would trade a real property for a cosmetic one.
#[allow(unsafe_code)]
fn effective_uid() -> u32 {
// SAFETY: `geteuid` is a POSIX syscall wrapper that takes no arguments, reads no memory
// through a pointer, cannot fail, and has no preconditions whatsoever.
unsafe { libc_geteuid() }
}
}
#[cfg(target_os = "linux")]
+7
View File
@@ -78,6 +78,13 @@
//! a `VASurfaceID` rather than an index — so the conversion will take that table as
//! a parameter and stay pure.
// The header above states the crate's whole design constraint: it is the CPU-testable half, it
// links no libva, and it compiles on macOS — "which is the point". That constraint is exactly
// what `forbid(unsafe_code)` encodes. The crate is full of hand-declared libva `repr(C)` mirrors,
// and the moment one of them gets dereferenced through a raw pointer here, the crate has quietly
// become the other half and stops being testable off a Linux box with a GPU.
#![forbid(unsafe_code)]
pub mod config;
pub mod drm;
pub mod pic;
+14 -5
View File
@@ -16,13 +16,9 @@ publish = false
[dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
pf-frame = { path = "../pf-frame" }
pf-gpu = { path = "../pf-gpu" }
pf-host-config = { path = "../pf-host-config" }
pf-paths = { path = "../pf-paths" }
pf-win-display = { path = "../pf-win-display" }
# The Windows admission gate consults NVENC's session budget (can_open_another_session).
pf-encode = { path = "../pf-encode" }
anyhow = "1"
tracing = "0.1"
# The platform-neutral policy/identity/custom-preset state is serde-serialized (persisted + the mgmt
@@ -41,8 +37,12 @@ hex = "0.4"
# the shipped host's dependency closure through this crate is unchanged.
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[target.'cfg(target_os = "linux")'.dependencies]
# `proc`'s process-group tree guard is Unix-wide, not Linux-only: the module is compiled on every
# platform and its tests run on whatever the developer is sitting at (macOS, here).
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
# The Mutter backend drives D-Bus RemoteDesktop + ScreenCast.RecordVirtual via ashpd on a tokio
# runtime; the gamescope restore worker + portal handshakes use tokio too.
ashpd = { version = "0.13", features = ["screencast", "remote_desktop"] }
@@ -61,6 +61,15 @@ bitflags = "2"
x11rb = { version = "0.13", default-features = false }
[target.'cfg(target_os = "windows")'.dependencies]
# Windows-only, all three, and gated here rather than unconditionally so the LINUX build does not
# drag their closures in for nothing: `pf-frame` for the DXGI capture identity + the CTA-861.3 HDR
# luminance fields, `pf-gpu` for the render-adapter LUID, and `pf-encode` for the admission gate's
# NVENC session budget (`can_open_another_session`, admission.rs, itself `#[cfg(windows)]`). Every
# use site of all three is Windows-gated — verified by grep — and between them they pull FFmpeg,
# ash and openh264, none of which a Linux host reaches through this crate.
pf-frame = { path = "../pf-frame" }
pf-gpu = { path = "../pf-gpu" }
pf-encode = { path = "../pf-encode" }
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
pf-driver-proto = { path = "../pf-driver-proto" }
bytemuck = { version = "1.19", features = ["derive"] }
+173 -22
View File
@@ -8,17 +8,36 @@
//! * **KWin** — privileged `zkde_screencast_unstable_v1::stream_virtual_output` ([`kwin`]).
//! * **wlroots/Sway** — `swaymsg create_output` + `output mode --custom` ([`wlroots`]).
//! * **Mutter/GNOME** — D-Bus `RemoteDesktop` + `ScreenCast.RecordVirtual` ([`mutter`]).
//! * **Hyprland** — `hyprctl output create headless` + the xdg-desktop-portal-hyprland ScreenCast
//! portal. Its own backend, not a wlroots dialect (`design/hyprland-support.md` D1).
//! * **gamescope** — three sub-modes behind one backend ([`GamescopeRoute`]): bare
//! **spawn** of a nested headless session, host-**managed** `gamescope-session-plus`/SteamOS
//! takeover, and **attach** to a session somebody else started. By far the largest backend here,
//! because it owns session lifecycle rather than just minting an output.
//! * **monitor mirror** — no virtual display at all: stream a PHYSICAL head the compositor already
//! has (the `PUNKTFUNK_CAPTURE_MONITOR` pin), reporting [`DisplayOwnership::External`] so none of
//! the lifecycle policy is applied to someone else's screen.
//! * **Windows pf-vdisplay** — the all-Rust IddCx driver + its `manager`, the sole Windows backend.
//!
//! No list of file sizes here: it rots. The rule instead — the Linux backends plus the Windows
//! manager are the bulk of this crate, and the platform-neutral half (`policy`, `registry`,
//! `lifecycle`, `layout`, `identity`, `admission`, `monitors`, `session`, `routing`, `proc`,
//! `portal_config`) is the minority that every platform's CI actually compiles and tests.
//!
//! [`VirtualDisplay::create`] returns a [`VirtualOutput`]: the PipeWire node to capture plus an
//! owned keepalive whose `Drop` releases the output (RAII — no explicit `destroy`). Capture
//! consumes the node via the host `capture::capture_virtual_output`.
// `dead_code` is ENFORCED on Linux, where ~10k of this crate's ~17k lines live. Off elsewhere for
// one structural reason: `proc`, `session`, `routing`, `monitors` and `lifecycle` are declared
// unconditionally but exist to serve the Linux backends, so on Windows/macOS most of their surface
// is legitimately unreferenced. Scoping it this way rather than crate-wide keeps the platform that
// owns the code honest. (Was a bare crate-wide allow whose "scaffold, defined ahead of the target
// that uses them" rationale had stopped being true.)
// `dead_code` is ENFORCED on Linux, where the clear majority of this crate lives — every compositor
// backend under `vdisplay/linux/` plus everything only they consume, which is roughly half the crate
// on its own and the half that carries the session-lifecycle risk. Off elsewhere for one structural
// reason: `proc`, `session`, `routing`, `monitors` and `lifecycle` are declared unconditionally but
// exist to serve the Linux backends, so on Windows/macOS most of their surface is legitimately
// unreferenced. Note what that waives: the Windows backend (`vdisplay/windows/`, itself thousands of
// lines) gets NO dead-code enforcement, so an orphaned Windows path has to be found by review.
// Scoping it this way rather than crate-wide still keeps the platform that owns most of the code
// honest. (Was a bare crate-wide allow whose "scaffold, defined ahead of the target that uses them"
// rationale had stopped being true.)
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -200,9 +219,16 @@ impl Compositor {
/// The compositor backends usable on this host *right now*: gamescope wherever its binary is
/// installed (it spawns a nested session — independent of the running desktop), plus the live
/// session's own compositor (KWin / Mutter / wlroots / Hyprland) when the host runs inside it.
/// Cheap, side-effect-free probes — safe to call per management request. A concrete client
/// preference is validated against this set before it's honored (see the punktfunk/1 handshake's
/// resolution).
/// Side-effect-free, but **not cheap, and not memoized**: every call re-walks `/proc`
/// ([`detect_active_session`]), and each backend probe that the live/pinned short-circuit below does
/// not exempt does real work — `gamescope::is_available` FORKS `gamescope --version`,
/// `kwin::is_available` does a Wayland registry roundtrip, `wlroots`/`hyprland` read a socket path
/// and `mutter` a D-Bus name. So a console polling `/host/compositors` on a KDE box still forks a
/// gamescope per poll, on a thread the caller must therefore not assume is cheap to block (mgmt
/// calls it inline on the async runtime). Callers wanting a hot path should cache the answer;
/// treating this as free is what the "cheap, safe per management request" claim this doc used to
/// make invited. A concrete client preference is validated against this set before it's honored
/// (see the punktfunk/1 handshake's resolution).
///
/// The **live session is the primary signal**, ahead of each backend's own probe. Those probes read
/// the process env (`XDG_CURRENT_DESKTOP` for Mutter, `WAYLAND_DISPLAY` for KWin's registry
@@ -311,7 +337,12 @@ pub fn detect() -> Result<Compositor> {
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
return Ok(c);
}
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
// Under [`ENV_LOCK`]: `apply_session_env` `set_var`s — and, for a dead session,
// `remove_var`s — this very key from another session's `spawn_blocking`, and a glibc
// `getenv` concurrent with a `setenv` is the `environ` realloc data race ENV_LOCK exists
// for (it is UB regardless of which key each side touches, so "different variable" is no
// defence). Read-then-drop: only the read needs serializing.
let desktop = with_env_lock(|| std::env::var("XDG_CURRENT_DESKTOP"))
.unwrap_or_default()
.to_ascii_uppercase();
if desktop.contains("KDE") {
@@ -559,13 +590,18 @@ pub fn effective_topology() -> policy::Topology {
return resolve_topology(e.topology);
}
// Unconfigured: honor a legacy operator env if present (a host runs one desktop backend, so at
// most one of these is set), else the Auto default.
let legacy = [
"PUNKTFUNK_KWIN_VIRTUAL_PRIMARY",
"PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY",
]
.iter()
.find_map(|k| std::env::var(k).ok());
// most one of these is set), else the Auto default. Read under [`ENV_LOCK`] like every other
// env read on the session-setup path: this runs inside `create`, concurrent with another
// session's `apply_session_env` `set_var`s, and glibc's `environ` realloc makes a racing
// `getenv` UB no matter that these particular keys are ones nobody writes.
let legacy = with_env_lock(|| {
[
"PUNKTFUNK_KWIN_VIRTUAL_PRIMARY",
"PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY",
]
.iter()
.find_map(|k| std::env::var(k).ok())
});
match legacy.as_deref().map(str::trim) {
Some("1" | "true" | "yes" | "on") => policy::Topology::Exclusive,
Some("0" | "false" | "no" | "off") => policy::Topology::Extend,
@@ -637,19 +673,92 @@ pub fn gamescope_composites_cursor() -> bool {
///
/// A host-managed `gamescope-session-plus` / SteamOS session counts as a spawn: we own its
/// `GAMESCOPE_BIN` wrapper (or PATH shim), so the flags are ours.
///
/// **Ask the resolved ROUTE, never the env.** This used to test the spawn-vs-attach term by reading
/// `PUNKTFUNK_GAMESCOPE_NODE`, which worked only while `apply_input_env` PUBLISHED its decision into
/// that key. Phase 2.3 deleted the publication (routing.rs: "Nothing is written back to the two
/// knobs") and left the key as an operator override — rung 2 of a 6-rung ladder — so the session
/// that reaches [`GamescopeRoute::Attach`] at the ladder's rung 5 instead (a foreign gamescope on an
/// infra-less box), and the monitor-pin mirror that never consults the ladder at all, both answered
/// "ours". The two consequences were silent and unrecoverable: the punktfunk/1 Welcome fixed the
/// session at 10-bit BT.2020/PQ against a foreign 8-bit SDR composite, and the host skipped the
/// XFixes cursor reconstruction for a session whose gamescope was never given
/// `--pipewire-composite-cursor` — a stream with no pointer in it at all.
///
/// **Two residual gaps**, both of which need a route this crate cannot see from here:
///
/// * the ladder is re-run with `dedicated_launch = false`, since a capability query carries no
/// session context — so it cannot see the one input that would move a session from
/// Managed/Attach to Spawn. On a box with no session infrastructure AND a foreign gamescope
/// running, a `game_session=dedicated` launch really takes rung 3 (`Spawn`) while this re-run
/// takes rung 5 (`Attach`) and answers "foreign";
/// * `create_managed_session` can degrade a resolved `Managed` to an ATTACH at create time (a
/// mask-fragile DM it may not stop — it then mirrors the box's own game-mode session). That
/// happens after this answer is due, and the ladder re-run here still says `Managed`, so such a
/// session is still credited with flags it does not own.
///
/// The second over-promises. The first UNDER-promises, and `false` is the deliberate choice for an
/// input we cannot see, because the two directions do not cost the same: over-promising fixes the
/// punktfunk/1 Welcome at 10-bit PQ against an 8-bit SDR composite and leaves a stream with **no
/// pointer at all**, while under-promising costs HDR and draws the pointer twice. But do not read
/// that as "fails closed": it is not, for the cursor. `gamescope::cursor_args` adds
/// `--pipewire-composite-cursor` from the BINARY probe alone, ungated by this answer, so on the
/// bare spawn above gamescope paints the pointer into the node while the host's
/// `session_plan::gamescope_needs_host_cursor` (`gamescope && !gamescope_composites_cursor()`) also
/// blends the XFixes pointer on top — two pointers, plus the encoder pushed off its zero-copy arm.
/// Do not "fix" that by re-running the ladder with a guessed `dedicated_launch = true`: that trades
/// the mild failure for the severe one on every non-launching session. Both gaps close the same
/// way, and only that way: give these two functions the session's own [`GamescopeRoute`] (which
/// `SessionContext` already carries) and have the backend report the degrade — a change to two
/// public signatures and every host call site, i.e. work outside this crate.
fn gamescope_ours_and(#[cfg(target_os = "linux")] probe: fn() -> bool) -> bool {
#[cfg(target_os = "linux")]
{
let attaching = with_env_lock(|| std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some());
!attaching && probe()
// `probe` first: it is memoized (the `--version` banner is parsed once per process), while
// the route resolution walks `/proc` for a foreign gamescope. On a box with a stock
// gamescope the answer is already `false` and the walk never happens.
probe()
&& !session_is_a_foreign_gamescope(
capture_monitor().is_some(),
resolve_gamescope_route(Compositor::Gamescope, false).as_ref(),
)
}
#[cfg(not(target_os = "linux"))]
false
}
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
/// Pure predicate behind [`gamescope_ours_and`]: is the gamescope this session will use one
/// SOMEBODY ELSE started, whose spawn flags we therefore cannot vouch for?
///
/// Two ways to land on a foreign session, and both must count:
///
/// * `mirror_pinned` — a `PUNKTFUNK_CAPTURE_MONITOR` pin routes [`open`] to the mirror backend,
/// whose gamescope arm attaches to the node the RUNNING session already publishes without
/// consulting the sub-mode ladder at all. On a Bazzite/SteamOS box that session is Game Mode's,
/// i.e. by definition not ours.
/// * a [`GamescopeRoute::Attach`] verdict — however the ladder reached it (operator override,
/// or the foreign-gamescope rung).
///
/// [`GamescopeRoute::Managed`] is NOT foreign: the managed takeover starts the session through our
/// own `GAMESCOPE_BIN` wrapper / PATH shim, so its flags are the ones we chose.
///
/// `mirror_pinned` is judged from the pin alone, not from whether the mirror actually took: [`open`]
/// degrades a pin to the virtual-display path when the session reports no physical heads, and a
/// pinned box that lands there is called foreign here although it will bare-spawn. That is the
/// fail-closed direction — a capability withheld from a session that could have had it — and the
/// alternative (enumerating heads from a capability query) would put a compositor roundtrip on a
/// path that must answer before anything exists to ask.
fn session_is_a_foreign_gamescope(mirror_pinned: bool, route: Option<&GamescopeRoute>) -> bool {
mirror_pinned || matches!(route, Some(GamescopeRoute::Attach { .. }))
}
// Platform-neutral per-client stable display-id map: Windows seeds the monitor EDID serial +
// IddCx ConnectorIndex from the id; KWin names its output `Virtual-punktfunk-<id>` (kwin.rs's
// `resolve_slot` call); Mutter cannot carry the id into its virtual monitor at all, so it keys the
// host-persisted `ScaleMap` on the same identity key. All three are production call sites, so the
// `allow(dead_code)` below no longer stands for "unwired yet" (it did when only Windows consumed the
// map); it now covers whatever helpers no CURRENT backend reaches. Worth re-testing without it —
// that has to happen on a Linux build, since this is the platform where dead_code is enforced.
#[allow(dead_code)]
#[path = "vdisplay/identity.rs"]
pub(crate) mod identity;
@@ -735,6 +844,48 @@ mod tests {
assert_eq!(compositor_for_kind(ActiveKind::None), None);
}
/// The spawn-vs-attach term behind [`gamescope_hdr_available`] /
/// [`gamescope_composites_cursor`]. Both answers are IRREVOCABLE once the punktfunk/1 Welcome
/// has gone out (bit depth is fixed there; the session plan's cursor decision feeds the encoder
/// open), so an over-promise here is not recoverable at runtime — which is why the regression
/// this pins mattered: the term used to be read off `PUNKTFUNK_GAMESCOPE_NODE`, a key nothing
/// writes any more, so every foreign session answered "ours".
#[test]
fn only_a_session_we_start_can_promise_gamescope_capabilities() {
// Attach — however the ladder got there — is somebody else's session: unknown spawn flags.
assert!(session_is_a_foreign_gamescope(
false,
Some(&GamescopeRoute::Attach {
node: "auto".into()
})
));
assert!(session_is_a_foreign_gamescope(
false,
Some(&GamescopeRoute::Attach { node: "42".into() })
));
// A bare spawn is ours by definition; so is the managed takeover (it starts gamescope
// through our own GAMESCOPE_BIN wrapper / PATH shim, so the flags are the ones we chose).
assert!(!session_is_a_foreign_gamescope(
false,
Some(&GamescopeRoute::Spawn)
));
assert!(!session_is_a_foreign_gamescope(
false,
Some(&GamescopeRoute::Managed {
client: "steam".into()
})
));
// No route at all = not a gamescope session; the binary probe alone then decides.
assert!(!session_is_a_foreign_gamescope(false, None));
// A monitor pin bypasses the ladder entirely (mirror backend → attach to the node the
// RUNNING session publishes), so it is foreign whatever the ladder would have said.
assert!(session_is_a_foreign_gamescope(true, None));
assert!(session_is_a_foreign_gamescope(
true,
Some(&GamescopeRoute::Spawn)
));
}
#[test]
fn detect_active_session_is_side_effect_free_and_terminates() {
// A pure probe of /proc + the runtime dir: it must not panic and must return promptly on
+20 -1
View File
@@ -136,7 +136,26 @@ pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
!live.is_empty(),
)
};
let _ = any_live; // read only by the Windows budget block below
let _ = any_live; // read only by the budget blocks below
// The operator's `max_displays` ceiling (design §5.3). Applied HERE, once per connecting
// session, and deliberately NOT in the display create path: `acquire` runs again on every
// mid-stream rebuild (capture loss, a Game↔Desktop switch), and those rebuild before dropping
// the old display — so a ceiling enforced there counts the session against itself and refuses
// the recovery. Admission is reached once per connect, so it cannot.
#[cfg(target_os = "linux")]
if matches!(decision, Admission::Separate) && any_live {
// The Linux pool had no ceiling at all: its reuse key includes the CLIENT-SUPPLIED mode, so
// a client reconnecting at a different resolution misses reuse and mints a fresh display,
// and a handful of reconnects could row out an unbounded number of compositor outputs.
let max = policy::prefs().get().effective().max_displays;
let live = super::registry::live_display_count();
if live >= max {
return Admission::Reject(format!(
"host display budget exhausted: {live} display(s) live/kept, max_displays = {max}"
));
}
}
#[cfg(windows)]
if matches!(decision, Admission::Separate) && any_live {
let max = policy::prefs().get().effective().max_displays;
+14 -3
View File
@@ -225,9 +225,20 @@ pub trait VirtualDisplay: Send {
/// ([`DisplayOwnership::Owned`], keep-alive-able) display? The registry consults this **before**
/// its keep-alive reuse lookup, so it never hands a kept display of one flavor to a request of
/// another — specifically a gamescope managed/attach acquire must not reuse a kept **bare-spawn**
/// (they share the backend name `"gamescope"`). Default `true`; only gamescope overrides it,
/// returning `false` when the env selects attach/managed (consistent with the `ownership` its
/// `create` will report). See `design/gamemode-and-dedicated-sessions.md` A1.
/// (they share the backend name `"gamescope"`). Overridden by gamescope, which reads the
/// resolved [`GamescopeRoute`](crate::GamescopeRoute) carried on the instance (`self.route`, NOT
/// env — the sub-mode stopped travelling through `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` in Phase
/// 2.3): `false` for `Managed` and `Attach`, `true` for `Spawn` **and for no route at all**,
/// since `create`'s own `None` arm falls through to the bare spawn — so an instance nobody
/// called `set_gamescope_route` on (the operator-pinned `PUNKTFUNK_COMPOSITOR` path) is
/// poolable, and takes both the reuse lookup and the `max_displays` ceiling. Also overridden by
/// the mirror backend (`false` always). See `design/gamemode-and-dedicated-sessions.md` A1.
///
/// The default `true` is a DEFAULT, not a fact: it happens to be right for every backend that
/// creates a display it owns, and it is wrong for any backend whose `create` reports something
/// other than [`DisplayOwnership::Owned`] — this answer and that one must agree, and nothing
/// enforces it. A required method would; making it one costs an impl in each of the five
/// per-compositor backends plus Windows.
fn poolable_now(&self) -> bool {
true
}
+176 -36
View File
@@ -21,6 +21,7 @@
//! Persisted to `<config>/display-identity.json` (migrated from the legacy Windows
//! `pf-vdisplay-identity.json`) so ids — and the client→config association — survive host restarts.
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
@@ -78,12 +79,38 @@ impl DisplayIdentityMap {
pub(crate) fn load() -> Self {
let dir = pf_paths::config_dir();
let path = dir.join(FILE);
let bytes = std::fs::read(&path)
.or_else(|_| std::fs::read(dir.join(LEGACY_FILE)))
.ok();
let mut store = bytes
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
.unwrap_or_default();
let (from, bytes) = match std::fs::read(&path) {
Ok(b) => (path.clone(), Some(b)),
Err(_) => {
let legacy = dir.join(LEGACY_FILE);
match std::fs::read(&legacy) {
Ok(b) => (legacy, Some(b)),
// No file at all is the ordinary first-run case — not worth a word.
Err(_) => (path.clone(), None),
}
}
};
let mut store = match bytes {
Some(b) => match serde_json::from_slice::<Store>(&b) {
Ok(s) => s,
Err(e) => {
// An UNPARSEABLE map used to be swallowed into `Default::default()`, and the very
// next `resolve` persisted that empty store OVER the file — silently discarding
// every client's Windows EDID serial / KWin `Virtual-punktfunk-<id>` and the
// per-display DPI the OS keyed to them. Say so, and move the file aside so the
// damage is recoverable by hand (same treatment `display-presets.json` gets).
tracing::warn!(
path = %from.display(),
error = %e,
"display-identity map is unreadable — starting a fresh one; \
the old file is kept as .bad (every client re-derives its display id once)"
);
let _ = std::fs::rename(&from, from.with_extension("json.bad"));
Store::default()
}
},
None => Store::default(),
};
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s
// found-entry branch returns the stored id verbatim, so an out-of-range id (0 = the "auto"
// sentinel, or > MAX_ID) or a duplicate id/key would flow straight into the display identity.
@@ -100,7 +127,17 @@ impl DisplayIdentityMap {
/// The stable id (`1..=15`) for the client `key` ([`identity_key`]): its remembered id, or a
/// freshly assigned one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
pub(crate) fn resolve(&mut self, key: &str) -> u32 {
///
/// `live` is the set of ids that currently drive a REAL display (the Windows manager's slot keys
/// / the Linux pool's `identity_slot`s). An id in it is never evicted, and when every eviction
/// candidate is live this **refuses** (`None`) rather than handing the newcomer an id that is
/// already someone else's monitor. That is not hypothetical: the id keys the Windows manager's
/// slot map, whose plain-JOIN branch attaches an arriving session to whatever monitor the slot
/// already holds — so evicting a live id handed client B client A's streaming monitor, capture
/// target and all. Refusing costs the newcomer its stable identity (upstream falls back to the
/// shared/auto slot: `resolve_slot` → `None`, `slot_id_for` → `0`); evicting cost a live client
/// its session.
pub(crate) fn resolve(&mut self, key: &str, live: &BTreeSet<u32>) -> Option<u32> {
self.store.tick = self.store.tick.wrapping_add(1);
let now = self.store.tick;
@@ -108,32 +145,43 @@ impl DisplayIdentityMap {
e.seen = now;
let id = e.id;
self.persist();
return id;
return Some(id);
}
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
let id = (1..=MAX_ID)
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
.unwrap_or_else(|| {
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the
// least-recently-seen entry that is NOT live and reuse its id (that client re-establishes its
// scaling once on its next connect).
let id = match (1..=MAX_ID).find(|i| !self.store.entries.iter().any(|e| e.id == *i)) {
Some(free) => free,
None => {
let lru = self
.store
.entries
.iter()
.enumerate()
.filter(|(_, e)| !live.contains(&e.id))
.min_by_key(|(_, e)| e.seen)
.map(|(i, _)| i)
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
let evicted = self.store.entries.remove(lru);
evicted.id
});
.map(|(i, _)| i);
let Some(lru) = lru else {
tracing::warn!(
cap = MAX_ID,
live = live.len(),
"display identity map is full and every id is driving a live display — \
this client gets the shared/auto display identity (no persisted per-client \
scaling) rather than displacing a live one"
);
return None;
};
self.store.entries.remove(lru).id
}
};
self.store.entries.push(Entry {
key: key.to_string(),
id,
seen: now,
});
self.persist();
id
Some(id)
}
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
@@ -168,7 +216,8 @@ pub(crate) fn global() -> &'static Mutex<DisplayIdentityMap> {
/// Resolve the connecting client's stable slot id per the `identity` policy. When no policy is
/// configured, `default` applies — **PerClient on Windows / Shared on Linux**, preserving each
/// platform's historical behavior (Windows always keyed monitors per-client; Linux used one shared
/// output name). `None` ⇒ shared / anonymous the backend uses its base name / auto slot.
/// output name). `None` ⇒ shared / anonymous (or the map [refused](DisplayIdentityMap::resolve) an
/// id because every one is live) → the backend uses its base name / auto slot.
pub(crate) fn resolve_slot(
fp: Option<[u8; 32]>,
mode: (u32, u32),
@@ -185,12 +234,40 @@ pub(crate) fn resolve_slot(
Identity::PerClientMode => true,
};
let fp = fp?;
Some(
global()
.lock()
.unwrap()
.resolve(&identity_key(fp, mode, per_client_mode)),
)
// Sample the live ids BEFORE taking the map lock, never under it: the sources below take the
// Windows manager's `state` lock / the Linux pool lock, and this map is reached from inside a
// backend `create` — a lock order of (display owner → identity map) in both directions would be
// a deadlock. One direction only, and the map lock stays a leaf.
let live = live_slot_ids();
global()
.lock()
.unwrap()
.resolve(&identity_key(fp, mode, per_client_mode), &live)
}
/// The identity slots currently driving a REAL display — the eviction guard for
/// [`DisplayIdentityMap::resolve`]. Windows reads the manager's slot map (the key IS the identity
/// slot); Linux reads the registry pool's per-entry `identity_slot`. Both include KEPT
/// (lingering/pinned) displays on purpose: a kept display is a live compositor/driver resource whose
/// owner is expected back, and the whole point of the id is that the reconnect finds it again.
/// Anonymous (`0`) is not an identity and never blocks an assignment.
fn live_slot_ids() -> BTreeSet<u32> {
#[cfg(target_os = "windows")]
{
crate::manager::snapshot()
.into_iter()
.map(|i| i.slot_id)
.filter(|s| *s != 0)
.collect()
}
#[cfg(target_os = "linux")]
{
crate::registry::live_identity_slots()
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
BTreeSet::new()
}
}
// ---------------------------------------------------------------------------------------
@@ -306,24 +383,31 @@ mod tests {
}
}
/// Nothing is streaming — the ordinary case, where the live set never constrains anything.
fn nothing_live() -> BTreeSet<u32> {
BTreeSet::new()
}
#[test]
fn stable_across_calls_and_distinct_per_client() {
let mut m = temp_map("stable");
let a1 = m.resolve(&identity_key(fp(1), (1920, 1080), false));
let b = m.resolve(&identity_key(fp(2), (1920, 1080), false));
let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false)); // per-client: mode ignored
let a1 = m.resolve(&identity_key(fp(1), (1920, 1080), false), &nothing_live());
let b = m.resolve(&identity_key(fp(2), (1920, 1080), false), &nothing_live());
// per-client: mode ignored
let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false), &nothing_live());
assert_eq!(a1, a2, "same client → same id (per-client ignores mode)");
assert_ne!(a1, b, "distinct clients → distinct ids");
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
assert!(a1.is_some_and(|i| (1..=MAX_ID).contains(&i)));
assert!(b.is_some_and(|i| (1..=MAX_ID).contains(&i)));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn per_client_mode_splits_by_resolution() {
let mut m = temp_map("permode");
let hd = m.resolve(&identity_key(fp(1), (1920, 1080), true));
let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true));
let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true));
let hd = m.resolve(&identity_key(fp(1), (1920, 1080), true), &nothing_live());
let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true), &nothing_live());
let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true), &nothing_live());
assert_ne!(hd, uhd, "same client, different resolution → different id");
assert_eq!(hd, hd2, "same client + resolution → same id");
let _ = std::fs::remove_file(&m.path);
@@ -333,16 +417,72 @@ mod tests {
fn lru_eviction_reuses_an_id_at_the_cap() {
let mut m = temp_map("lru");
for n in 1..=15u8 {
m.resolve(&identity_key(fp(n), (1920, 1080), false));
m.resolve(&identity_key(fp(n), (1920, 1080), false), &nothing_live());
}
let _ = m.resolve(&identity_key(fp(2), (1920, 1080), false)); // touch 2 so 1 is LRU
let id16 = m.resolve(&identity_key(fp(16), (1920, 1080), false));
// touch 2 so 1 is LRU
let _ = m.resolve(&identity_key(fp(2), (1920, 1080), false), &nothing_live());
let id16 = m
.resolve(&identity_key(fp(16), (1920, 1080), false), &nothing_live())
.expect("nothing is live → the LRU id is free to take");
assert!((1..=MAX_ID).contains(&id16));
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
let _ = std::fs::remove_file(&m.path);
}
/// 10.2: the LRU victim is chosen among ids that are NOT driving a display. Handing the LRU id
/// to a newcomer while its owner streams is what let the Windows manager's plain-JOIN branch
/// attach the newcomer to the live client's monitor.
#[test]
fn lru_eviction_never_takes_a_live_id() {
let mut m = temp_map("lru-live");
let mut ids = Vec::new();
for n in 1..=15u8 {
ids.push(
m.resolve(&identity_key(fp(n), (1920, 1080), false), &nothing_live())
.unwrap(),
);
}
// fp(1) is the least-recently-seen — and it is the one that is streaming.
let lru_id = ids[0];
let live: BTreeSet<u32> = [lru_id].into_iter().collect();
let id16 = m
.resolve(&identity_key(fp(16), (1920, 1080), false), &live)
.expect("14 idle ids remain — one of them is the victim");
assert_ne!(id16, lru_id, "must not take the id of a live display");
assert_eq!(id16, ids[1], "the next-least-recently-seen IDLE id instead");
// The live client's mapping is untouched, so its reconnect still finds its own display.
assert_eq!(
m.resolve(&identity_key(fp(1), (1920, 1080), false), &live),
Some(lru_id)
);
let _ = std::fs::remove_file(&m.path);
}
/// Fail-closed at the extreme: every id live ⇒ refuse, rather than displace a streaming client.
/// The caller degrades to the shared/auto identity (`resolve_slot` → `None`, `slot_id_for` → 0).
#[test]
fn refuses_rather_than_evicting_when_every_id_is_live() {
let mut m = temp_map("lru-all-live");
let mut live = BTreeSet::new();
for n in 1..=15u8 {
live.insert(
m.resolve(&identity_key(fp(n), (1920, 1080), false), &BTreeSet::new())
.unwrap(),
);
}
assert_eq!(
m.resolve(&identity_key(fp(16), (1920, 1080), false), &live),
None
);
assert_eq!(m.store.entries.len(), 15, "nothing was evicted");
// A KNOWN client is still resolved even when everything is live — it owns that id already.
assert!(m
.resolve(&identity_key(fp(3), (1920, 1080), false), &live)
.is_some());
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn key_composition() {
assert_eq!(identity_key(fp(0xab), (1920, 1080), false).len(), 64); // hex fp only
+228 -22
View File
@@ -10,8 +10,16 @@
//! deterministic.
//! * **manual** — per-identity-slot offsets from [`Layout::positions`] (console-arranged): a member
//! whose stable identity slot has a stored position sits there; a member with no pin (no stored
//! position, or a shared/anonymous identity that has no slot) falls back to its auto-row origin, so
//! a half-arranged group never collapses everything onto the origin.
//! position, or a shared/anonymous identity that has no slot) is **packed clear of the pins** —
//! rowed left-to-right starting past the rightmost pinned edge — so a half-arranged group neither
//! collapses everything onto the origin nor drops an unpinned display exactly on top of a pinned
//! one. The pins themselves are reproduced verbatim: where two of them overlap, that is the
//! operator's own arrangement and not ours to second-guess.
//!
//! Members carry no height, so "clear of the pins" is decided on the x axis alone and every pin
//! counts regardless of its `y` — a vertically-stacked arrangement therefore packs further right than
//! it strictly needs to. That is the conservative direction: a gap is a cosmetic waste of desktop
//! coordinate space, an overlap is two desktops fighting over the same pixels.
//!
//! Group membership + acquire order live in the registry ([`super::registry`]); this file only turns
//! that ordered member list into positions.
@@ -24,8 +32,18 @@ pub struct Member {
/// Stable per-client identity slot — the manual-layout key. `None` for a shared/anonymous
/// identity (no per-client slot), which can't carry a manual pin and therefore always auto-rows.
pub identity_slot: Option<u32>,
/// Pixel width, for auto-row `x` accumulation. Clamped at 0 (a bogus negative never shifts a
/// sibling left).
/// The member's width **in the same coordinate space the resulting [`Placement`] is expressed
/// in**, for row `x` accumulation. Clamped at 0 (a bogus negative never shifts a sibling left).
///
/// ⚠ Every fill site currently uses the requested *mode* width, i.e. pixels. On Windows
/// that is also the desktop space (CCD geometry is pixels), so the two agree; on KWin the
/// placement is handed to `config.position()`, which is the compositor's **logical** space — the
/// two coincide only at scale 1.0, and a per-output scale is exactly what the identity machinery
/// exists to make KDE reapply. A 150 %-scaled 2560-wide output occupies 1707 logical px, so
/// auto-rowing past it by 2560 leaves an 853-px dead band. Fixing that means dividing by the
/// output's applied scale at the KWin fill site (`kwin_output_mgmt` already reads `scale` into
/// its device state); this type stays unit-agnostic, and the contract is that whoever fills it
/// speaks the consumer's space.
pub width: i32,
}
@@ -37,30 +55,79 @@ pub struct Placement {
}
/// The auto-row origin of member `i`: the summed width of every prior member, top-aligned.
/// `saturating_add` because the widths are client-supplied through the requested mode — an absurd
/// one must produce an absurd coordinate, not a debug-build panic inside the state readout.
fn auto_row_x(members: &[Member], i: usize) -> i32 {
members[..i].iter().map(|m| m.width.max(0)).sum()
members[..i]
.iter()
.fold(0i32, |x, m| x.saturating_add(m.width.max(0)))
}
/// The manual pin for `m`, if its identity slot carries one. The lookup is an exact string match on
/// the canonical decimal slot id — `DisplayPolicy::sanitized` re-keys the table to that form on
/// write, so a `"01"` typed into a hand-edited settings file still resolves here.
fn pin_of(m: &Member, layout: &Layout) -> Option<Placement> {
m.identity_slot
.and_then(|slot| layout.positions.get(&slot.to_string()))
.map(|p| Placement { x: p.x, y: p.y })
}
/// Arrange `members` (in acquire order) per `layout`, returning one [`Placement`] per member in the
/// same order. Pure — the single source of truth for auto-row / manual placement, shared by the
/// state readout and (KWin) the per-backend position apply.
pub fn arrange(members: &[Member], layout: &Layout) -> Vec<Placement> {
members
.iter()
.enumerate()
.map(|(i, m)| {
let auto = Placement {
match layout.mode {
LayoutMode::AutoRow => (0..members.len())
.map(|i| Placement {
x: auto_row_x(members, i),
y: 0,
};
match layout.mode {
LayoutMode::AutoRow => auto,
// A pinned member sits at its stored offset; an unpinned one falls back to auto-row.
LayoutMode::Manual => m
.identity_slot
.and_then(|slot| layout.positions.get(&slot.to_string()))
.map(|p| Placement { x: p.x, y: p.y })
.unwrap_or(auto),
})
.collect(),
LayoutMode::Manual => arrange_manual(members, layout),
}
}
/// Manual placement: pins verbatim, everything else rowed out past them.
///
/// The unpinned fallback used to be the unconditional auto-row prefix sum — computed as if the pins
/// did not exist — so an unpinned display could land exactly on top of a pinned sibling with nothing
/// downstream noticing (the arrangement is only ever *reported* and *applied*, never validated). One
/// number in this crate's own fixture separated the tested case from that collision. Rowing the
/// unpinned members from the rightmost pinned edge instead makes the overlap unrepresentable within
/// one call, and keeps three of the fallback's properties: deterministic, acquire-ordered, and
/// identical to plain auto-row when nothing is pinned.
///
/// ⚠ **The fourth property is gone, knowingly: incremental stability.** The prefix sum could not
/// move member `i` when member `i+1` joined; this cursor is seeded from the pins of *all* members,
/// so an already-placed unpinned member's computed `x` shifts the moment a pinned sibling arrives
/// later in acquire order. Nothing re-applies it — `registry::position_for_new` takes only the
/// `.last()` placement and the registry moves the newly-acquired display alone — so in that ordering
/// `GET /display/state` reports a position the desktop never received (the pre-existing shape of
/// this: an auto-row teardown already shifts every survivor's reported `x` with no re-apply; the
/// packing widens the class to joins under `Manual`). It is not fixable here: the honest fix is for
/// the registry to re-apply the WHOLE group's arrangement on any membership change under
/// `LayoutMode::Manual`, the way `windows/manager.rs`'s `arrange_slots` already does, at which point
/// this function is right in every ordering. Seeding the cursor from preceding pins only would buy
/// incremental stability back by reintroducing the collision this exists to prevent — the wrong
/// trade, since the common ordering (the pin exists, an unpinned client joins) does reach the apply
/// path and is placed correctly.
fn arrange_manual(members: &[Member], layout: &Layout) -> Vec<Placement> {
let pins: Vec<Option<Placement>> = members.iter().map(|m| pin_of(m, layout)).collect();
// Start the unpinned row at the desktop origin, or past the rightmost pinned edge when there is
// one. `max(0)` on the width keeps a bogus negative from pulling the cursor back over a pin.
let mut cursor = pins
.iter()
.zip(members)
.filter_map(|(pin, m)| pin.map(|p| p.x.saturating_add(m.width.max(0))))
.fold(0i32, i32::max);
pins.iter()
.zip(members)
.map(|(pin, m)| match pin {
Some(p) => *p,
None => {
let at = Placement { x: cursor, y: 0 };
cursor = cursor.saturating_add(m.width.max(0));
at
}
})
.collect()
@@ -115,14 +182,153 @@ mod tests {
}
#[test]
fn manual_unpinned_and_slotless_fall_back_to_auto_row() {
fn manual_unpinned_and_slotless_pack_clear_of_the_pins() {
let members = [m(Some(1), 2560), m(Some(9), 1920), m(None, 1280)];
// Only slot 1 is pinned; slot 9 has no stored pin; the third has no slot at all.
let layout = manual(&[("1", 100, 50)]);
let out = arrange(&members, &layout);
assert_eq!(out[0], Placement { x: 100, y: 50 }, "pinned");
assert_eq!(out[1], Placement { x: 2560, y: 0 }, "unpinned → auto-row");
assert_eq!(out[2], Placement { x: 4480, y: 0 }, "slotless → auto-row");
// The pin occupies [100, 2660); the unpinned members row out from its right edge in acquire
// order, NOT from the pin-blind prefix sum (which would have put the first one at 2560 —
// inside the pin).
assert_eq!(
out[1],
Placement { x: 2660, y: 0 },
"unpinned → past the pin"
);
assert_eq!(out[2], Placement { x: 4580, y: 0 }, "slotless → past both");
}
#[test]
fn manual_with_no_pins_at_all_is_plain_auto_row() {
// The fallback must not drift from auto-row when the manual table happens to be empty (the
// state a group is in the instant `manual` is selected and nothing has been arranged yet).
let members = [m(Some(1), 2560), m(Some(2), 1920), m(None, 1280)];
let out = arrange(&members, &manual(&[]));
assert_eq!(out, arrange(&members, &Layout::default()));
}
#[test]
fn a_manual_pin_that_would_collide_with_an_auto_row_sibling_is_packed_clear() {
// The exact geometry §13 11.8 names: a pin sitting where the pin-blind auto-row would have
// put the unpinned sibling. Two displays on one origin = two desktops on the same pixels.
let members = [m(Some(1), 2560), m(Some(9), 1920)];
let layout = manual(&[("1", 2560, 0)]);
let out = arrange(&members, &layout);
assert_eq!(out[0], Placement { x: 2560, y: 0 }, "pin honored verbatim");
assert_ne!(
out[1], out[0],
"the unpinned sibling must not land on the pin"
);
assert_eq!(
out[1],
Placement { x: 5120, y: 0 },
"past the pin's right edge"
);
}
#[test]
fn a_pin_left_of_the_origin_still_leaves_the_unpinned_row_at_zero() {
// A negative pin is legal (KWin's global space extends left of 0). Its right edge is what
// matters: at -3000+2560 = -440 it constrains nothing, so the row still starts at the origin.
let members = [m(Some(1), 2560), m(Some(9), 1920)];
let out = arrange(&members, &manual(&[("1", -3000, 0)]));
assert_eq!(out[0], Placement { x: -3000, y: 0 });
assert_eq!(out[1], Placement { x: 0, y: 0 });
}
#[test]
fn absurd_widths_saturate_instead_of_panicking() {
// Widths originate in the client-requested mode; a hostile or corrupt one must produce an
// absurd coordinate, not an overflow panic inside the `/display/state` readout.
let members = [m(Some(1), i32::MAX), m(Some(2), i32::MAX), m(None, 4096)];
let out = arrange(&members, &Layout::default());
assert_eq!(out[2], Placement { x: i32::MAX, y: 0 });
let out = arrange(&members, &manual(&[("1", i32::MAX, 0)]));
assert_eq!(out[2], Placement { x: i32::MAX, y: 0 });
}
/// Property (deterministic seeded walk): across arbitrary member widths, slot assignments and pin
/// tables, **no unpinned member may share desktop space with any sibling**. Overlap between two
/// *pins* is excluded from the invariant — that is the operator's own arrangement, faithfully
/// reproduced. Members carry no height, so "share space" is decided on the x interval alone,
/// which is the strictest reading available here.
#[test]
fn no_unpinned_member_overlaps_a_sibling_under_any_layout() {
// Tiny deterministic LCG (Numerical Recipes) — reproducible, no dependency. Same shape as
// `lifecycle`'s property walk.
let mut rng: u64 = 0x0bad_f00d_dead_beef;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(rng >> 33) as u32
};
for _ in 0..20_000 {
let count = (next() % 6) as usize;
let members: Vec<Member> = (0..count)
.map(|_| {
// A slot only sometimes, and from a small pool so collisions with the pin table
// are frequent; widths include 0 and the odd negative.
let slot = match next() % 4 {
0 => None,
_ => Some(next() % 6 + 1),
};
let width = match next() % 8 {
0 => 0,
1 => -((next() % 4000) as i32),
_ => (next() % 4000) as i32,
};
m(slot, width)
})
.collect();
let mut pairs: Vec<(String, i32, i32)> = Vec::new();
for slot in 1..=6u32 {
if next() % 2 == 0 {
let x = (next() % 8000) as i32 - 2000;
let y = ((next() % 3) * 1440) as i32;
pairs.push((slot.to_string(), x, y));
}
}
let borrowed: Vec<(&str, i32, i32)> =
pairs.iter().map(|(k, x, y)| (k.as_str(), *x, *y)).collect();
for layout in [Layout::default(), manual(&borrowed)] {
let out = arrange(&members, &layout);
assert_eq!(out.len(), members.len());
let pinned: Vec<bool> = members
.iter()
.map(|mem| pin_of(mem, &layout).is_some())
.collect();
for i in 0..out.len() {
for j in (i + 1)..out.len() {
if pinned[i] && pinned[j] {
continue; // the operator's own arrangement
}
let span = |k: usize| {
let x = out[k].x as i64;
(x, x + members[k].width.max(0) as i64)
};
let (ai, bi) = span(i);
let (aj, bj) = span(j);
// Empty spans (a zero/negative width) can't collide with anything.
if ai >= bi || aj >= bj {
continue;
}
assert!(
bi <= aj || bj <= ai,
"members {i} {:?} and {j} {:?} overlap under {layout:?} \
(widths {} / {})",
out[i],
out[j],
members[i].width,
members[j].width
);
}
}
}
}
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -5,10 +5,18 @@
use super::*;
/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
/// — the adapter and the inner stream — and only the advertised id is the correct capture
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
/// Budget for a `pw-dump` snapshot. Two facts make an unbounded one the worst call in this file:
/// it is polled every 300500 ms from three separate 45 s loops, and it talks to the very daemon
/// this module documents gamescope as head-blocking below [`MIN_GAMESCOPE`] — so the failure mode
/// is not "slow", it is "never returns", on the session's own stream thread. Two seconds is far
/// above a populated graph's real cost; every caller already has a "couldn't ask" path.
const PW_DUMP_BUDGET: Duration = Duration::from_secs(2);
/// Budget for a `gamescope --version` probe. It loads the binary and prints a banner — no Vulkan
/// device, no daemon — so anything approaching this bound is a binary that cannot run at all,
/// which is exactly what a `None`/`false` answer means to each caller.
const VERSION_PROBE_BUDGET: Duration = Duration::from_secs(2);
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
@@ -159,16 +167,51 @@ pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
}
}
/// Wait for a freshly spawned gamescope to report its PipeWire node. Authoritative source:
/// gamescope's own log line `stream available on node ID: N` (its node carries
/// `node.name=gamescope` on TWO objects — the adapter and the inner stream — and only the
/// advertised id is the correct capture target). Falls back, at the deadline, to `pw-dump`
/// discovery SCOPED to this spawn's process tree (`child`'s pid, A5), so a coexisting gamescope's
/// node is never mistaken for ours.
///
/// Takes the `Child` rather than a bare pid so it can **stop early when gamescope is already
/// dead**. A gamescope that fails `vkCreateDevice` exits in under a second, and polling its corpse
/// for the full 15 s bought nothing except a caller error that blamed the wrong thing ("headless
/// capture is unsupported on this GPU/driver"). `try_wait` turns that into an immediate `None`
/// while the log — which the caller names in the same error — still holds the real reason.
pub(super) fn wait_for_node(
timeout: Duration,
log: &std::path::Path,
child_pid: u32,
child: &mut Child,
) -> Option<u32> {
let child_pid = child.id();
let deadline = Instant::now() + timeout;
loop {
if let Some(id) = node_from_log(log) {
return Some(id);
}
// Check for a node FIRST, then for death: a gamescope that published its node and then
// exited in the same tick still gives us the id, and the caller's own liveness handling
// (the keepalive `Child`, `kept_display_alive`) owns what happens next.
match child.try_wait() {
// Still running — keep waiting.
Ok(None) => {}
// Exited. One last scoped look (the node line may have been written between the two
// reads above), then give up rather than poll a corpse to the deadline.
Ok(Some(status)) => {
tracing::warn!(
pid = child_pid,
%status,
log = %log.display(),
"gamescope: the spawned process exited before publishing a PipeWire node — \
not waiting out the rest of the budget"
);
return node_from_log(log).or_else(|| find_gamescope_node_scoped(Some(child_pid)));
}
// `try_wait` itself failed (the child was reaped elsewhere, ECHILD): fall back to the
// old behaviour rather than inventing a death.
Err(_) => {}
}
if Instant::now() >= deadline {
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
// node isn't picked by mistake.
@@ -197,7 +240,10 @@ fn node_from_log(log: &std::path::Path) -> Option<u32> {
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
pub(super) fn gamescope_node_present(node_id: u32) -> bool {
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
let Ok(out) = crate::proc::output_within(
Command::new("pw-dump").arg(node_id.to_string()),
PW_DUMP_BUDGET,
) else {
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
return true;
};
@@ -229,7 +275,7 @@ pub(super) fn find_gamescope_node() -> Option<u32> {
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
let out = Command::new("pw-dump").output().ok()?;
let out = crate::proc::output_within(&mut Command::new("pw-dump"), PW_DUMP_BUDGET).ok()?;
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let nodes = dump.as_array()?;
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
@@ -302,7 +348,12 @@ fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
/// most recently created (the live session). Returns the bare socket *name* (the injector
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
pub(super) fn find_gamescope_eis_socket() -> Option<String> {
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
// Under the shared env lock: `session::apply_session_env` `set_var`s XDG_RUNTIME_DIR from the
// connect thread, and glibc's setenv/getenv pair is a data race the crate's own `lib.rs`
// documents as UB. The lock is not reentrant, so this must stay a read taken HERE and not
// hoisted into a caller — the only caller, `point_injector_at_eis`, holds nothing (its
// `ei_socket_file()` takes and releases the same lock separately).
let runtime = crate::with_env_lock(|| std::env::var("XDG_RUNTIME_DIR").ok())?;
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
@@ -328,11 +379,12 @@ pub(super) fn find_gamescope_eis_socket() -> Option<String> {
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
/// create path); just checks the binary executes.
pub(crate) fn is_available() -> bool {
std::process::Command::new(gamescope_bin())
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
crate::proc::output_within(
Command::new(gamescope_bin()).arg("--version"),
VERSION_PROBE_BUDGET,
)
.map(|o| o.status.success())
.unwrap_or(false)
}
/// The gamescope binary this host spawns, resolved ONCE per process:
@@ -400,14 +452,20 @@ fn which_in_path(name: &str) -> Option<String> {
///
/// Monotonic, so one probe answers every capability:
/// * `1` — 10-bit BT.2020/PQ capture formats ([`gamescope_hdr_capable`]);
/// * `2` — …and `--pipewire-composite-cursor` ([`gamescope_can_composite_cursor`]).
/// * `2` — …and `--pipewire-composite-cursor` ([`gamescope_can_composite_cursor`]);
/// * `3` — …and `--custom-refresh-rates` ([`gamescope_can_offer_refresh_rates`]);
/// * `4` — …and `--pipewire-composite-external-overlay`
/// ([`gamescope_can_composite_external_overlay`]).
///
/// When upstream takes the functional patches this becomes a plain version floor, exactly like
/// [`MIN_GAMESCOPE_OVERLAY`].
fn gamescope_patch_level() -> u32 {
static LEVEL: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
*LEVEL.get_or_init(|| {
let Ok(out) = Command::new(gamescope_bin()).arg("--version").output() else {
let Ok(out) = crate::proc::output_within(
Command::new(gamescope_bin()).arg("--version"),
VERSION_PROBE_BUDGET,
) else {
return 0;
};
// The banner goes to stderr on some builds, stdout on others (same as the version gate).
@@ -530,7 +588,8 @@ fn parse_patch_level(banner: &str) -> u32 {
/// WSI-layer check has to compare TWO binaries — ours and the distro's — and a `None` there means
/// "leave the layer alone", not "assume old".
pub(super) fn gamescope_version_of(bin: &std::path::Path) -> Option<(u32, u32, u32)> {
let out = Command::new(bin).arg("--version").output().ok()?;
let out = crate::proc::output_within(Command::new(bin).arg("--version"), VERSION_PROBE_BUDGET)
.ok()?;
// Same stdout/stderr split as the version gate: builds disagree on where the banner goes.
let text = format!(
"{}{}",
@@ -549,8 +608,15 @@ const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
/// the overlay-window paint (gated on the consumer negotiating `gamescope_focus_appid == 0`, which
/// we do by never advertising that property — see the capturer's EnumFormat builders) first ships
/// in 3.16.23 (gamescope commits `ccd62074` + `f8b33d38`). Below this the overlay is *never* in the
/// node, so it cannot appear in the stream no matter what the host does. The cursor and
/// external-overlay / notification layers are excluded on *every* version (handled host-side).
/// node, so it cannot appear in the stream no matter what the host does.
///
/// On a **stock** gamescope the cursor and external-overlay / notification layers are excluded from
/// `paint_pipewire` on every version, and the host handles the cursor itself. punktfunk's own build
/// puts both back: `--pipewire-composite-cursor` at patch level 2+
/// ([`gamescope_can_composite_cursor`], which is what suppresses the host-side blend) and
/// `--pipewire-composite-external-overlay` at 4+ ([`gamescope_can_composite_external_overlay`]) —
/// see [`gamescope_patch_level`]. So "the overlay is missing from the stream" is a question about
/// which flags reached the running compositor, not about host-side compositing.
const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
/// Best-effort: warn if the installed gamescope is older than [`MIN_GAMESCOPE`] (capture is
@@ -558,10 +624,11 @@ const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
/// diagnostic, not a gate. Returns the parsed version when it could read one.
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
let out = Command::new(gamescope_bin())
.arg("--version")
.output()
.ok()?;
let out = crate::proc::output_within(
Command::new(gamescope_bin()).arg("--version"),
VERSION_PROBE_BUDGET,
)
.ok()?;
// gamescope prints the version banner to stderr on some builds, stdout on others.
let text = format!(
"{}{}",
@@ -34,24 +34,28 @@ pub(crate) fn list_monitors() -> anyhow::Result<Vec<PhysicalMonitor>> {
Ok(heads_under(
Path::new("/sys/class/drm"),
&super::gamescope_argvs(),
super::current_gamescope_output_size(),
))
}
/// [`list_monitors`] against an arbitrary sysfs root and a supplied argv set — the unit-testable
/// core. `output_size` is gamescope's own `-W`/`-H`, which OUTRANKS the EDID's preferred timing
/// because it is the size the capture node actually produces.
fn heads_under(
base: &Path,
argvs: &[Vec<String>],
output_size: Option<(u32, u32)>,
) -> Vec<PhysicalMonitor> {
/// core.
///
/// The head's size comes from the `-W`/`-H` of the argv selected HERE, which OUTRANKS the EDID's
/// preferred timing because it is the size the capture node actually produces. It used to arrive as
/// a parameter filled by a scan over ALL gamescopes on the box — including the nested child this
/// function had just deliberately rejected, and any headless one the crate spawned itself. On a
/// Deck driving eDP-1 at 1280x800 with a game nested at `-W 1920 -H 1080`, the panel was listed as
/// 1920x1080, and `mirror::create` publishes that row verbatim as the `preferred_mode` the stream
/// negotiates against — a mode the composited node never produces, and one `check_mirrorable` waves
/// through because it only rejects `0x0`.
fn heads_under(base: &Path, argvs: &[Vec<String>]) -> Vec<PhysicalMonitor> {
// A gamescope that isn't on DRM has no head of its own. Any DRM-backed one qualifies the box:
// a Deck streaming from Game Mode often has a second, nested gamescope running the game inside
// the session one, and that child must not disqualify its parent.
let Some(argv) = argvs.iter().find(|a| drives_drm(a)) else {
return Vec::new();
};
let output_size = super::gamescope_output_size(argv);
let connected = connected_connectors(base);
if connected.is_empty() {
return Vec::new();
@@ -342,7 +346,6 @@ mod tests {
let heads = heads_under(
&base,
&[argv("/usr/bin/gamescope --prefer-output HDMI-A-1 --steam")],
None,
);
assert_eq!(heads.len(), 1);
assert_eq!(heads[0].connector, "HDMI-A-1");
@@ -366,12 +369,12 @@ mod tests {
"gamescope --backend sdl",
] {
assert!(
heads_under(&base, &[argv(a)], None).is_empty(),
heads_under(&base, &[argv(a)]).is_empty(),
"expected no heads for {a:?}"
);
}
// No gamescope at all is the same answer, not an error.
assert!(heads_under(&base, &[], None).is_empty());
assert!(heads_under(&base, &[]).is_empty());
std::fs::remove_dir_all(&base).unwrap();
}
@@ -384,12 +387,15 @@ mod tests {
&base,
&[
argv("gamescope --backend wayland -W 1280 -H 800"),
argv("/usr/bin/gamescope --prefer-output *,eDP-1 --steam"),
argv("/usr/bin/gamescope --prefer-output *,eDP-1 -W 2560 -H 1440 --steam"),
],
None,
);
assert_eq!(heads.len(), 1);
assert_eq!(heads[0].connector, "eDP-1");
// …and the size comes from the DRM PARENT, not from the nested child listed first. Reading
// it off any-gamescope-on-the-box is what published a 1280x800 panel as the mirror's
// preferred mode on a box where the game happened to be nested at a different size.
assert_eq!((heads[0].width, heads[0].height), (2560, 1440));
std::fs::remove_dir_all(&base).unwrap();
}
@@ -404,7 +410,7 @@ mod tests {
("card1-HDMI-A-1", "connected\n", "enabled\n"),
],
);
let heads = heads_under(&base, &[argv("gamescope --prefer-output *,eDP-1")], None);
let heads = heads_under(&base, &[argv("gamescope --prefer-output *,eDP-1")]);
assert_eq!(heads.len(), 1);
assert_eq!(heads[0].connector, "eDP-1");
std::fs::remove_dir_all(&base).unwrap();
@@ -421,7 +427,7 @@ mod tests {
("card1-HDMI-A-1", "connected\n", "enabled\n"),
],
);
let heads = heads_under(&base, &[argv("gamescope --steam")], None);
let heads = heads_under(&base, &[argv("gamescope --steam")]);
assert_eq!(
heads
.iter()
@@ -440,7 +446,7 @@ mod tests {
"unplugged",
&[("card1-HDMI-A-1", "disconnected\n", "disabled\n")],
);
assert!(heads_under(&base, &[argv("gamescope --steam")], None).is_empty());
assert!(heads_under(&base, &[argv("gamescope --steam")]).is_empty());
std::fs::remove_dir_all(&base).unwrap();
}
@@ -452,7 +458,6 @@ mod tests {
let heads = heads_under(
&base,
&[argv("gamescope -W 2560 -H 1440 --prefer-output HDMI-A-1")],
Some((2560, 1440)),
);
assert_eq!((heads[0].width, heads[0].height), (2560, 1440));
std::fs::remove_dir_all(&base).unwrap();
@@ -511,7 +516,6 @@ mod tests {
&[argv(
"gamescope --nested-refresh 30 --prefer-output HDMI-A-1",
)],
None,
);
assert_eq!(heads[0].refresh_mhz, 60_000);
assert_eq!(heads[0].mode_label(), "1920x1080@60");
@@ -539,7 +543,7 @@ mod tests {
"3840x2160\n1920x1080\n",
)
.unwrap();
let heads = heads_under(&base, &[argv("gamescope --steam")], None);
let heads = heads_under(&base, &[argv("gamescope --steam")]);
assert_eq!((heads[0].width, heads[0].height), (3840, 2160));
std::fs::remove_dir_all(&base).unwrap();
}
@@ -143,17 +143,57 @@ pub(crate) fn run() -> Result<()> {
}
}
/// How long the splash waits for the session's X server before giving up.
const CONNECT_BUDGET: Duration = Duration::from_secs(10);
/// Connect to the session's `DISPLAY`, retrying briefly — gamescope sets the variable before
/// exec'ing the nested command, but a slow Xwayland under cold driver init gets a grace window.
///
/// The retry runs on a worker thread and the budget is enforced by `recv_timeout` rather than by
/// re-checking a deadline between attempts. The difference is the whole point: `x11rb::connect`
/// has no timeout of its own, so against an Xwayland that ACCEPTED the socket and then never
/// answered the setup handshake it blocks indefinitely — and a deadline consulted only in the
/// `Err` arm is never reached at all. That is the failure this module exists to prevent, from the
/// inside: no painting client, no composite, no PipeWire buffers, and the capture dies on its 10 s
/// first-frame timeout having never logged "gamescope splash: mapped", so the diagnosis points
/// anywhere but here.
///
/// A worker still stuck in `connect` is abandoned rather than joined; it is one thread in a
/// process whose whole job is this window, and the alternative is the hang.
fn connect_with_retry() -> Result<(RustConnection, usize)> {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
match x11rb::connect(None) {
Ok(ok) => return Ok(ok),
Err(e) if std::time::Instant::now() >= deadline => {
return Err(e).context("gamescope splash: could not connect to the session DISPLAY")
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("pf-splash-x11-connect".into())
.spawn(move || {
let deadline = std::time::Instant::now() + CONNECT_BUDGET;
loop {
match x11rb::connect(None) {
Ok(ok) => {
let _ = tx.send(Ok(ok));
return;
}
Err(e) if std::time::Instant::now() >= deadline => {
let _ = tx.send(Err(e));
return;
}
Err(_) => std::thread::sleep(Duration::from_millis(200)),
}
}
Err(_) => std::thread::sleep(Duration::from_millis(200)),
})
.context("gamescope splash: could not start the X connect thread")?;
// A little past the worker's own deadline, so a connect that merely finished slowly still wins
// and only a genuinely blocked one trips this.
match rx.recv_timeout(CONNECT_BUDGET + Duration::from_secs(1)) {
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => Err(e).context("gamescope splash: could not connect to the session DISPLAY"),
Err(_) => {
tracing::warn!(
secs = CONNECT_BUDGET.as_secs(),
"gamescope splash: the session's X server accepted no connection and never \
answered giving up. Nothing will paint in this gamescope, so it will composite \
nothing and the capture will starve; the gamescope log is where the reason is."
);
anyhow::bail!("gamescope splash: connecting to the session DISPLAY did not return")
}
}
}
+239 -23
View File
@@ -5,9 +5,10 @@
//! protocols, so it shares the wlr virtual-input path with sway — but it needs its own IPC and
//! portal, so it is a **distinct backend** from [`super::wlroots`], not a branch inside it (D1):
//!
//! 1. `hyprctl output create headless PF-<n>` adds a named headless output — Hyprland supports
//! 1. `hyprctl output create headless PF-<pid>-<n>` adds a named headless output — Hyprland supports
//! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll
//! `hyprctl -j monitors` until the name shows up.
//! `hyprctl -j monitors` until the name shows up. The creator's pid rides in the name so a
//! crashed host's leftovers are attributable, and only those (see [`reclaim_leftovers_once`]).
//! 2. A monitor rule sets the client's exact mode. [`set_monitor_rule`] uses `hyprctl keyword
//! monitor NAME,WxH@Hz,auto,1` (the hyprlang path — the default config manager on every current
//! release, ≥0.55 included) and falls back to the Lua `hyprctl eval 'hl.monitor{…}'` only for a
@@ -69,12 +70,46 @@ fn picker_selection_line(name: &str) -> String {
format!("[SELECTION]screen:{name}\n")
}
/// Monotonic per-process counter for headless output names (`PF-1`, `PF-2`, …). Named outputs kill
/// the before/after diff race sway needs (D6).
/// Monotonic per-process counter for headless output names (`PF-<pid>-1`, `PF-<pid>-2`, …). Named
/// outputs kill the before/after diff race sway needs (D6).
static OUTPUT_SEQ: AtomicU32 = AtomicU32::new(0);
/// The name for our next headless output: `PF-<pid>-<n>`.
///
/// The pid is not decoration. `OutputGuard::drop` is the only thing that removes an output, so a
/// host that was SIGKILLed leaves its outputs in the compositor — and a bare `PF-<n>` counter starts
/// again at `PF-1` in the next process, colliding with the corpses it just inherited. Stamping the
/// creator's pid into the name makes a leftover both recognisable and *attributable*, which is what
/// lets [`reclaim_leftovers_once`] remove only the ones whose owner is gone.
fn next_output_name() -> String {
format!("PF-{}", OUTPUT_SEQ.fetch_add(1, Ordering::Relaxed) + 1)
format!(
"PF-{}-{}",
std::process::id(),
OUTPUT_SEQ.fetch_add(1, Ordering::Relaxed) + 1
)
}
/// Is `name` an output some punktfunk host created (`PF-<pid>-<n>`, or a legacy `PF-<n>`)? Pure —
/// this is what [`list_monitors`] reports as `managed`, so a user's own monitor called `PF-office`
/// must not qualify.
fn is_managed_output(name: &str) -> bool {
let Some(rest) = name.strip_prefix("PF-") else {
return false;
};
!rest.is_empty()
&& rest
.split('-')
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
}
/// The pid of the host that created `name`, for `PF-<pid>-<n>` only. `None` for anything else —
/// including a legacy `PF-<n>` from a host older than this naming scheme, which carries no owner and
/// therefore may not be reclaimed on a guess.
fn output_owner_pid(name: &str) -> Option<u32> {
let rest = name.strip_prefix("PF-")?;
let (pid, seq) = rest.split_once('-')?;
seq.parse::<u32>().ok()?;
pid.parse::<u32>().ok()
}
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
@@ -100,11 +135,24 @@ impl HyprlandDisplay {
/// under `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env
/// import, unlike sway's `SWAYSOCK`; the signature is then exported by `apply_session_env`). Cheap,
/// side-effect-free — safe on the enumeration path.
///
/// Both env reads take [`crate::with_env_lock`] — in ONE scope, so the pair is sampled from a single
/// consistent view. This runs on a management worker (`/host/compositors` → [`crate::available`])
/// concurrently with another connect's `apply_session_env`, which `set_var`s the signature for a
/// live Hyprland session and `remove_var`s it for anything else; a glibc `getenv` racing that
/// `setenv`/`unsetenv` is the `environ` realloc data race ENV_LOCK exists for. No caller holds the
/// lock (it is not reentrant), and the `read_dir` below deliberately runs outside it.
pub fn is_available() -> bool {
if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() {
let (sig, runtime) = crate::with_env_lock(|| {
(
std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE"),
std::env::var_os("XDG_RUNTIME_DIR"),
)
});
if sig.is_some() {
return true;
}
let dir = match std::env::var_os("XDG_RUNTIME_DIR") {
let dir = match runtime {
Some(d) => std::path::PathBuf::from(d).join("hypr"),
None => return false,
};
@@ -147,6 +195,9 @@ impl VirtualDisplay for HyprlandDisplay {
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Log the permission-system caveat once per process (silent black frames otherwise).
preflight_once();
// Remove any output a PREVIOUS host left in this compositor, before we mint our first.
reclaim_leftovers_once();
warn_topology_is_extend_only();
let name = next_output_name();
hyprctl_dispatch(&["output", "create", "headless", &name]).with_context(|| {
@@ -181,7 +232,7 @@ impl VirtualDisplay for HyprlandDisplay {
remote_fd: Some(fd),
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
keepalive: Box::new(Keepalive {
_stop: StopGuard(stop),
_stop: stop,
_output: output,
}),
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
@@ -212,6 +263,62 @@ impl Drop for StopGuard {
}
}
/// Remove the `PF-<pid>-<n>` outputs left behind by host processes that are **gone**, once per
/// process before we create our first.
///
/// [`OutputGuard::drop`] is the only unplug path there is, so a host that was SIGKILLed, OOM-killed
/// or crashed leaves its headless outputs in the compositor for as long as the Hyprland session
/// lives — a dead `PF-…` head in the operator's layout, forever, with the next host start happily
/// adding more beside it. Reclaim is keyed on the OWNER pid in the name and only removes an output
/// whose creator no longer exists, so a second live host on the same session (or this very process)
/// can never have its output pulled out from under it. `Once` puts the sweep strictly before this
/// process owns anything, and blocks a concurrent first `create` until it is done.
fn reclaim_leftovers_once() {
static RECLAIMED: Once = Once::new();
RECLAIMED.call_once(|| {
let Ok(names) = monitor_names() else { return };
for name in names {
let Some(pid) = output_owner_pid(&name) else {
// Either not ours, or a legacy `PF-<n>` with no owner recorded — which we must not
// remove on a guess, because a still-running older host may be streaming it.
if is_managed_output(&name) {
tracing::debug!(output = %name, "a managed headless output with no owner pid in \
its name (an older host build) left alone");
}
continue;
};
if pid == std::process::id() || std::path::Path::new(&format!("/proc/{pid}")).exists() {
continue;
}
match hyprctl_dispatch(&["output", "remove", &name]) {
Ok(()) => tracing::info!(output = %name, owner_pid = pid, "removed a headless \
output left behind by a host that is no longer running"),
Err(e) => tracing::warn!(output = %name, owner_pid = pid, error = %format!("{e:#}"),
"could not remove a leftover headless output"),
}
}
});
}
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
/// (sweep 13.18). The Hyprland headless output is always an EXTENSION: nothing here promotes it to
/// primary or disables the operator's heads.
fn warn_topology_is_extend_only() {
let topology = crate::effective_topology();
if !matches!(
topology,
crate::policy::Topology::Extend | crate::policy::Topology::Auto
) {
tracing::warn!(
?topology,
"hyprland: this backend implements EXTEND only — the headless output is added beside \
the operator's heads and nothing is promoted or disabled. Configure `topology: extend` \
to stop the console promising otherwise."
);
}
}
/// Owns the created headless output; dropping it removes it from Hyprland.
struct OutputGuard(String);
@@ -226,14 +333,25 @@ impl Drop for OutputGuard {
}
}
/// Budget for one `hyprctl` call ([`crate::proc`]).
///
/// `hyprctl` is a client of the compositor it drives — it connects to the instance socket and waits
/// for a reply, so against a wedged Hyprland it never returns. These calls run on the session's
/// stream thread, whose only way to end a session is to return, so one hung query used to wedge the
/// session for good. Generous next to a healthy call (single-digit milliseconds), and every call
/// site already has a failed-query path.
const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
/// Budget for the one-shot xdph restart. `systemctl --user try-restart` waits for the user manager's
/// job to settle, so it is the slowest helper on this path — and its result is already ignored.
const PORTAL_RESTART_BUDGET: Duration = Duration::from_secs(10);
/// Run `hyprctl <args>`, returning stdout. `hyprctl` reads `HYPRLAND_INSTANCE_SIGNATURE` from the
/// env (exported by `apply_session_env`) to reach the right instance socket. It exits non-zero on a
/// hard failure, but for dispatch commands it can print an error with status 0 — see
/// [`hyprctl_dispatch`].
fn hyprctl(args: &[&str]) -> Result<String> {
let out = Command::new("hyprctl")
.args(args)
.output()
let out = crate::proc::output_within(Command::new("hyprctl").args(args), HYPRCTL_BUDGET)
.context("run hyprctl (is Hyprland installed?)")?;
if !out.status.success() {
bail!(
@@ -251,12 +369,36 @@ fn hyprctl(args: &[&str]) -> Result<String> {
/// write between ours and xdph's read would silently steer capture at the other session's output.
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The per-session selection file, removed when the handshake it steers is over.
///
/// Its lifetime is the HANDSHAKE, not the session: the shim cats it once, inside
/// [`select_and_cast`]'s critical section, and everything after that is the cast's own business.
/// Left behind (as it was) the stale `[SELECTION]screen:PF-…` outlives the output `Drop` has since
/// removed, and it permanently shadows xdph's documented empty-read fallback — every later capture
/// that reaches the picker without a session of ours is steered at an output that is gone. Tying
/// removal to the CAST instead would be worse: the file is one per user, so a session ending hours
/// later would delete a *sibling's* selection out from under its picker.
struct SelectionFile(String);
impl Drop for SelectionFile {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_file(&self.0) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::debug!(path = %self.0, error = %e, "could not remove the xdph selection file");
}
}
}
}
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
ensure_xdph_config()?;
let sel = selection_file();
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
// only thing that reads it.
let _sel_file = SelectionFile(sel);
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
@@ -264,8 +406,16 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<A
.name("punktfunk-hypr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn hyprland portal thread")?;
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
// arms dropping an un-set flag: the thread's `send` can still LAND in the queue in the window
// between `recv_timeout` giving up and `setup_rx` being dropped, so it reports success and then
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
// lifetime, against an output that no longer exists.
let guard = StopGuard(stop);
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
@@ -285,7 +435,7 @@ pub(crate) fn stream_existing_output(
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
keepalive: Box::new(StopGuard(stop)),
keepalive: Box::new(stop),
})
}
@@ -330,11 +480,12 @@ pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
.unwrap_or(1.0),
primary: m.get("focused").and_then(|v| v.as_bool()).unwrap_or(false),
enabled: !m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false),
// Our headless outputs are named `PF-<n>` (see `next_output_name`).
// Our headless outputs are named `PF-<pid>-<n>` (see `next_output_name`); the shape
// is checked, not just the prefix, so a user's own `PF-office` stays theirs.
managed: m
.get("name")
.and_then(|v| v.as_str())
.is_some_and(|n| n.starts_with("PF-")),
.is_some_and(is_managed_output),
})
})
.collect();
@@ -382,6 +533,23 @@ fn wait_monitor_ready(name: &str, timeout: Duration) -> Result<()> {
}
}
/// Every monitor name Hyprland reports, **disabled ones included** (`-j monitors all`) — a leftover
/// output from a dead host may well have ended up disabled, and [`reclaim_leftovers_once`] must see
/// it anyway.
fn monitor_names() -> Result<Vec<String>> {
let out = hyprctl(&["-j", "monitors", "all"])?;
let monitors: serde_json::Value =
serde_json::from_str(&out).context("parse hyprctl -j monitors all")?;
Ok(monitors
.as_array()
.map(|a| {
a.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(str::to_owned))
.collect()
})
.unwrap_or_default())
}
/// Is a monitor named `name` present in `hyprctl -j monitors` (JSON)?
fn monitor_exists(name: &str) -> Result<bool> {
let out = hyprctl(&["-j", "monitors"])?;
@@ -417,17 +585,33 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
);
let keyword: Vec<&str> = vec!["keyword", "monitor", &spec];
let eval: Vec<&str> = vec!["eval", &lua];
// What each form actually said. hyprctl reports a rejection in its OUTPUT TEXT ("eval is only
// supported with the lua config manager", "invalid monitor rule", a permission denial), and
// dropping it on the floor with `.is_err()` is what left the failure below guessing at GBM when
// the compositor had already named the real cause.
let mut attempts: Vec<String> = Vec::new();
for a in [&keyword, &eval] {
// A wrong-era command errors (`keyword` gone under Lua, or `eval` under hyprlang) — skip to
// the other form. A command that's accepted then has up to the timeout to take effect.
if hyprctl_dispatch(a).is_err() {
if let Err(e) = hyprctl_dispatch(a) {
let said = format!("{e:#}");
tracing::debug!(output = %name, cmd = ?a, error = %said, "hyprctl rejected this monitor-rule form — trying the other config era");
attempts.push(said);
continue;
}
if wait_exact_mode(name, mode, Duration::from_millis(1500)) {
tracing::debug!(output = %name, cmd = ?a, w = mode.width, h = mode.height, "monitor adopted the requested mode");
return Ok(());
}
attempts.push(format!(
"hyprctl {a:?} was accepted but the mode never took effect"
));
}
let said = if attempts.is_empty() {
"nothing (no form was attempted)".to_string()
} else {
attempts.join("; ")
};
// Neither form produced the exact mode. Distinguish "usable but different size" (proceed with a
// warning — a working stream beats none) from "0×0 / gone" (the output has no framebuffer at all).
match monitor_size(name)? {
@@ -436,14 +620,20 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
output = %name,
requested = %format!("{}x{}", mode.width, mode.height),
got = %format!("{w}x{h}"),
hyprctl = %said,
"Hyprland did not adopt the exact requested mode — streaming at the output's current size"
);
Ok(())
}
// The output has no framebuffer at all. Lead with what hyprctl SAID: if every form was
// rejected the cause is named right there (wrong config era, a permission denial, a bad
// rule) and no allocation was ever attempted; only a form that was accepted and still left
// the output at 0×0 points at the compositor failing to back the mode.
_ => bail!(
"headless output {name} never got a framebuffer (stayed 0x0) after the monitor rule for \
{}x{}@{hz} the compositor could not back the mode, likely a headless GBM/dmabuf \
allocation failure (GPU driver; cf. Sunshine#4197). Check the Hyprland log.",
{}x{}@{hz}. hyprctl said: {said}. If a form was accepted, the compositor could not back \
the mode likely a headless GBM/dmabuf allocation failure (GPU driver; cf. \
Sunshine#4197). Check the Hyprland log.",
mode.width,
mode.height
),
@@ -574,13 +764,17 @@ fn ensure_xdph_config() -> Result<()> {
return Ok(());
}
tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-hyprland at the managed picker shim");
let _ = Command::new("systemctl")
.args([
// Bounded: `systemctl --user` blocks on the user manager's job queue, and this runs on the
// session's stream thread. Its result was already ignored — a timeout just means xdph picks the
// new config up whenever it next starts.
let _ = crate::proc::status_within(
Command::new("systemctl").args([
"--user",
"try-restart",
"xdg-desktop-portal-hyprland.service",
])
.status();
]),
PORTAL_RESTART_BUDGET,
);
Ok(())
}
@@ -702,6 +896,28 @@ mod tests {
assert_ne!(a, b);
}
/// The name carries the creating host's pid, which is what makes a leftover attributable — a
/// reclaim that could not tell whose output it was would have to remove a LIVE sibling's or
/// nothing at all.
#[test]
fn a_name_carries_its_owner_pid_and_only_ours_does() {
let mine = next_output_name();
assert_eq!(output_owner_pid(&mine), Some(std::process::id()));
assert!(is_managed_output(&mine));
// A legacy `PF-<n>` from an older host: recognisably managed, but with no owner recorded —
// so it may be reported, never reclaimed on a guess.
assert!(is_managed_output("PF-1"));
assert_eq!(output_owner_pid("PF-1"), None);
// Not ours: a user's own monitor name that happens to start with the prefix, and the
// connectors every wlr-family compositor mints.
for theirs in ["PF-office", "PF-", "PF-12-abc", "HEADLESS-1", "DP-1", ""] {
assert!(!is_managed_output(theirs), "{theirs:?} is not ours");
assert_eq!(output_owner_pid(theirs), None, "{theirs:?} has no owner");
}
}
#[test]
fn picker_line_carries_the_selection_marker() {
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
+633 -127
View File
@@ -33,7 +33,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
use wayland_client::protocol::wl_callback::{self, WlCallback};
use wayland_client::protocol::wl_output::{self, WlOutput};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
@@ -237,7 +238,20 @@ impl VirtualDisplay for KwinDisplay {
Some(id) => format!("{VOUT_NAME}-{id}"),
None => VOUT_NAME.to_string(),
};
self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout)
// `apply_position`'s kscreen-doctor fallback (the registry-driven §6.2 layout) addresses
// `last_name`, so seed it with `Virtual-<name>`: the address KWin exposes our output under
// and the ONLY spelling kscreen-doctor can resolve. The bare `name` we ask KWin for
// (`punktfunk`) matches no output at all, so seeding it with that left every position apply
// shelling out against an address that can never exist — and the `is_none()` guard that was
// supposed to correct it later could never fire, because this write is never `None`.
let our_prefix = format!("Virtual-{name}");
self.last_name = Some(our_prefix.clone());
// Every `create` re-resolves its own output, so the PREVIOUS one's UUID must not survive
// into this one. A supersede keeps this `KwinDisplay` and creates the replacement while the
// predecessor is still alive, so a stale UUID still RESOLVES: `set_position` would find the
// old output, position it, report success, and never reach the fallback — the new display
// silently stays where it was born. Re-set below only if the in-process path handles us.
self.our_uuid = None;
let (width, height) = (mode.width, mode.height);
let pointer_mode = if self.hw_cursor {
POINTER_METADATA
@@ -255,10 +269,18 @@ impl VirtualDisplay for KwinDisplay {
virtual_output_thread(w, h, name_thread, pointer_mode, setup_tx, stop_thread)
})
.context("spawn KWin virtual-output thread")?;
match setup_rx.recv_timeout(Duration::from_secs(20)) {
match setup_rx.recv_timeout(OPENER_BUDGET) {
Ok(Ok(v)) => Ok((v, stop)),
Ok(Err(e)) => bail!("KWin virtual output failed: {e}"),
Err(_) => bail!("timed out creating the KWin virtual output"),
Err(_) => {
// Nothing else will ever flip this `stop`: it is dropped with the error, and
// the `StopGuard` that normally owns it is only built on the success path. So
// the worker — which is by construction still inside `await_created` — would
// sit out its own budget holding a half-built output whose Wayland connection
// KWin keeps the output alive for. Release it here.
stop.store(true, Ordering::Relaxed);
bail!("timed out creating the KWin virtual output")
}
}
};
// KWin creates virtual outputs at a hardcoded 60 Hz, `stream_virtual_output` has no
@@ -293,8 +315,8 @@ impl VirtualDisplay for KwinDisplay {
);
// Topology + positioning address OUR output by its kde_output_management UUID (resolved
// in-process in `apply_topology`, supersede-robust) — no early kscreen-doctor resolve, so
// the path never shells out. `Virtual-<name>` is the name KWin exposes our output as.
let our_prefix = format!("Virtual-{name}");
// the path never shells out. `our_prefix` (computed above with `last_name`) is the name
// KWin exposes our output as.
let mut expect_exact_dims = false;
// The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to
// shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's
@@ -372,12 +394,11 @@ impl VirtualDisplay for KwinDisplay {
// kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
// is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
let disabled = self.apply_topology(&name, &our_prefix, final_dims);
// A plain managed name is enough for apply_position's kscreen-doctor fallback when the
// in-process UUID path isn't set (single-output sessions are unambiguous; a supersede uses
// the UUID path instead). `want_high` already set `last_name` to the resolved kscreen id.
if self.last_name.is_none() {
self.last_name = Some(our_prefix);
}
// `last_name` is already the best address we have: `Virtual-<name>` from the top of this
// function, upgraded in place to the RESOLVED numeric kscreen id by whichever of the
// `want_high` fallback or `apply_topology`'s fallback actually ran a resolve. Nothing to
// fill in here — the guard that used to sit at this spot could never fire (`last_name` is
// written unconditionally above) and only made the plain-name case look handled.
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
@@ -385,7 +406,16 @@ impl VirtualDisplay for KwinDisplay {
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
self.pending_restore = (!disabled.is_empty()).then(|| {
let disabled = disabled.clone();
// In-process first; fall back to kscreen-doctor if the compositor doesn't answer in budget.
// In-process first; fall back to kscreen-doctor if the compositor doesn't answer in
// budget. **Both halves now return honest verdicts** — `reenable_outputs` reports
// `false` unless every requested output was actually staged (an empty configuration
// used to ack as `applied` and suppress this backstop), and
// `reenable_outputs_kscreen` branches on its own exit status instead of logging
// success unconditionally. Any future extraction of these hand-rolled
// in-process-then-kscreen ladders into one facade must keep that property: a fallback
// arm that returns a value the helper never checked would re-introduce exactly the
// silent-success this pair was fixed for, behind a seam that claims to have one log
// site for every decline.
Box::new(move || {
if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
reenable_outputs_kscreen(&disabled);
@@ -409,6 +439,17 @@ impl VirtualDisplay for KwinDisplay {
/// closure only when the in-process path reports the compositor didn't answer. Called by the registry
/// when the display group's last member is torn down (design §6.1), BEFORE that member's output is
/// reclaimed — so KWin is never momentarily left with zero enabled outputs.
///
/// **This is the last line of defence for a physical monitor**, so it reports what actually
/// happened. It used to discard both `kscreen_ok` verdicts and log restored-everything
/// unconditionally — including when the call had been killed at [`KSCREEN_BUDGET`], i.e. exactly
/// the wedged compositor this fallback exists for, with a screen left dark and a green line in the
/// log saying otherwise.
///
/// Reporting honestly is not the same as *stopping* on a bad verdict, and the difference is
/// [`kscreen_verdict`]'s third state: a helper killed at its budget has told us nothing, and this
/// path must go on to the mode re-assert and the settle in that case exactly as the pre-verdict
/// code did — see the `None` arm below for what skipping them costs.
fn reenable_outputs_kscreen(outputs: &[(String, String)]) {
if outputs.is_empty() {
return;
@@ -420,20 +461,65 @@ fn reenable_outputs_kscreen(outputs: &[(String, String)]) {
.iter()
.map(|(name, _)| format!("output.{name}.enable"))
.collect();
let _ = kscreen_ok(&enable_args);
let enable_verdict = kscreen_verdict(&enable_args);
match enable_verdict {
// It ran and it refused (or could not be run at all). Nothing further to try: both the
// in-process path and this one have now declined, so the outputs stay as `exclusive` left
// them. Say so loudly — a dark monitor with no line in the log is what this whole restore
// chain exists to prevent.
Some(false) => {
tracing::error!(
outputs = ?outputs,
args = ?enable_args,
"KWin: could NOT re-enable the physical/bootstrap outputs (kscreen-doctor refused \
the config, or could not be run, after the in-process restore already declined) \
a monitor may be left dark"
);
return;
}
// Killed at [`KSCREEN_BUDGET`] — which is NOT the same as a refusal, and treating it as one
// is a regression this path already had once. kscreen-doctor applies the config and only
// THEN waits on the compositor before exiting, so a loaded KWin routinely lands the enable
// and still gets killed: the output is lit, and returning here would skip both halves of
// the rest of the restore — the mode re-assert (a 120 Hz panel comes back at the
// EDID-preferred ~60 Hz without it) and the 200 ms settle that keeps KWin from seeing zero
// enabled outputs when the caller reclaims the virtual one right after us (§6.1). The
// second budget this costs on the stream thread is deliberate and bounded, and is what the
// pre-`match` code spent unconditionally.
None => tracing::warn!(
outputs = ?outputs,
args = ?enable_args,
"KWin: kscreen-doctor was killed at its budget re-enabling the physical/bootstrap \
outputs the apply may well have landed, so continuing with the mode restore"
),
Some(true) => {}
}
// THEN re-assert each captured mode, best-effort — a bare re-enable lets KWin fall back to the
// EDID-preferred mode (a 120 Hz panel returns at ~60 Hz); this restores the exact refresh. The
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default.
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default
// a wrong refresh, not a dark screen, which is why only this half degrades to a warn.
let mode_args: Vec<String> = outputs
.iter()
.filter(|(_, mode)| !mode.is_empty())
.map(|(name, mode)| format!("output.{name}.mode.{mode}"))
.collect();
if !mode_args.is_empty() {
let _ = kscreen_ok(&mode_args);
}
let modes_restored = mode_args.is_empty() || kscreen_ok(&mode_args);
std::thread::sleep(Duration::from_millis(200));
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
// `enable_confirmed` rides along on both lines: after a budget kill the enable is *probable*,
// not established, and a log that cannot tell the operator which of the two it is put us here
// in the first place.
let enable_confirmed = enable_verdict == Some(true);
if modes_restored {
tracing::info!(reenabled = ?outputs, enable_confirmed, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
} else {
tracing::warn!(
reenabled = ?outputs,
args = ?mode_args,
enable_confirmed,
"KWin: re-enabled the physical/bootstrap outputs but could not re-assert their captured \
modes they are back at KWin's preferred refresh, not the one they were streaming at"
);
}
}
/// Resolve the kscreen address of the virtual output the host JUST created: the managed-prefix
@@ -488,12 +574,27 @@ const KSCREEN_BUDGET: Duration = Duration::from_secs(5);
/// `kscreen-doctor <args>` run for its exit status, bounded by [`KSCREEN_BUDGET`]. A timeout reads
/// as a failed apply — the same best-effort path a rejected argument already takes.
fn kscreen_ok(args: &[String]) -> bool {
crate::proc::status_within(
kscreen_verdict(args) == Some(true)
}
/// The same call, keeping the outcome that [`kscreen_ok`]'s `bool` throws away.
///
/// `Some(true)`/`Some(false)`: kscreen-doctor ran to completion and accepted / refused (a helper
/// that cannot be spawned at all counts as a refusal — there is nothing to wait for and no reason
/// to retry the next invocation). `None`: it was **killed at [`KSCREEN_BUDGET`]**, which is a
/// different fact entirely. kscreen-doctor applies the config and then waits on the compositor
/// before exiting, so a slow-but-working KWin gives us a kill on a request that already landed;
/// any caller that treats `None` as "it failed" is asserting something it does not know, and for
/// the restore path that assertion costs a monitor its refresh rate.
fn kscreen_verdict(args: &[String]) -> Option<bool> {
match crate::proc::status_within(
std::process::Command::new("kscreen-doctor").args(args),
KSCREEN_BUDGET,
)
.map(|s| s.success())
.unwrap_or(false)
) {
Ok(status) => Some(status.success()),
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => None,
Err(_) => Some(false),
}
}
/// `kscreen-doctor -j` stdout, bounded by [`KSCREEN_BUDGET`]; `None` on any failure.
@@ -511,24 +612,111 @@ fn kscreen_json() -> Option<serde_json::Value> {
serde_json::from_slice(&kscreen_json_bytes()?).ok()
}
/// The `(width, height)` of an output's CURRENT mode from its `kscreen-doctor -j` entry.
fn output_active_size(o: &serde_json::Value) -> Option<(u32, u32)> {
let as_id = |v: &serde_json::Value| -> Option<String> {
v.as_str()
.map(|s| s.to_string())
.or_else(|| v.as_u64().map(|n| n.to_string()))
};
let current = o.get("currentModeId").and_then(as_id)?;
/// The CURRENT mode of an output from its `kscreen-doctor -j` entry, as `(width, height,
/// refresh_mHz)`. `None` if the entry names no current mode or that mode carries no size; a mode
/// with no `refreshRate` reports 0 mHz, which is the "unknown" the monitor type documents.
fn output_active_mode(o: &serde_json::Value) -> Option<(u32, u32, u32)> {
let current = o.get("currentModeId").and_then(json_id)?;
let mode = o
.get("modes")?
.as_array()?
.iter()
.find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?;
.find(|m| m.get("id").and_then(json_id).as_deref() == Some(current.as_str()))?;
let size = mode.get("size")?;
Some((
size.get("width").and_then(|v| v.as_u64())? as u32,
size.get("height").and_then(|v| v.as_u64())? as u32,
))
let w = size.get("width").and_then(|v| v.as_u64())? as u32;
let h = size.get("height").and_then(|v| v.as_u64())? as u32;
// Hz → mHz without an intermediate round: `refreshRate` is a float (59.94, 119.92) and whole
// Hz would throw away exactly the distinction `PhysicalMonitor::refresh_mhz` exists to keep.
let mhz = mode
.get("refreshRate")
.and_then(|r| r.as_f64())
.map(|hz| (hz * 1000.0).round().max(0.0) as u32)
.unwrap_or(0);
Some((w, h, mhz))
}
/// The `(width, height)` of an output's CURRENT mode from its `kscreen-doctor -j` entry.
fn output_active_size(o: &serde_json::Value) -> Option<(u32, u32)> {
output_active_mode(o).map(|(w, h, _)| (w, h))
}
/// Every head KWin reports, for [`crate::monitors::list`] — the in-process enumerate
/// ([`crate::kwin_output_mgmt::list_monitors`]) with a `kscreen-doctor -j` fallback.
///
/// This was the ONE KWin call site with no fallback at all, while the in-process session it depends
/// on declines for exactly the reasons the other five fall back for: management global absent
/// (pre-6.x KWin), or a compositor that does not answer in budget. The console's monitor picker and
/// `PUNKTFUNK_CAPTURE_MONITOR`'s resolve then failed outright on a box whose `kscreen-doctor` was
/// perfectly able to answer — and a failed `list` is not "no monitors", it is a session that
/// refuses to start (`monitors::resolve` treats a miss as a hard error, deliberately).
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
let declined = match crate::kwin_output_mgmt::list_monitors() {
Ok(monitors) => return Ok(monitors),
Err(e) => e,
};
let Some(doc) = kscreen_json() else {
return Err(declined.context(
"kscreen-doctor -j did not answer either (not installed, or killed at its budget)",
));
};
let monitors = monitors_from_kscreen_json(&doc);
tracing::info!(
count = monitors.len(),
reason = %declined,
"KWin: enumerated monitors via kscreen-doctor (in-process output management declined)"
);
Ok(monitors)
}
/// Parse `kscreen-doctor -j` into the shared monitor type. Split from the process call so it can be
/// tested against captured JSON — the mapping is where a picker's identity keys come from, and
/// `x`/`y` are what make two same-sized heads distinguishable at all.
///
/// Deliberately mirrors the in-process reader's contract: a disabled output has no current mode and
/// reports zeroed geometry rather than an invented one, `primary` accepts either the modern
/// `priority: 1` or the older `primary: true`, and the list is sorted by desktop position so it
/// reads left-to-right the way the desk looks.
fn monitors_from_kscreen_json(doc: &serde_json::Value) -> Vec<crate::monitors::PhysicalMonitor> {
let Some(outputs) = doc.get("outputs").and_then(|o| o.as_array()) else {
return Vec::new();
};
let mut out: Vec<crate::monitors::PhysicalMonitor> = outputs
.iter()
.filter_map(|o| {
let connector = o.get("name").and_then(|n| n.as_str())?.to_string();
let mode = output_active_mode(o);
let coord = |k: &str| {
o.get("pos")
.and_then(|p| p.get(k))
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32
};
Some(crate::monitors::PhysicalMonitor {
managed: connector.starts_with(MANAGED_PREFIX),
description: crate::monitors::describe(
o.get("vendor").and_then(|v| v.as_str()).unwrap_or(""),
o.get("model").and_then(|v| v.as_str()).unwrap_or(""),
&connector,
),
width: mode.map(|m| m.0).unwrap_or(0),
height: mode.map(|m| m.1).unwrap_or(0),
refresh_mhz: mode.map(|m| m.2).unwrap_or(0),
x: coord("x"),
y: coord("y"),
scale: o
.get("scale")
.and_then(|v| v.as_f64())
.filter(|s| *s > 0.0)
.unwrap_or(1.0),
primary: o.get("primary").and_then(|p| p.as_bool()).unwrap_or(false)
|| o.get("priority").and_then(|p| p.as_u64()) == Some(1),
enabled: o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false),
connector,
})
})
.collect();
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
out
}
/// CVT's horizontal cell granularity. KWin generates every custom mode's timing with **libxcvt**,
@@ -542,7 +730,11 @@ fn output_active_size(o: &serde_json::Value) -> Option<(u32, u32)> {
/// birth mode, and the caller falls back to 60 Hz — while KDE's display list shows the perfectly
/// good 2864x1320@119.92 mode sitting there unselected. Widths like 1920/2560/3840 are all
/// multiples of 8, which is why only phone-shaped clients ever hit it.
const CVT_H_GRANULARITY: u32 = 8;
///
/// Shared with [`crate::kwin_output_mgmt`], which matches the generated mode back the same way —
/// it used to keep its own copy under a comment claiming the two "match", which is a claim no
/// compiler was checking.
pub(crate) const CVT_H_GRANULARITY: u32 = 8;
/// One row of an output's mode list, as parsed from `kscreen-doctor -j`.
#[derive(Clone, Debug, PartialEq)]
@@ -762,7 +954,11 @@ fn read_active_mode(output: &str) -> Option<(u32, u32, u32)> {
/// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`,
/// which KWin exposes as `Virtual-punktfunk` / `Virtual-punktfunk-<id>`. Group membership (§6.1) is
/// recognised by this prefix, so we never have to thread the live set through the backend.
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
///
/// Shared with [`crate::kwin_output_mgmt`] rather than copied: both halves of the ladder decide
/// "is this output one of OURS?" with it, and a drift between two copies would make the in-process
/// path disable a sibling session's output that the kscreen path deliberately spares.
pub(crate) const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// The current mode of an output as a kscreen-doctor mode setter, from its `-j` entry — preferring
/// the human `WxH@Hz` form (survives a mode-id re-enumeration across disable→enable) and falling back
@@ -875,14 +1071,27 @@ fn apply_virtual_primary(ours: &str) -> Vec<(String, String)> {
// the group is unambiguously the desktop — never a sibling session's output (group-aware filter).
// Each is captured WITH its current mode so teardown restores its real refresh, not KWin's default.
let others = other_enabled_outputs();
if !others.is_empty() {
let args: Vec<String> = others
.iter()
.map(|(o, _mode)| format!("output.{o}.disable"))
.collect();
let _ = kscreen(&args);
if others.is_empty() {
tracing::info!("KWin: streamed output set as the sole desktop (nothing else was enabled)");
return others;
}
let args: Vec<String> = others
.iter()
.map(|(o, _mode)| format!("output.{o}.disable"))
.collect();
if kscreen(&args) {
tracing::info!(also_disabled = ?others, "KWin: streamed output set as the sole desktop");
} else {
// Report the request, not a success: the outputs are still enabled, so the client sees the
// shell wherever KWin left it. They are returned for the restore regardless — re-enabling an
// output that was never disabled is a harmless no-op, and dropping them here would strand a
// physical dark if the disable actually landed and only the ack was lost to the budget.
tracing::warn!(
attempted_disable = ?others,
"KWin: could not disable the other outputs for the exclusive topology (kscreen-doctor \
failed or hit its budget) the streamed output is not the sole desktop"
);
}
tracing::info!(also_disabled = ?others, "KWin: streamed output set as the sole desktop");
others
}
@@ -919,10 +1128,42 @@ struct State {
node_id: Option<u32>,
failed: Option<String>,
closed: bool,
/// Every `wl_output` KWin advertises, keyed by the proxy, with its connector name once the
/// `name` event arrives. Only the monitor-mirror path ([`stream_existing_output`]) needs these
/// — `stream_output` takes a `wl_output` object, so the connector has to be resolved to one.
outputs: Vec<(WlOutput, Option<String>)>,
/// Highest `wl_display.sync` serial whose `done` has arrived the barrier [`roundtrip_within`]
/// waits on, so a compositor that accepted the connection and then stopped serving costs a
/// budget instead of the thread.
sync_done: u32,
/// Whether this connection needs `wl_output` objects at all — true ONLY on the monitor-mirror
/// path. `stream_virtual_output` names its output by string, so the virtual-output path never
/// reads [`State::outputs`]; binding them there was pure accumulation on a connection that
/// lives for the whole session, and every managed display this host creates is itself another
/// `wl_output` global.
want_outputs: bool,
/// Every `wl_output` KWin advertises, as (registry global name, proxy, connector once the
/// `name` event arrives). Only the monitor-mirror path ([`stream_existing_output`]) needs these —
/// `stream_output` takes a `wl_output` object, so the connector has to be resolved to one. The
/// global name is carried so `global_remove` can find the entry again ([`State::forget_output`]).
outputs: Vec<(u32, WlOutput, Option<String>)>,
}
impl State {
/// Drop the `wl_output` whose registry global just went away.
///
/// Both halves matter. The proxy must be `release`d — wayland-rs sends no destructor when a
/// proxy is merely dropped, so an unreleased binding is a server-side object leaked for the
/// life of a connection that lasts as long as the session. And the ENTRY must go, because
/// [`run_existing`]'s connector resolve scans this vector: a stale row for an unplugged head
/// would shadow the live output that took its connector name.
fn forget_output(&mut self, global: u32) {
let Some(pos) = self.outputs.iter().position(|(n, _, _)| *n == global) else {
return;
};
let (_, out, connector) = self.outputs.remove(pos);
// `wl_output.release` is `since 3`; below that the object simply has no destructor.
if out.version() >= 3 {
out.release();
}
tracing::debug!(?connector, "KWin: a wl_output went away — released it");
}
}
impl Dispatch<WlRegistry, ()> for State {
@@ -934,23 +1175,45 @@ impl Dispatch<WlRegistry, ()> for State {
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
if interface == Screencast::interface().name {
let v = version.min(MAX_VERSION);
state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ()));
} else if interface == WlOutput::interface().name {
// v4 is where `wl_output.name` (the connector) arrives; bind at least that when the
// compositor offers it, else bind what it has and let the resolve fail loudly
// rather than mirroring an unidentifiable head.
let v = version.min(WL_OUTPUT_MAX_VERSION);
let out = registry.bind::<WlOutput, _, _>(name, v, qh, ());
state.outputs.push((out, None));
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => {
if interface == Screencast::interface().name {
let v = version.min(MAX_VERSION);
state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ()));
} else if state.want_outputs && interface == WlOutput::interface().name {
// v4 is where `wl_output.name` (the connector) arrives; bind at least that when
// the compositor offers it, else bind what it has and let the resolve fail
// loudly rather than mirroring an unidentifiable head.
let v = version.min(WL_OUTPUT_MAX_VERSION);
let out = registry.bind::<WlOutput, _, _>(name, v, qh, ());
state.outputs.push((name, out, None));
}
}
wl_registry::Event::GlobalRemove { name } => state.forget_output(name),
_ => {}
}
}
}
/// The `wl_display.sync` callback: `done` releases whichever [`roundtrip_within`] is waiting on
/// this serial. A plain `roundtrip()` would do the same job in one call, but it blocks on the
/// socket with no ceiling — against a compositor that accepted the connection and then stopped
/// answering, that is the session's stream thread pinned forever.
impl Dispatch<WlCallback, u32> for State {
fn event(
state: &mut Self,
_: &WlCallback,
event: wl_callback::Event,
serial: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_callback::Event::Done { .. } = event {
state.sync_done = state.sync_done.max(*serial);
}
}
}
@@ -969,8 +1232,8 @@ impl Dispatch<WlOutput, ()> for State {
_: &QueueHandle<Self>,
) {
if let wl_output::Event::Name { name } = event {
if let Some(slot) = state.outputs.iter_mut().find(|(o, _)| o == output) {
slot.1 = Some(name);
if let Some(slot) = state.outputs.iter_mut().find(|(_, o, _)| o == output) {
slot.2 = Some(name);
}
}
}
@@ -1052,10 +1315,16 @@ pub(crate) fn stream_existing_output(
}
})
.context("spawn KWin monitor-mirror thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
let node_id = match setup_rx.recv_timeout(OPENER_BUDGET) {
Ok(Ok(v)) => v,
Ok(Err(e)) => bail!("KWin monitor mirror failed: {e}"),
Err(_) => bail!("timed out recording the KWin output {connector:?}"),
Err(_) => {
// Same leak as the virtual-output opener: `StopOnDrop` only takes ownership of `stop`
// on the success path, so without this the mirror thread keeps recording a monitor
// nobody is watching until its own budget runs out.
stop.store(true, Ordering::Relaxed);
bail!("timed out recording the KWin output {connector:?}")
}
};
Ok(crate::mirror::MirrorStream {
node_id,
@@ -1187,7 +1456,16 @@ pub fn probe() -> Result<()> {
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut state = State::default();
queue.roundtrip(&mut state).context("registry roundtrip")?;
// Nothing to interrupt a probe: it is a one-shot question, bounded by the roundtrip budget.
let never = AtomicBool::new(false);
roundtrip_within(
&conn,
&mut queue,
&mut state,
&never,
1,
"registry roundtrip",
)?;
if state.screencast.is_none() {
bail!(
"KWin is up but does not expose zkde_screencast_unstable_v1 to this client — KWin gates \
@@ -1221,19 +1499,32 @@ fn run_existing(
setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool,
) -> Result<()> {
// The opener started its own clock a moment ago; everything this worker spends before
// `await_created` comes out of the same 20 s (see [`CREATE_BUDGET`] — this path has two
// barriers, which is exactly why the create wait cannot be a fixed 15 s here).
let started = Instant::now();
let conn = Connection::connect_to_env()
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut state = State::default();
// The one path that resolves a connector to a `wl_output`, so the only one that binds them.
let mut state = State {
want_outputs: true,
..State::default()
};
// Two roundtrips: the first processes the globals (binding screencast + every wl_output), the
// second drains each output's property burst — the `name` event we resolve the connector by.
queue.roundtrip(&mut state).context("registry roundtrip")?;
queue
.roundtrip(&mut state)
.context("wl_output property roundtrip")?;
roundtrip_within(&conn, &mut queue, &mut state, stop, 1, "registry roundtrip")?;
roundtrip_within(
&conn,
&mut queue,
&mut state,
stop,
2,
"wl_output property roundtrip",
)?;
let screencast = state.screencast.clone().ok_or_else(|| {
anyhow!(
@@ -1251,19 +1542,19 @@ fn run_existing(
let named: Vec<&str> = state
.outputs
.iter()
.filter_map(|(_, n)| n.as_deref())
.filter_map(|(_, _, n)| n.as_deref())
.collect();
let output = state
.outputs
.iter()
.find(|(_, n)| n.as_deref() == Some(connector))
.find(|(_, _, n)| n.as_deref() == Some(connector))
.or_else(|| {
state.outputs.iter().find(|(_, n)| {
state.outputs.iter().find(|(_, _, n)| {
n.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(connector))
})
})
.map(|(o, _)| o.clone())
.map(|(_, o, _)| o.clone())
.ok_or_else(|| {
if named.is_empty() {
anyhow!(
@@ -1285,20 +1576,14 @@ fn run_existing(
"KWin: recording an existing output; awaiting PipeWire node"
);
let node_id = loop {
queue
.blocking_dispatch(&mut state)
.context("wayland dispatch (awaiting created)")?;
if let Some(node) = state.node_id {
break node;
}
if let Some(e) = state.failed.take() {
bail!("stream_output failed: {e}");
}
if state.closed {
bail!("KWin closed the stream before it was created");
}
};
let node_id = await_created(
&conn,
&mut queue,
&mut state,
stop,
"stream_output",
started,
)?;
setup_tx
.send(Ok(node_id))
.map_err(|_| anyhow!("monitor-mirror opener went away"))?;
@@ -1317,14 +1602,19 @@ fn run(
setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool,
) -> Result<()> {
// Same clock as the mirror path: one barrier here rather than two, but the create wait is
// bounded against the opener either way (see [`CREATE_BUDGET`]).
let started = Instant::now();
let conn = Connection::connect_to_env()
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
// `want_outputs` stays false: `stream_virtual_output` names its output by string, so this
// connection never needs a `wl_output` — and it lives for the whole session (see `State`).
let mut state = State::default();
queue.roundtrip(&mut state).context("registry roundtrip")?;
roundtrip_within(&conn, &mut queue, &mut state, stop, 1, "registry roundtrip")?;
let screencast = state.screencast.clone().ok_or_else(|| {
anyhow!(
@@ -1353,21 +1643,15 @@ fn run(
"KWin: requested virtual output; awaiting PipeWire node"
);
// Pump events until KWin reports the node id (or an error).
let node_id = loop {
queue
.blocking_dispatch(&mut state)
.context("wayland dispatch (awaiting created)")?;
if let Some(node) = state.node_id {
break node;
}
if let Some(e) = state.failed.take() {
bail!("stream_virtual_output failed: {e}");
}
if state.closed {
bail!("KWin closed the stream before it was created");
}
};
// Pump events until KWin reports the node id (or an error, or the budget).
let node_id = await_created(
&conn,
&mut queue,
&mut state,
stop,
"stream_virtual_output",
started,
)?;
setup_tx
.send(Ok(node_id))
.map_err(|_| anyhow!("virtual-output opener went away"))?;
@@ -1380,25 +1664,83 @@ fn run(
Ok(())
}
/// Keep the connection (and thus the stream) alive until told to stop, observing `closed`.
/// `blocking_dispatch` can't be interrupted, so poll the connection fd with a short timeout and
/// honor `stop` within ~200 ms. Shared by the virtual-output and monitor-mirror paths — for a
/// virtual output this connection IS the output's lifetime; for a mirror it is only the
/// recording's, and the monitor itself is untouched either way.
fn park_until_stopped(
/// Poll slice while waiting on the Wayland fd — the granularity at which `stop` and a deadline are
/// observed (matches `kwin_output_mgmt`'s `POLL_MS`).
const POLL_MS: i32 = 200;
/// Budget for one compositor roundtrip. Generous next to a healthy one (a few ms); it exists only
/// so a KWin that accepted the connection and then stopped serving cannot pin the calling thread —
/// which for [`probe`] is whatever thread the mgmt API answered a `/display/compositors` on, and
/// for [`run`] is the session's own bring-up.
const ROUNDTRIP_BUDGET: Duration = Duration::from_secs(3);
/// How long an opener ([`spawn_vout`](VirtualDisplay::create), [`stream_existing_output`]) waits
/// for the worker's first word before giving up on it.
const OPENER_BUDGET: Duration = Duration::from_secs(20);
/// Slack subtracted from [`OPENER_BUDGET`] to get the worker's own ceiling: enough for its error to
/// travel one `mpsc` send while the opener is still listening.
const WORKER_MARGIN: Duration = Duration::from_millis(500);
/// Budget for the `created` handshake (the PipeWire node id) — but only as a ceiling, because
/// what actually matters is that the WORKER gives up before its opener does, so the failure the
/// client sees is a REASON ("KWin never created the output") rather than a bare timeout with the
/// worker still parked behind it.
///
/// That is a property of the whole worker, not of this one step, and it cannot be had by comparing
/// this constant with [`OPENER_BUDGET`]: the two workers do a different amount of work before they
/// get here. [`run`] spends one [`ROUNDTRIP_BUDGET`] barrier, so 3 + 15 < 20 ✓ — but [`run_existing`]
/// needs TWO (the registry globals, then the `wl_output` property burst that carries the connector
/// name), so 3 + 3 + 15 = 21 s and the mirror path lost the property that the doc here once claimed
/// for both. Hence [`await_created`] takes the worker's start instant and bounds itself by whichever
/// comes first, this budget or the opener's deadline; adding a third barrier to some future worker
/// cannot silently break it again.
const CREATE_BUDGET: Duration = Duration::from_secs(15);
/// How a bounded pump ended.
enum Pumped {
/// The predicate held.
Done,
/// `stop` was set — the caller's output/recording was released while we waited.
Stopped,
/// The deadline passed first.
Expired,
}
/// Bounded manual event loop: dispatch what's queued, then poll the connection fd for up to
/// [`POLL_MS`] and read, until `done(&state)` holds, `stop` is set, or `deadline` passes.
///
/// This is the only way to wait on this connection. `blocking_dispatch` and `roundtrip` cannot be
/// interrupted and have no ceiling, so a compositor that stops answering turns any wait into a
/// permanently stuck thread — and on the host that thread is the session's, whose only way to end a
/// session is to return. `deadline: None` means "no ceiling", which is correct for exactly one
/// caller: [`park_until_stopped`], where the wait IS the output's lifetime.
fn pump_until(
conn: &Connection,
queue: &mut wayland_client::EventQueue<State>,
state: &mut State,
deadline: Option<Instant>,
stop: &AtomicBool,
output: &str,
node_id: u32,
) -> Result<()> {
while !stop.load(Ordering::Relaxed) {
done: impl Fn(&State) -> bool,
) -> Result<Pumped> {
loop {
queue.dispatch_pending(state).context("dispatch_pending")?;
if state.closed {
tracing::warn!(output = %output, node_id, "KWin closed the screencast stream");
break;
if done(state) {
return Ok(Pumped::Done);
}
if stop.load(Ordering::Relaxed) {
return Ok(Pumped::Stopped);
}
let timeout = match deadline {
Some(d) => {
let remaining = d.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(Pumped::Expired);
}
(remaining.as_millis() as i64).clamp(0, i64::from(POLL_MS)) as i32
}
None => POLL_MS,
};
conn.flush().context("wayland flush")?;
let Some(guard) = conn.prepare_read() else {
continue; // events already queued — loop dispatches them
@@ -1411,19 +1753,109 @@ fn park_until_stopped(
// SAFETY: `&mut pfd` points at a single live, fully-initialized `libc::pollfd` on the stack, and
// the count `1` matches that one-element array, so `poll` reads `fd`/`events` and writes `revents`
// strictly within `pfd`. `pfd.fd` is the Wayland connection's fd, valid because `conn` (and the
// `prepare_read` guard) are alive across the call. `poll` blocks up to 200 ms and writes only
// `revents`; `pfd` outlives the synchronous call and aliases nothing (a fresh local).
let r = unsafe { libc::poll(&mut pfd, 1, 200) };
// `prepare_read` guard) are alive across the call. `poll` blocks up to `timeout` ms and writes
// only `revents`; `pfd` outlives the synchronous call and aliases nothing (a fresh local).
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
let _ = guard.read();
} // else: timeout or signal — drop the guard, re-check `stop`
} // else: timeout or signal — drop the guard, re-check `stop` and the deadline
}
}
/// A `wl_display.sync` barrier bounded by [`ROUNDTRIP_BUDGET`] — the replacement for
/// `EventQueue::roundtrip`, which waits on the socket with no ceiling. `serial` must be unique per
/// connection (callers number theirs from 1); `what` names the wait in the error.
fn roundtrip_within(
conn: &Connection,
queue: &mut wayland_client::EventQueue<State>,
state: &mut State,
stop: &AtomicBool,
serial: u32,
what: &str,
) -> Result<()> {
let qh = queue.handle();
let _cb = conn.display().sync(&qh, serial);
let deadline = Instant::now() + ROUNDTRIP_BUDGET;
match pump_until(conn, queue, state, Some(deadline), stop, |st| {
st.sync_done >= serial
})? {
Pumped::Done => Ok(()),
Pumped::Stopped => bail!("{what} abandoned — the stream was released while we waited"),
Pumped::Expired => bail!(
"KWin accepted the Wayland connection but did not answer the {what} within \
{ROUNDTRIP_BUDGET:?} the compositor is not serving this client"
),
}
}
/// Keep the connection (and thus the stream) alive until told to stop, observing `closed`.
/// Shared by the virtual-output and monitor-mirror paths — for a virtual output this connection IS
/// the output's lifetime; for a mirror it is only the recording's, and the monitor itself is
/// untouched either way. The only deadline-free [`pump_until`] in the file, for that reason.
fn park_until_stopped(
conn: &Connection,
queue: &mut wayland_client::EventQueue<State>,
state: &mut State,
stop: &AtomicBool,
output: &str,
node_id: u32,
) -> Result<()> {
match pump_until(conn, queue, state, None, stop, |st| st.closed)? {
Pumped::Done => {
tracing::warn!(output = %output, node_id, "KWin closed the screencast stream");
}
// `Expired` cannot happen without a deadline; `Stopped` is the ordinary teardown.
Pumped::Stopped | Pumped::Expired => {}
}
Ok(())
}
/// Wait for the `created` event carrying the PipeWire node id, bounded and interruptible by `stop`.
///
/// The loop this replaced was a bare `blocking_dispatch` with no deadline that never read `stop`:
/// a KWin that acknowledged `stream_virtual_output` and then never answered parked the worker
/// thread for good, and the opener's `recv_timeout` arm — which did not set `stop` either — left it
/// there holding a half-built output. `request` names the request in the error.
///
/// `started` is when the WORKER began, not when this wait did: the bound is the earlier of
/// [`CREATE_BUDGET`] and the opener's own deadline, so whatever the barriers before us consumed
/// comes out of this wait rather than out of the opener's patience (see [`CREATE_BUDGET`] for the
/// arithmetic that made a fixed budget wrong on the mirror path).
fn await_created(
conn: &Connection,
queue: &mut wayland_client::EventQueue<State>,
state: &mut State,
stop: &AtomicBool,
request: &str,
started: Instant,
) -> Result<u32> {
let began = Instant::now();
let deadline = (began + CREATE_BUDGET).min(started + OPENER_BUDGET - WORKER_MARGIN);
let settled = |st: &State| st.node_id.is_some() || st.failed.is_some() || st.closed;
match pump_until(conn, queue, state, Some(deadline), stop, settled)? {
// Node id first: a `closed` that arrives in the same burst as `created` is a stream that
// was made and then torn down, not a failure to make one.
Pumped::Done => match (state.node_id, state.failed.take()) {
(Some(node), _) => Ok(node),
(None, Some(e)) => bail!("{request} failed: {e}"),
(None, None) => bail!("KWin closed the stream before it was created"),
},
Pumped::Stopped => bail!("{request} abandoned — released before KWin created the stream"),
// Report the wait we actually got, not the budget we asked for — they differ whenever the
// opener's deadline was the tighter of the two, and a message naming 15 s after 11 s is the
// kind of thing that sends the next person hunting for a stall that never happened.
Pumped::Expired => bail!(
"KWin acknowledged {request} but never sent the PipeWire node within {:?}",
began.elapsed()
),
}
}
#[cfg(test)]
mod tests {
use super::{modes_from_json, pick_custom_mode, KModeRow, MANAGED_PREFIX};
use super::{
modes_from_json, monitors_from_kscreen_json, pick_custom_mode, KModeRow, MANAGED_PREFIX,
};
fn row(id: &str, w: u32, h: u32, hz: f64) -> KModeRow {
KModeRow {
@@ -1506,6 +1938,80 @@ mod tests {
assert!(modes_from_json(&doc, "Virtual-nope").is_empty());
}
/// The kscreen fallback for `monitors::list` must produce the same contract the in-process
/// reader promises: geometry from `pos` (the identity key), the mode in PIXELS with refresh in
/// mHz precise enough to keep 59.94 apart from 60, a DISABLED head still listed but zeroed
/// rather than invented, our own managed output flagged, and the list sorted by position.
#[test]
fn parses_a_kscreen_monitor_list() {
let doc: serde_json::Value = serde_json::from_str(
r#"{"outputs":[
{"id":2,"name":"HDMI-A-1","enabled":true,"priority":2,"scale":1,
"pos":{"x":1920,"y":0},"vendor":"ACME","model":"U2720Q",
"currentModeId":"m9","modes":[
{"id":"m9","size":{"width":1920,"height":1080},"refreshRate":59.94}]},
{"id":1,"name":"eDP-1","enabled":true,"priority":1,"scale":1.5,
"pos":{"x":0,"y":0},
"currentModeId":7,"modes":[
{"id":7,"size":{"width":3840,"height":2160},"refreshRate":120.0}]},
{"id":3,"name":"DP-3","enabled":false,"scale":1,"pos":{"x":0,"y":0},
"modes":[{"id":"z","size":{"width":2560,"height":1440},"refreshRate":60.0}]},
{"id":4,"name":"Virtual-punktfunk-7","enabled":true,"scale":1,
"pos":{"x":5760,"y":0},"currentModeId":"v1","modes":[
{"id":"v1","size":{"width":2560,"height":1440},"refreshRate":119.98}]}
]}"#,
)
.expect("fixture parses");
let mons = monitors_from_kscreen_json(&doc);
let by = |c: &str| {
mons.iter()
.find(|m| m.connector == c)
.unwrap_or_else(|| panic!("{c} missing"))
.clone()
};
// Sorted by desktop position, not by kscreen's own order.
let order: Vec<&str> = mons.iter().map(|m| m.connector.as_str()).collect();
assert_eq!(
order,
vec!["DP-3", "eDP-1", "HDMI-A-1", "Virtual-punktfunk-7"]
);
let edp = by("eDP-1");
// PIXELS, at the scale the desk actually runs — the whole point of `logical_size`.
assert_eq!((edp.width, edp.height), (3840, 2160));
assert_eq!(edp.scale, 1.5);
assert_eq!(edp.logical_size(), (2560.0, 1440.0));
assert!(edp.primary, "priority 1 is KWin's primary");
assert_eq!(edp.refresh_mhz, 120_000);
// 59.94 must survive as mHz; rounding to whole Hz here is the bug this guards.
assert_eq!(by("HDMI-A-1").refresh_mhz, 59_940);
assert_eq!(by("HDMI-A-1").description, "ACME U2720Q");
assert!(!by("HDMI-A-1").primary);
// Disabled: listed (so "why can't I pick it?" has an answer) with no invented mode.
let dark = by("DP-3");
assert!(!dark.enabled);
assert_eq!((dark.width, dark.height, dark.refresh_mhz), (0, 0, 0));
// Ours, and labelled by connector when the entry carries no make/model.
let ours = by("Virtual-punktfunk-7");
assert!(ours.managed);
assert_eq!(ours.description, "Virtual-punktfunk-7");
assert!(!by("eDP-1").managed);
}
/// A document with no `outputs` array (an error object, or a kscreen-doctor whose schema
/// changed) is an empty list, never a panic — the caller's own error path already covers "the
/// tool did not answer".
#[test]
fn a_malformed_kscreen_document_yields_no_monitors() {
assert!(monitors_from_kscreen_json(&serde_json::json!({})).is_empty());
assert!(monitors_from_kscreen_json(&serde_json::json!({"outputs": 7})).is_empty());
// An output with no name cannot be pinned or resolved, so it is dropped rather than
// reported under an empty connector.
assert!(
monitors_from_kscreen_json(&serde_json::json!({"outputs": [{"enabled": true}]}))
.is_empty()
);
}
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
/// (the Stage-3 naming would otherwise make a 2nd exclusive session black out the 1st).
@@ -107,10 +107,13 @@ const OP_BUDGET: Duration = Duration::from_secs(3);
/// Poll slice while waiting on the Wayland fd (matches the keepalive loop's cadence in `kwin.rs`).
const POLL_MS: i32 = 100;
/// KWin's CVT generator aligns a custom mode's width DOWN to a multiple of this (libxcvt's cell
/// grain), so the mode it builds for a `set_custom_modes` request may be a few px narrower than
/// asked — matches `kwin::CVT_H_GRANULARITY`. Used when matching the generated mode back.
const CVT_H_GRANULARITY: u32 = 8;
// KWin's CVT generator aligns a custom mode's width DOWN to a multiple of `CVT_H_GRANULARITY`
// (libxcvt's cell grain), so the mode it builds for a `set_custom_modes` request may be a few px
// narrower than asked — used below when matching the generated mode back. IMPORTED, not re-declared:
// this and `MANAGED_PREFIX` used to be second copies of `kwin.rs`'s literals, each under prose
// asserting the two "match" — an assertion no compiler was checking, on the two values that decide
// which output is OURS and which mode is the one we asked for.
use crate::kwin::{CVT_H_GRANULARITY, MANAGED_PREFIX};
/// `kde_output_management_v2.set_replication_source` (and the device's `replication_source` event)
/// arrived in v13. wayland-rs does not range-check requests, so sending one to a lower-version bind
@@ -166,9 +169,19 @@ pub(crate) struct TopologyOutcome {
/// One output as read from `kde_output_device_v2`.
#[derive(Default, Clone)]
struct DeviceState {
/// The global `name` number (higher = more recently advertised) — used to pick the newest of two
/// same-named outputs during a supersede.
/// The global `name` number (higher = more recently advertised) — the primary newest-wins
/// tie-break between two same-named outputs during a supersede. **Zero for every device on
/// KWin ≥ 6.7**, which hands outputs out through `kde_output_device_registry_v2` instead of one
/// global per output: those carry no global name at all (see [`seq`](DeviceState::seq)).
global: u32,
/// Order in which THIS connection first saw the device, from 1. The tie-break of last resort
/// behind `global`: on the registry model every `global` is 0, so without this the `max_by_key`
/// below degrades to "whichever entry `HashMap` iteration happened to reach last" — and `HashMap`
/// is seeded per process, so the supersede resolve was a coin flip that could pick the
/// PREDECESSOR (same name, same size) and configure the output that is about to disappear.
/// Announce order is not a proof of newness — it is the compositor's own enumeration order — but
/// it is deterministic, which the hash order was not.
seq: u32,
name: Option<String>,
uuid: Option<String>,
enabled: bool,
@@ -204,6 +217,8 @@ struct State {
/// the life of the session (dropping it would end the announcements).
device_registry: Option<DeviceRegistry>,
devices: HashMap<ObjectId, DeviceState>,
/// Highest [`DeviceState::seq`] handed out so far — the announce counter.
next_device_seq: u32,
/// mode object id → `(width, height, refresh_mHz)`.
mode_dims: HashMap<ObjectId, (u32, u32, u32)>,
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
@@ -213,6 +228,46 @@ struct State {
failure_reason: Option<String>,
}
impl State {
/// The entry for a device, stamping its announce order ([`DeviceState::seq`]) the first time we
/// see it. Every path that creates a device entry goes through here — the two announce models
/// (per-output global, and the ≥ 6.7 registry) plus the event handler, which can race ahead of
/// both — so the counter really does reflect the order the devices arrived in.
fn device_entry(&mut self, id: ObjectId) -> &mut DeviceState {
// Disjoint field borrows: `entry` holds `devices`, the closure holds only the counter.
let next = &mut self.next_device_seq;
self.devices.entry(id).or_insert_with(|| {
*next += 1;
DeviceState {
seq: *next,
..Default::default()
}
})
}
/// Forget a `kde_output_device_mode_v2` the compositor has destroyed.
///
/// The protocol's `removed` event says the compositor destroys the object *immediately after*
/// sending it — and the event is NOT marked `type="destructor"`, so wayland-rs happily keeps the
/// proxy alive locally. Anything still holding that id would later hand it back to KWin
/// (`kde_output_configuration_v2.mode`) as a request against a dead object, which is a protocol
/// error: KWin kills the connection, the apply "fails", and a >60 Hz session degrades to the
/// kscreen-doctor path with a log indistinguishable from "this KWin is too old". Reachable
/// precisely because `set_custom_modes` REPLACES the persisted custom list, so the mode a
/// previous session left behind is destroyed the moment this session installs its own.
fn forget_mode(&mut self, id: &ObjectId) {
self.mode_dims.remove(id);
for dev in self.devices.values_mut() {
dev.modes.retain(|(mid, _)| mid != id);
if dev.current_mode.as_ref() == Some(id) {
// Don't invent a size for a destroyed mode: a resolve keyed on current dims must
// miss (and fall back) rather than match on a mode that no longer exists.
dev.current_mode = None;
}
}
}
}
impl Dispatch<WlRegistry, ()> for State {
fn event(
state: &mut Self,
@@ -239,7 +294,7 @@ impl Dispatch<WlRegistry, ()> for State {
// handler can record it (newest-wins tie-break during a supersede).
let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name);
let id = dev.id();
state.devices.entry(id).or_default().proxy = Some(dev);
state.device_entry(id).proxy = Some(dev);
} else if interface == DeviceRegistry::interface().name {
// KWin ≥ 6.7 (Plasma 6.7.3 verified) no longer advertises ONE
// `kde_output_device_v2` global per output — it advertises this registry and
@@ -260,9 +315,14 @@ impl Dispatch<WlRegistry, ()> for State {
}
/// The device registry hands out one `kde_output_device_v2` per output via its `output` event
/// (a `new_id`, so the child is created by the `event_created_child!` binding below). Devices that
/// arrive this way have no global `name` number — the newest-wins supersede tie-break uses 0 for
/// them, which is fine: that tie-break only matters for the per-output-global model.
/// (a `new_id`, so the child is created by the `event_created_child!` binding below).
///
/// Devices that arrive this way have no global `name` number — the `0u32` UserData below is stamped
/// on every one of them, so [`DeviceState::global`] is 0 across the board. That is **not** harmless,
/// and an earlier comment here claimed it was: the registry model is what CURRENT KWin uses, so the
/// newest-wins supersede tie-break is unavailable exactly where it is needed (two same-named,
/// same-sized outputs, predecessor still alive). [`DeviceState::seq`] is the deterministic
/// fallback the tie-break actually lands on there.
impl Dispatch<DeviceRegistry, ()> for State {
fn event(
state: &mut Self,
@@ -274,7 +334,7 @@ impl Dispatch<DeviceRegistry, ()> for State {
) {
if let RegistryEvent::Output { output } = event {
let id = output.id();
state.devices.entry(id).or_default().proxy = Some(output);
state.device_entry(id).proxy = Some(output);
}
}
@@ -319,7 +379,23 @@ impl Dispatch<OutputDevice, u32> for State {
_: &Connection,
_: &QueueHandle<Self>,
) {
let entry = state.devices.entry(device.id()).or_default();
// Before anything re-creates the entry: `removed` (device ≥ v21, and we bind up to 24) means
// this output is gone for good and no further update will arrive. Dropping it keeps a
// hot-unplugged head from being resolved, disabled or "restored" minutes later, and the XML
// asks the client to `release` the object — the only way the server-side one is ever freed,
// since wayland-rs sends no destructor when a proxy is merely dropped.
if matches!(event, DeviceEvent::Removed) {
if let Some(dead) = state.devices.remove(&device.id()) {
for (mid, _) in &dead.modes {
state.mode_dims.remove(mid);
}
}
if device.version() >= 21 {
device.release();
}
return;
}
let entry = state.device_entry(device.id());
entry.global = *global;
if entry.proxy.is_none() {
entry.proxy = Some(device.clone());
@@ -363,6 +439,12 @@ impl Dispatch<DeviceMode, ()> for State {
_: &Connection,
_: &QueueHandle<Self>,
) {
// `removed` first, and NOT through the entry below: re-inserting a destroyed mode is exactly
// the stale row a later `config.mode(...)` would send back to KWin (see [`State::forget_mode`]).
if matches!(event, ModeEvent::Removed) {
state.forget_mode(&mode.id());
return;
}
let entry = state.mode_dims.entry(mode.id()).or_insert((0, 0, 0));
match event {
ModeEvent::Size { width, height } => {
@@ -370,6 +452,7 @@ impl Dispatch<DeviceMode, ()> for State {
entry.1 = height.max(0) as u32;
}
ModeEvent::Refresh { refresh } => entry.2 = refresh.max(0) as u32,
// `preferred` / `flags` / `cvt` carry nothing we drive an apply from.
_ => {}
}
}
@@ -419,13 +502,76 @@ struct Session {
next_sync: u32,
}
/// Why [`Session::open`] declined, i.e. why this operation degraded to the `kscreen-doctor`
/// shell-out.
///
/// The distinction is the whole value of the type: a bare `None` made every one of these read as
/// "not a KDE box", which is how a genuine regression — KWin ≥ 6.7 no longer advertising per-output
/// `kde_output_device_v2` globals, so the device list came back EMPTY — shipped as a fallback that
/// fired on every current KDE machine with nothing in the log to say so.
enum OpenFailure {
/// No Wayland connection at all (`WAYLAND_DISPLAY` unset/stale) — not a session we can drive.
Connect(String),
/// The compositor accepted the connection but did not answer the registry barrier in budget:
/// the wedge case this whole module exists for.
RegistryBarrier,
/// Connected and answering, but `kde_output_management_v2` is not advertised to this client
/// (too old a KWin, or not KWin at all).
NoManagementGlobal,
/// Management is there, but the outputs' own property bursts never completed in budget.
DeviceBarrier,
}
impl std::fmt::Display for OpenFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OpenFailure::Connect(e) => write!(f, "no Wayland connection ({e})"),
OpenFailure::RegistryBarrier => {
write!(
f,
"the compositor did not answer the registry roundtrip in budget"
)
}
OpenFailure::NoManagementGlobal => {
write!(
f,
"kde_output_management_v2 is not advertised to this client"
)
}
OpenFailure::DeviceBarrier => {
write!(
f,
"the outputs never finished announcing their state in budget"
)
}
}
}
}
impl Session {
/// [`Session::connect`] for the operation named by `op`, logging the reason on the way out.
///
/// One log site for all six callers: every one of them silently degraded to `kscreen-doctor`
/// before, so on a box where the in-process path never worked the only symptom was that
/// topology took ~26 s and nothing said why.
fn open(op: &'static str) -> Result<Session, OpenFailure> {
let opened = Session::connect();
if let Err(reason) = &opened {
tracing::warn!(
op,
%reason,
"KWin in-process output management unavailable — falling back to kscreen-doctor"
);
}
opened
}
/// Connect to the KWin Wayland socket, bind `kde_output_management_v2` + every
/// `kde_output_device_v2`, and read each output's state — all bounded by `OP_BUDGET`. `None` if
/// we can't connect, the management global isn't advertised, or the compositor doesn't answer in
/// budget (the wedge case — the caller then falls back to `kscreen-doctor`).
fn open() -> Option<Session> {
let conn = Connection::connect_to_env().ok()?;
/// `kde_output_device_v2`, and read each output's state — all bounded by `OP_BUDGET`. The
/// [`OpenFailure`] says which rung declined; every one of them sends the caller to
/// `kscreen-doctor`.
fn connect() -> Result<Session, OpenFailure> {
let conn = Connection::connect_to_env().map_err(|e| OpenFailure::Connect(e.to_string()))?;
let queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
@@ -438,19 +584,15 @@ impl Session {
let deadline = Instant::now() + OP_BUDGET;
// Phase 1: process the registry globals (binds management + every device in the handler).
if !s.sync_barrier(deadline) {
return None;
return Err(OpenFailure::RegistryBarrier);
}
if s.state.management.is_none() {
tracing::debug!(
"KWin does not advertise kde_output_management_v2 to this client — kscreen-doctor \
fallback"
);
return None;
return Err(OpenFailure::NoManagementGlobal);
}
// Phase 2: flush the device binds issued in phase 1 and drain each output's state burst
// (name / enabled / priority / current_mode / mode sizes / done).
if !s.sync_barrier(deadline) {
return None;
return Err(OpenFailure::DeviceBarrier);
}
// Phase 3 (KWin ≥ 6.7, the registry model): the devices themselves only arrive as the
// registry's `output` events during phase 2, so their property bursts are one round further
@@ -460,9 +602,9 @@ impl Session {
&& s.state.devices.values().any(|d| !d.seen_done)
&& !s.sync_barrier(deadline)
{
return None;
return Err(OpenFailure::DeviceBarrier);
}
Some(s)
Ok(s)
}
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
@@ -554,6 +696,26 @@ impl Session {
let id = dev.current_mode.as_ref()?;
self.state.mode_dims.get(id).copied()
}
/// Resolve OUR just-created virtual output: a managed-prefix name AND a current size equal to
/// the size we created it at — only the just-created output sits there during a supersede,
/// because the replacement deliberately reuses the per-slot name while the predecessor is still
/// alive. Newest wins the remaining tie: the global `name` number where there is one, else
/// announce order (see [`DeviceState::seq`] — on KWin ≥ 6.7 that is every device).
///
/// One resolve for all three operations (topology / de-mirror / custom mode). They had drifted
/// into three copies of the same filter, which is how a tie-break fix lands in two of them.
fn resolve_ours(&self, our_prefix: &str, our_w: u32, our_h: u32) -> Option<DeviceState> {
self.state
.devices
.values()
.filter(|d| {
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
&& self.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
})
.max_by_key(|d| (d.global, d.seq))
.cloned()
}
}
/// `(width, height, "WxH@Hz")` capture of a device's current mode, Hz rounded — the same shape the
@@ -563,10 +725,10 @@ fn mode_spec(dims: (u32, u32, u32)) -> String {
format!("{}x{}@{}", dims.0, dims.1, hz)
}
/// Prefix EVERY managed KWin output shares (mirrors `kwin::MANAGED_PREFIX`) — the streamed outputs
/// are `Virtual-punktfunk` / `Virtual-punktfunk-<id>`, so a same-family sibling session is never
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
// `MANAGED_PREFIX` — the prefix EVERY managed KWin output shares (`Virtual-punktfunk` /
// `Virtual-punktfunk-<id>`), so a same-family sibling session is never treated as a physical to
// disable and its primary is never stolen (first-slot-wins) — is imported at the top of this file
// from `kwin.rs`, which owns the naming.
/// Every head KWin reports, for [`crate::monitors::list`].
///
@@ -575,12 +737,8 @@ const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// burst is skipped rather than reported half-read (its geometry would be a guess, and geometry is
/// exactly what callers key on).
pub(crate) fn list_monitors() -> anyhow::Result<Vec<crate::monitors::PhysicalMonitor>> {
let session = Session::open().ok_or_else(|| {
anyhow::anyhow!(
"KWin did not answer kde_output_management_v2 (not a KWin session, the protocol is \
not advertised to this client, or the compositor is wedged)"
)
})?;
let session = Session::open("list_monitors")
.map_err(|e| anyhow::anyhow!("KWin did not answer kde_output_management_v2: {e}"))?;
let mut out: Vec<_> = session
.state
.devices
@@ -630,24 +788,12 @@ pub(crate) fn apply_topology(
disabled: Vec::new(),
handled: false,
};
let Some(mut sess) = Session::open() else {
let Ok(mut sess) = Session::open("topology") else {
return miss();
};
let deadline = Instant::now() + OP_BUDGET;
// Resolve OUR output: managed-prefix name AND current size == the birth size (only the
// just-created output sits there during a supersede); newest global wins the tie.
let ours = sess
.state
.devices
.values()
.filter(|d| {
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
})
.max_by_key(|d| d.global)
.cloned();
let Some(ours) = ours else {
let Some(ours) = sess.resolve_ours(our_prefix, our_w, our_h) else {
tracing::warn!(
our_prefix,
our_w,
@@ -846,7 +992,7 @@ pub(crate) fn apply_topology(
/// which is broken under every topology equally. So this reads the state and applies **only** when
/// our output really is mirroring; the ordinary session pays one bounded enumerate and no apply.
pub(crate) fn clear_replication_source(our_prefix: &str, our_w: u32, our_h: u32) {
let Some(mut sess) = Session::open() else {
let Ok(mut sess) = Session::open("clear_replication_source") else {
return;
};
let deadline = Instant::now() + OP_BUDGET;
@@ -858,18 +1004,7 @@ pub(crate) fn clear_replication_source(our_prefix: &str, our_w: u32, our_h: u32)
if mgmt_version < REPLICATION_SOURCE_SINCE {
return;
}
// Same resolve as `apply_topology`: managed-prefix name AND the birth size, newest global wins.
let Some(ours) = sess
.state
.devices
.values()
.filter(|d| {
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
})
.max_by_key(|d| d.global)
.cloned()
else {
let Some(ours) = sess.resolve_ours(our_prefix, our_w, our_h) else {
return;
};
if !is_mirroring(ours.replication_source.as_deref()) {
@@ -918,7 +1053,7 @@ pub(crate) fn set_custom_mode(
want_h: u32,
want_hz: u32,
) -> Option<(u32, u32, u32)> {
let mut sess = Session::open()?;
let mut sess = Session::open("custom_mode").ok()?;
let deadline = Instant::now() + OP_BUDGET;
// `set_custom_modes` is `since 18`; calling it on an older bound management object is a protocol
@@ -928,16 +1063,9 @@ pub(crate) fn set_custom_mode(
return None;
}
// Resolve our output at its birth size (newest global wins a supersede).
// Resolve our output at its birth size (newest wins a supersede — see `resolve_ours`).
let our_proxy = sess
.state
.devices
.values()
.filter(|d| {
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((birth_w, birth_h))
})
.max_by_key(|d| d.global)
.resolve_ours(our_prefix, birth_w, birth_h)
.and_then(|d| d.proxy.clone())?;
let our_key = our_proxy.id();
@@ -985,10 +1113,15 @@ pub(crate) fn set_custom_mode(
}
// Grab the generated mode's proxy, then select it (this is what changes the size).
// Newest match wins: `modes` is in announce order, and the entry we just had KWin generate is
// the last one. An earlier session's identical custom mode may still be listed here — KWin only
// destroys it (`kde_output_device_mode_v2.removed`) when it processes our `set_custom_modes`,
// and that removal may not have been dispatched yet.
let mode_proxy = {
let dev = sess.state.devices.get(&our_key)?;
dev.modes
.iter()
.rev()
.find(|(mid, _)| mode_matches(&sess.state, mid))
.map(|(_, p)| p.clone())?
};
@@ -1031,19 +1164,31 @@ pub(crate) fn set_custom_mode(
}
/// Re-enable outputs by name at their captured `WxH@Hz` modes (teardown), in-process. Returns
/// `true` if the config applied; `false` (compositor unresponsive / management absent) tells the
/// caller to fall back to `kscreen-doctor`.
/// `true` only if EVERY requested output was staged and the config applied; `false` (compositor
/// unresponsive, management absent, or an output we could not address) tells the caller to fall
/// back to `kscreen-doctor`.
///
/// The "every requested output" half is load-bearing, not pedantry. The names in `outputs` were
/// captured on a DIFFERENT connection during [`apply_topology`] and this restore opens a fresh
/// session minutes later, when the display group's last member drops — so a name that no longer
/// resolves is a live possibility. An empty `kde_output_configuration_v2` still gets an `applied`
/// event, so returning the apply verdict alone reported SUCCESS for a total no-op, suppressed the
/// `reenable_outputs_kscreen` backstop, and left a physical monitor dark.
pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
if outputs.is_empty() {
return true;
}
let Some(mut sess) = Session::open() else {
let Ok(mut sess) = Session::open("restore_outputs") else {
return false;
};
let deadline = Instant::now() + OP_BUDGET;
let config = sess.new_config();
let mut matched = 0usize;
for (name, spec) in outputs {
// Find the device by name (physical names are stable across a session).
// Find the device by name (physical names are stable across a session). BOTH misses below
// leave `matched` un-incremented, the proxy one included: a `DeviceState` can be created by
// the event handler ([`State::device_entry`]) and carry a name before the announce that
// records its proxy has been dispatched, and a name with no proxy is not addressable.
let Some(dev) = sess
.state
.devices
@@ -1056,6 +1201,7 @@ pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
let Some(proxy) = dev.proxy.as_ref() else {
continue;
};
matched += 1;
// Enable first — a bare enable always succeeds, so a physical is never left dark.
config.enable(proxy, 1);
// Then re-assert the captured mode so a 120 Hz panel doesn't return at KWin's ~60 Hz default.
@@ -1063,18 +1209,39 @@ pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
config.mode(proxy, &mode);
}
}
if matched == 0 {
// Nothing staged: applying would ack an empty config and read as success. Hand the whole
// restore to kscreen-doctor, which addresses outputs by name and needs no live proxy.
config.destroy();
tracing::warn!(
requested = ?outputs,
"KWin output management: none of the outputs to restore are addressable on this \
connection kscreen-doctor fallback"
);
return false;
}
let ok = sess.apply(&config, deadline);
config.destroy();
if ok {
let complete = ok && matched == outputs.len();
if complete {
tracing::info!(reenabled = ?outputs, "KWin output management: restored outputs (in-process)");
} else {
tracing::warn!(
requested = ?outputs,
matched,
applied = ok,
reason = ?sess.state.failure_reason,
"KWin output management: restore incomplete — kscreen-doctor backstop takes the rest \
(an output left disabled is a physical left dark)"
);
}
ok
complete
}
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
let Some(mut sess) = Session::open() else {
let Ok(mut sess) = Session::open("position") else {
return false;
};
let deadline = Instant::now() + OP_BUDGET;
+160 -33
View File
@@ -122,8 +122,14 @@ impl MutterDisplay {
/// `XDG_SESSION_DESKTOP` alongside would resurrect the bug that scrub exists to prevent — a stale
/// `gnome` there after a gnome-shell crash reports Mutter usable and routes the next client into a
/// dead session (45 s create timeouts instead of a crisp handshake error).
///
/// The read takes [`crate::with_env_lock`]: this runs on a management worker (`/host/compositors` →
/// [`crate::available`]) concurrently with another connect's `apply_session_env`, which `set_var`s
/// this key for a live session and `remove_var`s it when nothing is — and a glibc `getenv` racing
/// that is the `environ` realloc data race ENV_LOCK exists for, torn answer at best and a host
/// segfault mid-connect at worst. Read-then-drop; no caller holds the lock (it is not reentrant).
pub fn is_available() -> bool {
std::env::var("XDG_CURRENT_DESKTOP")
crate::with_env_lock(|| std::env::var("XDG_CURRENT_DESKTOP"))
.map(|d| d.to_ascii_uppercase().contains("GNOME"))
.unwrap_or(false)
}
@@ -718,13 +724,24 @@ async fn connect(
}
// ---------------------------------------------------------------------------------------------
// Optional: make the per-session virtual output the PRIMARY monitor (PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY).
// Optional: make the per-session virtual output the PRIMARY monitor.
//
// `RecordVirtual` adds the virtual monitor as an *extended* desktop. On a headless host that's the
// only display, so the shell + windows live there. But when a physical monitor is attached, GNOME
// keeps it primary and the virtual output is an empty extension — the stream shows only the
// wallpaper. We fix that by promoting the virtual output to primary (physical kept on, secondary)
// via `org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig`, and restore on teardown.
// wallpaper. We fix that by promoting the virtual output via
// `org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig`.
//
// Which shape is `crate::effective_topology()`'s call, not this module's: the console policy first,
// then the legacy `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, then the Auto default. `Primary`
// keeps the physicals on as secondaries; `Exclusive` omits them, so Mutter disables them for the
// session; `Extend` skips this block entirely.
//
// Applied at APPLY_TEMPORARY, and **MUTTER ITSELF REVERTS IT** when the virtual monitor disappears
// and our DisplayConfig connection closes. We must never re-assert the layout on teardown: the
// banner used to promise a "restore on teardown" that the teardown deliberately does not do, and
// issuing that ApplyMonitorsConfig is what SIGSEGVed gnome-shell on Mutter 50 + NVIDIA and wedged a
// box at the GDM greeter (see the teardown comment in `session_thread`).
// ---------------------------------------------------------------------------------------------
/// `org.gnome.Mutter.DisplayConfig.GetCurrentState` reply shapes (see the interface XML):
@@ -811,7 +828,9 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
/// Pure mode-pick for a KEPT physical (unit-tested). Given the physical's PRE-connect mode
/// (`pre_mode = (id, w, h, refresh)`; `None` when the connector is new since the snapshot) and the
/// mode list Mutter reports for it in the POST-virtual state
/// (`(id, w, h, refresh, is_current, is_preferred)`), return the `(mode_id, width)` to re-apply.
/// (`(id, w, h, refresh, is_current, is_preferred)`), return the `(mode_id, width, height)` to
/// re-apply. The height is not decoration: a head rotated 90°/270° is as wide on the desktop as its
/// mode is tall, and the caller lays the kept heads out side by side.
///
/// Mutter re-derives its layout when the `RecordVirtual` output appears and can silently drop a
/// 120 Hz panel to its EDID-preferred 60 Hz — so the post-virtual `is-current` is *already* 60 Hz.
@@ -821,40 +840,40 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
fn pick_keep_mode(
pre_mode: Option<(String, i32, i32, f64)>,
state_modes: &[(String, i32, i32, f64, bool, bool)],
) -> Option<(String, i32)> {
) -> Option<(String, i32, i32)> {
let state_current = || {
state_modes
.iter()
.find(|m| m.4)
.or_else(|| state_modes.iter().find(|m| m.5))
.or_else(|| state_modes.first())
.map(|m| (m.0.clone(), m.1))
.map(|m| (m.0.clone(), m.1, m.2))
};
let Some((pre_id, w, h, hz)) = pre_mode else {
return state_current();
};
// The exact pre mode id, if the connector still offers it (same session ⇒ usually true).
if state_modes.iter().any(|m| m.0 == pre_id) {
return Some((pre_id, w));
return Some((pre_id, w, h));
}
// Else a re-keyed id with the same geometry + refresh (still the real 120 Hz).
if let Some(m) = state_modes
.iter()
.find(|m| m.1 == w && m.2 == h && (m.3 - hz).abs() < 0.5)
{
return Some((m.0.clone(), m.1));
return Some((m.0.clone(), m.1, m.2));
}
// The physical genuinely no longer offers that mode — use whatever is valid now.
state_current()
}
/// The `(mode_id, width)` a kept physical should be RE-APPLIED at — its PRE-connect mode preserved
/// across Mutter's virtual-output layout re-derive. See [`pick_keep_mode`].
/// The `(mode_id, width, height)` a kept physical should be RE-APPLIED at — its PRE-connect mode
/// preserved across Mutter's virtual-output layout re-derive. See [`pick_keep_mode`].
fn physical_keep_mode(
pre: &CurrentState,
state: &CurrentState,
conn: &str,
) -> Option<(String, i32)> {
) -> Option<(String, i32, i32)> {
let pre_mode = current_mode_full(pre, conn);
let state_modes: Vec<(String, i32, i32, f64, bool, bool)> = state
.1
@@ -1044,13 +1063,57 @@ fn snap_integral_scale(want: f64, width: u32, height: u32) -> f64 {
.unwrap_or(want)
}
/// The scale of the logical monitor carrying `connector`, if present.
fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
/// The `(scale, transform)` of the logical monitor carrying `connector`. `None` means **no logical
/// monitor carries it** — which is how Mutter reports a head the operator has DISABLED, and is the
/// distinction [`keep_head_layout`] turns into "leave it off".
fn logical_placement(state: &CurrentState, connector: &str) -> Option<(f64, u32)> {
state
.2
.iter()
.find(|l| l.5.iter().any(|spec| spec.0 == connector))
.map(|l| l.2)
.map(|l| (l.2, l.3))
}
/// The scale of the logical monitor carrying `connector`, if present.
fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
logical_placement(state, connector).map(|(scale, _)| scale)
}
/// Whether a kept physical should be re-applied at all, and with what `(scale, transform)`. Pure —
/// unit-tested, because getting it wrong is invisible on a headless lab box and very visible on the
/// operator's desk.
///
/// The rebuild used to hardcode `scale = 1.0`, `transform = 0` and to list every connector Mutter
/// reported, so one connect un-rotated a portrait panel, dropped a 2×-scaled 4K head to native
/// pixels, and switched a deliberately-dark monitor back on. All three facts are in the PRE-connect
/// snapshot: `pre_logical` is the head's logical-monitor entry there, and Mutter reports a disabled
/// head by omitting it from `logical_monitors` entirely. So: carry the pre values when the head was
/// on; leave it out when the connector existed pre-connect and carried no logical monitor (disabled
/// on purpose); and for a connector that was not in the snapshot at all — a hotplug inside our
/// window — keep it on at whatever Mutter has just derived for it, which is the friendlier reading
/// of "the operator plugged this in while we were connecting".
fn keep_head_layout(
existed_pre: bool,
pre_logical: Option<(f64, u32)>,
state_logical: Option<(f64, u32)>,
) -> Option<(f64, u32)> {
// A non-finite or non-positive scale would fail the whole ApplyMonitorsConfig, taking the
// primary switch down with it.
let sane = |(scale, transform): (f64, u32)| {
(
if scale.is_finite() && scale > 0.0 {
scale
} else {
1.0
},
transform,
)
};
match (pre_logical, existed_pre) {
(Some(l), _) => Some(sane(l)),
(None, true) => None,
(None, false) => Some(sane(state_logical.unwrap_or((1.0, 0)))),
}
}
/// Every head Mutter reports, for [`crate::monitors::list`].
@@ -1142,16 +1205,20 @@ fn build_exclusive_config(vconn: &str, vmode: &str, scale: f64) -> Vec<ApplyLogi
)]
}
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its **pre-connect**
/// mode). So the shell + new windows land on the streamed surface, but the operator's physical
/// screen stays on **at its real refresh**. On a headless host (no physicals) this is identical to
/// [`build_exclusive_config`].
/// **Primary** — the virtual output primary at `(0, 0)`, with every physical monitor the operator
/// had ENABLED kept as a secondary (laid left-to-right past the virtual, each at its **pre-connect**
/// mode, scale and transform). So the shell + new windows land on the streamed surface, but the
/// operator's physical screen stays exactly as they left it. On a headless host (no physicals) this
/// is identical to [`build_exclusive_config`].
///
/// `pre` is the snapshot taken *before* the virtual output existed (physical still at its true
/// refresh); `state` is the post-virtual state. We read each physical's mode from `pre` because
/// Mutter can knock a 120 Hz panel down to 60 Hz when it re-derives the layout for the virtual
/// monitor — reading `state` would cement that 60 Hz (`physical_keep_mode`).
/// refresh); `state` is the post-virtual state. Everything about a kept head is read from `pre`,
/// because the post-virtual state is already contaminated: Mutter re-derives the layout when the
/// `RecordVirtual` output appears and can knock a 120 Hz panel down to 60 Hz, so reading `state`
/// would cement that 60 Hz (`physical_keep_mode`). Scale, transform and enabled-ness come from the
/// same snapshot for the same reason — and because rebuilding them from scratch is what used to
/// un-rotate portrait panels, flatten a 2× scale and re-light a head the operator had switched off
/// ([`keep_head_layout`]).
///
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
@@ -1190,16 +1257,42 @@ fn build_primary_keeping_physicals(
if conn == vconn {
continue;
}
if let Some((mode_id, w)) = physical_keep_mode(pre, state, conn) {
let existed_pre = pre.1.iter().any(|m| m.0 .0 == *conn);
let Some((head_scale, transform)) = keep_head_layout(
existed_pre,
logical_placement(pre, conn),
logical_placement(state, conn),
) else {
// Omitted from the config ⇒ Mutter leaves it disabled, which is what the operator asked
// for. Listing it would switch their dark head on for the length of the session.
tracing::debug!(
connector = %conn,
"mutter: this head was disabled before the session — leaving it disabled"
);
continue;
};
if let Some((mode_id, w, h)) = physical_keep_mode(pre, state, conn) {
logicals.push((
x,
0,
1.0,
0,
head_scale,
transform,
false,
vec![(conn.clone(), mode_id, HashMap::new())],
));
x += w.max(0);
// Advance by the head's own LOGICAL footprint, in the layout's coordinate space — the
// same space the virtual's advance above uses. A 3840-wide panel at scale 2 occupies
// 1920, and a head rotated 90°/270° (transform 1/3, or their flipped twins 5/7) is as
// wide as its mode is TALL. Advancing by raw mode width was only ever *consistent* with
// the forced scale of 1.0 this rebuild used to apply; preserving the real scale without
// this would just trade one wrong layout for another (overlapping or gapped heads).
let rotated = matches!(transform, 1 | 3 | 5 | 7);
let footprint = if rotated { h } else { w };
x += if physical_layout {
footprint.max(0)
} else {
((footprint as f64 / head_scale).round() as i32).max(0)
};
}
}
logicals
@@ -1207,7 +1300,10 @@ fn build_primary_keeping_physicals(
#[cfg(test)]
mod tests {
use super::{pick_keep_mode, pick_virtual, snap_integral_scale, HashMap, Mode, MonitorInfo};
use super::{
keep_head_layout, pick_keep_mode, pick_virtual, snap_integral_scale, HashMap, Mode,
MonitorInfo,
};
// (id, w, h, refresh, is_current, is_preferred)
fn m(
@@ -1232,7 +1328,7 @@ mod tests {
];
assert_eq!(
pick_keep_mode(pre, &state),
Some(("M120".to_string(), 2560))
Some(("M120".to_string(), 2560, 1440))
);
}
@@ -1247,7 +1343,7 @@ mod tests {
];
assert_eq!(
pick_keep_mode(pre, &state),
Some(("new-120".to_string(), 2560))
Some(("new-120".to_string(), 2560, 1440))
);
}
@@ -1262,7 +1358,7 @@ mod tests {
];
assert_eq!(
pick_keep_mode(pre, &state),
Some(("s-100".to_string(), 3440))
Some(("s-100".to_string(), 3440, 1440))
);
}
@@ -1289,7 +1385,10 @@ mod tests {
m("A", 1920, 1080, 60.0, true, false),
m("B", 1920, 1080, 144.0, false, true),
];
assert_eq!(pick_keep_mode(None, &state), Some(("A".to_string(), 1920)));
assert_eq!(
pick_keep_mode(None, &state),
Some(("A".to_string(), 1920, 1080))
);
let no_current = vec![
m("A", 1920, 1080, 60.0, false, false),
@@ -1297,7 +1396,35 @@ mod tests {
];
assert_eq!(
pick_keep_mode(None, &no_current),
Some(("B".to_string(), 1920))
Some(("B".to_string(), 1920, 1080))
);
}
/// A kept physical must come back exactly as the operator had it. Rebuilding the layout from
/// scratch (`scale = 1.0`, `transform = 0`, every connector listed) un-rotated portrait panels,
/// flattened a 2× scale, and switched a deliberately-dark head back on the moment a client
/// connected — while the code went to real trouble to preserve the refresh.
#[test]
fn a_kept_head_carries_its_pre_connect_scale_and_transform() {
// Rotated + 2×-scaled, exactly as it was before the virtual output appeared.
assert_eq!(
keep_head_layout(true, Some((2.0, 1)), Some((1.0, 0))),
Some((2.0, 1))
);
// Disabled on purpose (present pre-connect, carried by no logical monitor) — stays off.
assert_eq!(keep_head_layout(true, None, Some((1.0, 0))), None);
// Hotplugged inside our window: not in the snapshot at all, so keep it on at whatever
// Mutter derived rather than disabling a monitor the operator just plugged in.
assert_eq!(
keep_head_layout(false, None, Some((1.5, 2))),
Some((1.5, 2))
);
assert_eq!(keep_head_layout(false, None, None), Some((1.0, 0)));
// A junk scale would fail the WHOLE ApplyMonitorsConfig, taking the primary switch with it.
assert_eq!(keep_head_layout(true, Some((0.0, 3)), None), Some((1.0, 3)));
assert_eq!(
keep_head_layout(true, Some((f64::NAN, 0)), None),
Some((1.0, 0))
);
}
@@ -99,8 +99,42 @@ pub(crate) fn upsert(existing: &str, block: Block<'_>, key: &str, value: &str) -
/// Read `path`, set `key` in `block`, write it back — and back the original up ONCE, the first time
/// we touch a file we did not write. Returns `true` when the file changed (the caller restarts the
/// portal only then).
///
/// The read is matched EXPLICITLY, and only [`ErrorKind::NotFound`](std::io::ErrorKind::NotFound)
/// may mean "empty". This used to be `read_to_string(path).unwrap_or_default()`, which folded every
/// read failure into an empty string — and an empty string is the one input for which this function
/// destroys data: `upsert("")` yields a file holding ONLY our block, the backup below is skipped
/// because there is nothing to back up, and the write replaces the user's config. One non-UTF-8 byte
/// in a comment (a Latin-1 character, an 8-bit paste) or a transient EIO on an NFS/overlay config
/// dir was enough, and the result was exactly the silent, permanent loss this module exists to
/// prevent. A config we cannot read is a config we refuse to rewrite.
pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str) -> Result<bool> {
let existing = std::fs::read_to_string(path).unwrap_or_default();
// Read BYTES: whether a backup is owed is a question about what is on disk, not about what
// decoded — and the decode failure below is itself one of the cases that must not be silent.
let raw = match std::fs::read(path) {
Ok(b) => Some(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(e).with_context(|| {
format!(
"read {} (refusing to rewrite a portal config we could not read)",
path.display()
)
})
}
};
let existing = match &raw {
Some(bytes) => std::str::from_utf8(bytes)
.with_context(|| {
format!(
"{} is not UTF-8 — refusing to rewrite it (the one key we own is not worth \
losing the rest of the file for; fix or move the file and reconnect)",
path.display()
)
})?
.to_string(),
None => String::new(),
};
let updated = upsert(&existing, block, key, value);
if updated == existing {
return Ok(false);
@@ -108,9 +142,9 @@ pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str)
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?;
}
// One-time backup. `create_new` makes this genuinely once: a later edit must not overwrite the
// user's ORIGINAL with our own previous output.
if !existing.is_empty() {
// One-time backup, of the bytes we actually read. `create_new` makes this genuinely once: a
// later edit must not overwrite the user's ORIGINAL with our own previous output.
if let Some(bytes) = raw.as_deref().filter(|b| !b.is_empty()) {
let backup = path.with_extension("punktfunk-backup");
match std::fs::OpenOptions::new()
.write(true)
@@ -119,7 +153,7 @@ pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str)
{
Ok(mut f) => {
use std::io::Write;
let _ = f.write_all(existing.as_bytes());
let _ = f.write_all(bytes);
tracing::info!(
backup = %backup.display(),
"backed up the existing portal config before editing it"
@@ -133,10 +167,92 @@ pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str)
),
}
}
std::fs::write(path, &updated).with_context(|| format!("write {}", path.display()))?;
write_atomic(path, updated.as_bytes())?;
Ok(true)
}
/// Replace `path`'s contents with `bytes` **atomically**: fill a temp file beside it, then rename
/// over it. `fs::write` truncates first and fills afterwards, so a crash, a full disk or a killed
/// host between the two leaves the user's config truncated — the same loss this module exists to
/// prevent, arrived at from the other side. The temp file goes in the SAME directory because a
/// rename is only atomic within one filesystem, and it inherits the original's permission bits so
/// an operator's 0600 config does not come back at the umask default.
///
/// A **symlinked** config is followed first, and that is not a nicety: `fs::write` opens the path
/// and therefore writes through the link, while `rename(2)` replaces the link itself. Individual
/// files under `~/.config` are symlinks into a dotfiles repo on every stow / chezmoi / home-manager
/// setup, so renaming over `~/.config/hypr/xdph.conf` would detach the user's repo — their next
/// `stow` reports a conflict or quietly reverts our key, and the connect after that writes it
/// again, forever. Following the link keeps this write byte-for-byte equivalent to the `fs::write`
/// it replaced, atomicity aside; it also makes the permission copy below sample the file the
/// rename actually lands on rather than one it was about to orphan.
///
/// The case this deliberately does NOT paper over: a link into a read-only target (home-manager
/// pointing at `/nix/store`). Following it fails the write, and the caller fails the connect with
/// the store path in the error — exactly as the pre-atomic `fs::write` did. Renaming over the link
/// instead would "work" by quietly detaching a declaratively managed file, which the user's next
/// `home-manager switch` refuses or reverts; a nix-managed config has to gain our key in the
/// user's flake, and a legible error is the only thing that tells them so.
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let resolved = follow_link(path);
let path = resolved.as_path();
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let stem = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "config".to_string());
// Per-process name: two hosts editing the same config must not fill one another's temp file.
let tmp = dir.join(format!(".{stem}.punktfunk-{}.tmp", std::process::id()));
let write = || -> Result<()> {
{
let mut f =
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
f.write_all(bytes)
.with_context(|| format!("write {}", tmp.display()))?;
// The rename must not publish a name whose contents are still in the page cache only.
f.sync_all()
.with_context(|| format!("sync {}", tmp.display()))?;
} // closed before the rename — Windows is far happier renaming a file nobody holds open.
if let Ok(md) = std::fs::metadata(path) {
let _ = std::fs::set_permissions(&tmp, md.permissions());
}
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))
};
let r = write();
if r.is_err() {
// Never leave a half-written dotfile beside the user's config.
let _ = std::fs::remove_file(&tmp);
}
r
}
/// `path` with a symlink chain followed to the file it names, or `path` itself when it is not a
/// link (including when it does not exist yet — the ordinary first-connect case).
///
/// `symlink_metadata` rather than `metadata`, because the question is what `path` IS, not what it
/// points at. A **dangling** link is resolved by hand from its target text: `canonicalize` refuses
/// a target that does not exist, but `fs::write` through such a link creates it, and this write
/// stands in for that one.
fn follow_link(path: &Path) -> std::path::PathBuf {
match std::fs::symlink_metadata(path) {
Ok(md) if md.file_type().is_symlink() => std::fs::canonicalize(path)
.or_else(|_| {
std::fs::read_link(path).map(|target| {
if target.is_absolute() {
target
} else {
// A relative link is relative to the DIRECTORY holding it.
path.parent().unwrap_or_else(|| Path::new(".")).join(target)
}
})
})
.unwrap_or_else(|_| path.to_path_buf()),
_ => path.to_path_buf(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -237,3 +353,240 @@ mod tests {
);
}
}
/// [`ensure_key`] itself — the half that touches the user's disk.
///
/// The merge above was pinned by seven cases while the I/O wrapper around it, which is where the
/// destructive behaviour lives (the read, the once-only backup, the replacing write), had none. That
/// is backwards: `upsert` can at worst return a wrong string, `ensure_key` can delete a config.
/// Filesystem-only — no compositor, no portal — so these run on every platform, like the merge tests.
#[cfg(test)]
mod io_tests {
use super::*;
/// A scratch directory removed on drop. `tempfile` is deliberately not a dependency of this
/// crate; the temp-dir + pid + counter convention is the one `proc.rs`'s fixtures already use.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("pf-vd-portalcfg-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir");
Self(dir)
}
fn path(&self, name: &str) -> std::path::PathBuf {
self.0.join(name)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn backup_of(p: &Path) -> std::path::PathBuf {
p.with_extension("punktfunk-backup")
}
/// The data-loss case. A config that cannot be decoded must be left EXACTLY as it is: the old
/// `unwrap_or_default()` turned it into an empty string, wrote a file holding only our block,
/// skipped the backup (nothing to back up, as far as it could tell) and returned `Ok(true)`.
#[test]
fn a_non_utf8_config_is_refused_not_replaced() {
let s = Scratch::new("nonutf8");
let p = s.path("config");
// A Latin-1 'ÿ' in a comment — the whole file is otherwise perfectly ordinary.
let raw: &[u8] = b"[screencast]\n# r\xffgler\nchooser_type=simple\noutput_name=DP-1\n";
std::fs::write(&p, raw).expect("seed");
let err = ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x")
.expect_err("an unreadable config must not be rewritten");
assert!(
format!("{err:#}").contains("not UTF-8"),
"the error must name the real cause: {err:#}"
);
assert_eq!(
std::fs::read(&p).expect("still there"),
raw,
"byte-identical"
);
assert!(
!backup_of(&p).exists(),
"nothing was edited, so nothing is owed a backup"
);
}
/// The ordinary first-connect path: no file yet, so one is created — and there is no original
/// to preserve, so no backup is left lying beside it.
#[test]
fn a_missing_file_is_created_without_a_backup() {
let s = Scratch::new("missing");
let p = s.path("nested").join("config");
assert!(ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("write"));
assert_eq!(
std::fs::read_to_string(&p).expect("created"),
"[screencast]\nchooser_cmd=cat x\n"
);
assert!(!backup_of(&p).exists());
}
/// `create_new` is what makes the backup once-only, and this is the invariant it buys: after a
/// second edit (a new `$XDG_RUNTIME_DIR`, so a new value) the backup must still hold the user's
/// PRISTINE file — not our own previous output.
#[test]
fn the_backup_holds_the_original_across_two_edits() {
let s = Scratch::new("backup");
let p = s.path("config");
let pristine = "[screencast]\nchooser_type=simple\noutput_name=DP-1\n";
std::fs::write(&p, pristine).expect("seed");
assert!(
ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat /run/a").expect("1st")
);
assert!(
ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat /run/b").expect("2nd")
);
assert_eq!(
std::fs::read_to_string(backup_of(&p)).expect("backup"),
pristine
);
let now = std::fs::read_to_string(&p).expect("edited");
assert!(
now.contains("chooser_cmd=cat /run/b"),
"the second value won"
);
assert!(
now.contains("output_name=DP-1"),
"the user's other keys survived"
);
}
/// Idempotence at the I/O level: an already-correct file is not rewritten and reports `false`,
/// because the caller RESTARTS the portal on `true` — a spurious `true` restarts xdpw/xdph on
/// every connect.
#[test]
fn an_unchanged_file_returns_false_and_does_not_rewrite() {
let s = Scratch::new("unchanged");
let p = s.path("config");
assert!(ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("1st"));
let after_first = std::fs::read_to_string(&p).expect("written");
let mtime = std::fs::metadata(&p)
.and_then(|m| m.modified())
.expect("mtime");
assert!(
!ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("2nd"),
"an unchanged config must report no change"
);
assert_eq!(
std::fs::read_to_string(&p).expect("still there"),
after_first
);
assert_eq!(
std::fs::metadata(&p)
.and_then(|m| m.modified())
.expect("mtime"),
mtime,
"the file must not have been touched at all"
);
}
/// The write publishes the WHOLE new file or nothing (temp + rename), and it leaves no debris
/// beside the config — a stray dotfile in `~/.config/hypr` is the kind of thing that outlives
/// several releases.
#[test]
fn the_write_is_atomic_and_leaves_no_temp_behind() {
let s = Scratch::new("atomic");
let p = s.path("config");
std::fs::write(&p, "[other]\nkeep=me\n").expect("seed");
assert!(ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("write"));
let names: Vec<String> = std::fs::read_dir(&s.0)
.expect("dir")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
!names.iter().any(|n| n.ends_with(".tmp")),
"temp file left behind: {names:?}"
);
assert!(std::fs::read_to_string(&p)
.expect("edited")
.contains("keep=me"));
}
/// A user who manages dotfiles (stow, chezmoi, home-manager) has `~/.config/hypr/xdph.conf` as
/// a SYMLINK into their repo. The edit has to land in the repo file with the link intact:
/// `fs::write` followed the link, the temp-file + `rename` that replaced it does not, and a
/// detached link is a config the user's tooling then fights us over on every connect.
#[cfg(unix)]
#[test]
fn a_symlinked_config_is_edited_through_the_link() {
let s = Scratch::new("symlink");
let repo = s.path("dotfiles");
std::fs::create_dir_all(&repo).expect("repo dir");
let real = repo.join("xdph.conf");
std::fs::write(
&real,
"screencopy {\n allow_token_by_default = true\n}\n",
)
.expect("seed");
let link = s.path("xdph.conf");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
assert!(ensure_key(
&link,
Block::Hyprlang("screencopy"),
"custom_picker_binary",
"/run/user/1000/shim.sh",
)
.expect("write"));
assert!(
std::fs::symlink_metadata(&link)
.expect("still there")
.file_type()
.is_symlink(),
"the dotfiles link was replaced by a detached regular file"
);
let target = std::fs::read_to_string(&real).expect("the repo file");
assert!(
target.contains("custom_picker_binary = /run/user/1000/shim.sh"),
"the edit never reached the repo file: {target}"
);
assert!(
target.contains("allow_token_by_default = true"),
"the user's own keys survived"
);
}
/// The link may point at a file that does not exist yet (a repo checkout that has not been
/// populated). `fs::write` created the target through it, so this must too — replacing the
/// link would again detach it.
#[cfg(unix)]
#[test]
fn a_dangling_symlink_is_written_through_to_its_target() {
let s = Scratch::new("dangling");
let repo = s.path("dotfiles");
std::fs::create_dir_all(&repo).expect("repo dir");
let real = repo.join("config");
let link = s.path("config");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
assert!(
ensure_key(&link, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("write")
);
assert!(
std::fs::symlink_metadata(&link)
.expect("still there")
.file_type()
.is_symlink(),
"the link was replaced instead of written through"
);
assert_eq!(
std::fs::read_to_string(&real).expect("target created"),
"[screencast]\nchooser_cmd=cat x\n"
);
}
}
+149 -25
View File
@@ -40,7 +40,11 @@ fn chooser_file() -> String {
}
/// The chooser command xdpw runs via `/bin/sh -c`, reading stdout. The `|| echo` fallback keeps
/// plain portal capture (`--source portal`) working when no session has written the chooser file.
/// plain portal capture (`--source portal`) working when no session of ours is mid-handshake — it
/// is a GUESS at sway's own first headless output, right on a box whose sway loads the headless
/// backend with one output of its own and wrong (a cast of nothing) otherwise. It is reachable
/// again: the per-session file is removed with the handshake it steers ([`ChooserFile`]), so it no
/// longer sits there naming an output we have since unplugged.
fn chooser_cmd() -> String {
format!(
"cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'",
@@ -68,8 +72,14 @@ impl WlrootsDisplay {
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by `SWAYSOCK`
/// (the IPC socket `swaymsg create_output` needs). Cheap env check for the enumeration path.
///
/// Under [`crate::with_env_lock`]: this runs on a management worker (`/host/compositors` →
/// [`crate::available`]) concurrently with another connect's `apply_session_env`, which `set_var`s
/// — and, when no sway session is live, `remove_var`s — this very key. A glibc `getenv` racing a
/// `setenv` is the `environ` realloc data race ENV_LOCK exists for, and it is UB whichever key each
/// side names. No caller holds the lock (the mutex is not reentrant).
pub fn is_available() -> bool {
std::env::var_os("SWAYSOCK").is_some()
crate::with_env_lock(|| std::env::var_os("SWAYSOCK")).is_some()
}
impl VirtualDisplay for WlrootsDisplay {
@@ -86,13 +96,33 @@ impl VirtualDisplay for WlrootsDisplay {
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
let before = output_names()
.context("swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)")?;
swaymsg(&["create_output"])
.context("swaymsg create_output (sway needs the headless backend loaded)")?;
// The output appears synchronously in practice; poll briefly to be safe, and own it
// from here on so error unwinding unplugs it.
let output = OutputGuard(wait_new_output(&before, Duration::from_secs(5))?);
warn_topology_is_extend_only();
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
// itself (`HEADLESS-N`), so the only way to know which one is ours is "the name that was not
// there before" — and two concurrent creates each picking the other's output is a silent
// mis-capture, not a failure (mutter's TOPOLOGY_LOCK exists for exactly this class). The
// lock also gives the failure path somewhere safe to unplug from: the output already exists
// by the time `wait_new_output` can fail, and nothing else may have created one meanwhile.
let output = {
let _create = CREATE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let before = output_names().context(
"swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)",
)?;
swaymsg(&["create_output"])
.context("swaymsg create_output (sway needs the headless backend loaded)")?;
// The output appears synchronously in practice; poll briefly to be safe, and own it
// from here on so error unwinding unplugs it.
match wait_new_output(&before, Duration::from_secs(5)) {
Ok(name) => OutputGuard(name),
Err(e) => {
// `create_output` reported success, so an output very probably exists — it just
// never showed up in time (or showed up a moment after we gave up). Unowned, it
// would sit in the operator's sway layout forever.
unplug_strays(&before);
return Err(e);
}
}
};
let name = output.0.clone();
// The client's exact mode (also the refresh clock that makes the output produce frames).
@@ -128,7 +158,7 @@ impl VirtualDisplay for WlrootsDisplay {
remote_fd: Some(fd),
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
keepalive: Box::new(Keepalive {
_stop: StopGuard(stop),
_stop: stop,
_output: output,
}),
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
@@ -159,6 +189,52 @@ impl Drop for StopGuard {
}
}
/// Serializes **snapshot → `create_output` → identify-the-new-name**, process-wide. sway names its
/// headless outputs itself, so ownership is established by a before/after diff and two concurrent
/// creates would each adopt the other's output — which does not fail, it silently streams the wrong
/// one. Mutter's `TOPOLOGY_LOCK` is the same guard for the same reason; Hyprland needs none because
/// it lets us NAME the output (D6).
static CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Unplug any headless output that appeared since `before` and that nothing owns — the cleanup for a
/// `create_output` whose output we could not identify in time. Only `HEADLESS-*` is touched: a
/// physical hotplug in the same window is the operator's, not ours, and `unplug` on a real connector
/// would take their screen away. Best-effort by construction, and it runs with [`CREATE_LOCK`] held
/// so nothing else in this process can have created the strays it sees.
fn unplug_strays(before: &[String]) {
let Ok(now) = output_names() else { return };
for name in now
.into_iter()
.filter(|n| n.starts_with("HEADLESS-") && !before.iter().any(|b| b == n))
{
match swaymsg(&["output", &name, "unplug"]) {
Ok(_) => tracing::warn!(output = %name, "unplugged a headless output we created but \
could not identify in time"),
Err(e) => tracing::warn!(output = %name, error = %format!("{e:#}"), "could not unplug \
the headless output left behind by a failed create"),
}
}
}
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
/// (sweep 13.18). sway's virtual output is always an EXTENSION: nothing here promotes it to primary
/// or disables the operator's heads.
fn warn_topology_is_extend_only() {
let topology = crate::effective_topology();
if !matches!(
topology,
crate::policy::Topology::Extend | crate::policy::Topology::Auto
) {
tracing::warn!(
?topology,
"wlroots: this backend implements EXTEND only — the headless output is added beside the \
operator's heads and nothing is promoted or disabled. Configure `topology: extend` to \
stop the console promising otherwise."
);
}
}
/// Owns the created headless output; dropping it unplugs it from sway.
struct OutputGuard(String);
@@ -171,15 +247,26 @@ impl Drop for OutputGuard {
}
}
/// Budget for one `swaymsg` call ([`crate::proc`]).
///
/// swaymsg is a CLIENT of the compositor it drives: against a wedged sway it blocks in its own
/// connect to the IPC socket and never returns — and these calls run on the session's stream thread,
/// whose only way to end a session is to return, so one hung query used to wedge the session
/// permanently. Generous next to a healthy call (single-digit milliseconds), and every call site
/// here already has a failed-query path, so a timeout lands on behaviour that already exists.
const SWAYMSG_BUDGET: Duration = Duration::from_secs(5);
/// Budget for the one-shot xdpw restart. `systemctl --user try-restart` waits for the unit's job to
/// settle, so it is the slowest helper on this path — and its result is already ignored.
const PORTAL_RESTART_BUDGET: Duration = Duration::from_secs(10);
/// Run `swaymsg -- <args>`, returning stdout (`--` so command tokens like `--custom` reach
/// sway instead of swaymsg's own getopt). swaymsg exits non-zero (with the error on stderr/
/// stdout) when the command fails, so checking the status covers `{"success": false}` too.
fn swaymsg(args: &[&str]) -> Result<String> {
let out = Command::new("swaymsg")
.arg("--")
.args(args)
.output()
.context("run swaymsg (is sway installed?)")?;
let out =
crate::proc::output_within(Command::new("swaymsg").arg("--").args(args), SWAYMSG_BUDGET)
.context("run swaymsg (is sway installed?)")?;
if !out.status.success() {
bail!(
"swaymsg {:?} failed: {}{}",
@@ -197,10 +284,11 @@ fn swaymsg(args: &[&str]) -> Result<String> {
/// *command*, which is right for `create_output` and wrong for a query — `-t` after `--` comes back
/// as `Unknown/invalid command '-t'` (caught on-glass writing the monitor enumeration).
fn swaymsg_query(kind: &str) -> Result<serde_json::Value> {
let out = Command::new("swaymsg")
.args(["-t", kind, "--raw"])
.output()
.context("run swaymsg (is sway installed?)")?;
let out = crate::proc::output_within(
Command::new("swaymsg").args(["-t", kind, "--raw"]),
SWAYMSG_BUDGET,
)
.context("run swaymsg (is sway installed?)")?;
if !out.status.success() {
bail!(
"swaymsg -t {kind} failed: {}",
@@ -230,13 +318,37 @@ fn output_names() -> Result<Vec<String>> {
/// handshake, not just the write, because the read happens inside it.
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The per-session chooser file, removed when the handshake it steers is over.
///
/// Its lifetime is the HANDSHAKE, not the session: xdpw reads it once, inside
/// [`select_and_cast`]'s critical section, and everything after that is the cast's own business.
/// Left behind (as it was) the stale `Monitor: HEADLESS-3` outlives the output `Drop` has since
/// unplugged, and it permanently shadows [`chooser_cmd`]'s `|| echo` fallback — so a later
/// `--source portal` capture with no session of ours running steers at a connector that is gone.
/// Tying removal to the CAST instead would be worse still: the file is one per user, so a session
/// ending hours later would delete a *sibling's* selection out from under its picker.
struct ChooserFile(String);
impl Drop for ChooserFile {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_file(&self.0) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::debug!(path = %self.0, error = %e, "could not remove the xdpw chooser file");
}
}
}
}
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
ensure_xdpw_config()?;
let chooser = chooser_file();
std::fs::write(&chooser, format!("Monitor: {output}\n"))
.with_context(|| format!("write {chooser}"))?;
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
// only thing that reads it.
let _chooser = ChooserFile(chooser);
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
@@ -244,8 +356,16 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<A
.name("punktfunk-wlr-cast".into())
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn wlroots portal thread")?;
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
// arms dropping an un-set flag: the thread's `send` can still LAND in the queue in the window
// between `recv_timeout` giving up and `setup_rx` being dropped, so it reports success and then
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
// lifetime, against an output that no longer exists.
let guard = StopGuard(stop);
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
}
@@ -266,7 +386,7 @@ pub(crate) fn stream_existing_output(
Ok(crate::mirror::MirrorStream {
node_id,
remote_fd: Some(fd),
keepalive: Box::new(StopGuard(stop)),
keepalive: Box::new(stop),
})
}
@@ -374,9 +494,13 @@ fn ensure_xdpw_config() -> Result<()> {
return Ok(());
}
tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-wlr at the managed output chooser");
let _ = Command::new("systemctl")
.args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"])
.status();
// Bounded: `systemctl --user` blocks on the user manager's job queue, and this runs on the
// session's stream thread. Its result was already ignored — a timeout just means the portal
// picks the new config up whenever it next starts.
let _ = crate::proc::status_within(
Command::new("systemctl").args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"]),
PORTAL_RESTART_BUDGET,
);
Ok(())
}
+61 -5
View File
@@ -65,6 +65,16 @@ impl VirtualDisplay for MirrorDisplay {
self.hw_cursor
}
fn poolable_now(&self) -> bool {
// Never. `create` below always reports `DisplayOwnership::External` — we did not make this
// head and must not keep it — so the registry never pools a mirror, and the trait's `true`
// default was a claim this backend cannot honour on any request. It costs nothing today
// (the reuse lookup can only miss: no `"mirror"` entry ever enters the pool), but it is the
// answer the registry consults BEFORE `create` gets to declare ownership, so leaving it
// optimistic means the one pre-create statement of intent contradicts the post-create fact.
false
}
fn create(&mut self, _mode: Mode) -> Result<VirtualOutput> {
// Resolve the pin against the live head list FIRST: it yields the geometry the input anchor
// needs, and it turns "that monitor is gone" into one clear error before any compositor
@@ -101,7 +111,14 @@ impl VirtualDisplay for MirrorDisplay {
Compositor::Gamescope => {
crate::gamescope::stream_existing_output(&target.connector, self.hw_cursor)?
}
#[allow(unreachable_patterns)]
// Gated to non-Linux (`monitors::list`'s shape), NOT the bare `#[allow(unreachable_
// patterns)] other =>` this replaced: with it, a newly added `Compositor` variant fell
// through to a runtime bail on the very platform that would define it, silently, in the
// one place that decides which backends can mirror a head. Cfg'd out on Linux, the match
// is exhaustive and the new variant is a compile error here instead. The arm exists at
// all only because every arm above is itself `cfg(target_os = "linux")` — this module is
// Linux-only today, so it is a placeholder that keeps the shape honest if that changes.
#[cfg(not(target_os = "linux"))]
other => bail!(
"mirroring an existing monitor is not supported on the {} backend",
other.id()
@@ -172,11 +189,28 @@ fn check_mirrorable(target: &monitors::PhysicalMonitor, compositor: Compositor)
Ok(())
}
/// Does this compositor's `managed` flag mean "ours, for certain"? KWin outputs carry the
/// `Virtual-punktfunk` prefix we chose, and Hyprland's are `PF-N` — both ours by construction.
/// Sway's `HEADLESS-N` is sway's own generic naming, so it is a hint, not proof.
/// Does this compositor's `managed` flag mean "ours, for certain"?
///
/// EXHAUSTIVE on purpose, unlike the `matches!` it used to be. This is the one table in the crate
/// whose un-listed default is the UNSAFE direction: a `false` sends [`check_mirrorable`] down the
/// warn-and-proceed branch, which for a backend that DOES name its managed outputs by construction
/// (the KWin/Hyprland shape — i.e. both backends that have the property today) means streaming
/// punktfunk's own virtual display back to the client, the capture loop
/// `one_of_our_own_virtual_displays_is_refused` exists to forbid. Adding a `Compositor` variant must
/// therefore be a compile error here rather than a silent opt-out. (Contrast
/// [`Compositor::needs_live_session`], also a `matches!` — its omitted default is the safe one.)
fn names_ours_conclusively(compositor: Compositor) -> bool {
matches!(compositor, Compositor::Kwin | Compositor::Hyprland)
match compositor {
// Ours by construction: KWin outputs carry the `Virtual-punktfunk-<id>` name the identity
// module hands the backend, Hyprland's are `PF-N`. Nothing else mints those names.
Compositor::Kwin | Compositor::Hyprland => true,
// Sway names EVERY headless output `HEADLESS-N`, its own included; Mutter's virtual monitors
// carry no distinguishing name at all (it won't take one from us); and gamescope's
// `list_monitors` only ever reports the real DRM head a Game Mode session drives, so
// `managed` is never even set there. A hint at most — refusing would break the legitimate
// headless-sway setup this feature serves.
Compositor::Wlroots | Compositor::Mutter | Compositor::Gamescope => false,
}
}
/// mHz → whole Hz for [`VirtualOutput::preferred_mode`], never 0 (the negotiation treats 0 as
@@ -252,6 +286,28 @@ mod tests {
assert!(check_mirrorable(&m, Compositor::Hyprland).is_err());
}
/// Pin the conclusive-naming table per variant. The answer is a safety decision whose wrong
/// direction is the SILENT one: a backend that mints punktfunk-named outputs but is missing
/// from the `true` arm takes the warn-and-proceed branch and streams our own virtual display
/// back to the client. Exhaustive `match` + this test = the new variant has to be considered.
#[test]
fn the_conclusive_naming_table_is_pinned_per_backend() {
assert!(names_ours_conclusively(Compositor::Kwin));
assert!(names_ours_conclusively(Compositor::Hyprland));
assert!(!names_ours_conclusively(Compositor::Wlroots));
assert!(!names_ours_conclusively(Compositor::Mutter));
assert!(!names_ours_conclusively(Compositor::Gamescope));
}
/// The registry asks `poolable_now` BEFORE `create` gets to report ownership, so the two must
/// agree: a mirror's `create` always reports `External` (we did not make this head), therefore
/// no mirror request is ever poolable.
#[test]
fn a_mirrored_head_is_never_registry_poolable() {
let vd = MirrorDisplay::new(Compositor::Kwin, "DP-2".into()).unwrap();
assert!(!vd.poolable_now());
}
/// A head listed but not driving a mode (enabled yet modeless) would negotiate a 0x0 stream.
#[test]
fn a_head_with_no_current_mode_is_refused() {
+147 -14
View File
@@ -18,15 +18,22 @@
use crate::Compositor;
use anyhow::{bail, Result};
/// One head as the compositor currently reports it. Logical (post-scale) geometry throughout —
/// the same coordinate space libei regions and compositor layout use, *not* pixels.
/// One head as the compositor currently reports it.
///
/// **The two halves live in different spaces, and that is not an accident.** `x`/`y` are LOGICAL —
/// the compositor's global layout coordinates, the same space libei regions use — while
/// `width`/`height` are the current mode in PIXELS, because that is what every backend actually
/// reports (KWin's `current_mode` size, `hyprctl`'s mode, the CCD path's source mode) and what a
/// capturer has to open against. `scale` is the factor between them: see [`Self::logical_size`],
/// which is the only correct way to compare a size against `x`/`y`. An earlier version of this doc
/// claimed logical geometry "throughout", which is a trap for exactly the consumer that mixes them.
#[derive(Clone, Debug, PartialEq)]
pub struct PhysicalMonitor {
/// Connector name — `DP-1`, `HDMI-A-2`, `eDP-1`. The id `PUNKTFUNK_CAPTURE_MONITOR` names.
pub connector: String,
/// Human label for a picker (`make model`, else the connector). Never used for matching.
pub description: String,
/// Current mode, in pixels.
/// Current mode, in PIXELS (not the logical size — see the type doc and [`Self::logical_size`]).
pub width: u32,
pub height: u32,
/// Refresh in mHz (60000 = 60 Hz). 0 when the backend doesn't report it.
@@ -71,6 +78,24 @@ pub(crate) fn describe(make: &str, model: &str, connector: &str) -> String {
}
impl PhysicalMonitor {
/// The head's extent in the SAME space as `x`/`y` — mode pixels divided by `scale`.
///
/// The bridge between the two spaces this type carries, and the only correct way to ask "does
/// this head's box contain that layout coordinate?". A consumer that compares `width`/`height`
/// against `x`/`y` directly is right only at scale 1.0 and silently wrong on every fractional
/// KDE/GNOME desk (a 3840-px panel at 150 % occupies 2560 logical units, so a naive
/// `x + width` overlaps the head to its right by 1280).
///
/// A non-positive scale can only come from a backend that reported nonsense; it is treated as
/// 1.0 rather than dividing by zero.
pub fn logical_size(&self) -> (f64, f64) {
let scale = if self.scale > 0.0 { self.scale } else { 1.0 };
(
f64::from(self.width) / scale,
f64::from(self.height) / scale,
)
}
/// `1920x1080@60` — for logs and pickers.
pub fn mode_label(&self) -> String {
if self.refresh_mhz == 0 {
@@ -94,8 +119,11 @@ impl PhysicalMonitor {
/// callers resolving a pinned monitor must not (see [`resolve`]).
pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
match compositor {
// Via the `kwin` backend rather than `kwin_output_mgmt` directly: it owns the
// in-process-then-`kscreen-doctor` ladder, so this read degrades the same way every other
// KWin operation does instead of being the one that hard-fails on a wedged/old compositor.
#[cfg(target_os = "linux")]
Compositor::Kwin => crate::kwin_output_mgmt::list_monitors(),
Compositor::Kwin => crate::kwin::list_monitors(),
#[cfg(target_os = "linux")]
Compositor::Mutter => crate::mutter::list_monitors(),
#[cfg(target_os = "linux")]
@@ -133,15 +161,21 @@ pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
/// * `refresh_mhz` comes from the path's own rational rate, which keeps 59.94 distinct from 60.
#[cfg(windows)]
pub fn list_windows() -> Result<Vec<PhysicalMonitor>> {
let inv = pf_win_display::win_display::target_inventory();
if inv.is_empty() {
// Distinguish "reached it, nothing there" from a failure, exactly as [`list`] promises:
// an empty CCD database is a real state (every panel off — measured on .173 with the TV
// powered down), not an error.
return Ok(Vec::new());
}
Ok(inv
.into_iter()
// `Ok` even when the inventory is empty, exactly as [`list`] promises: an empty CCD database is
// a real state (every panel off — measured on .173 with the TV powered down), not a failure.
// Everything past the OS call is the pure mapping, so it lives where a test can reach it.
Ok(from_inventory(
pf_win_display::win_display::target_inventory(),
))
}
/// The CCD inventory → [`PhysicalMonitor`] mapping, split from the OS call so the Windows test leg
/// can exercise it (`list_windows` touches the display database on its first line, which left the
/// only mapping that decides what an operator can PIN with no coverage on the one platform that
/// runs it).
#[cfg(windows)]
fn from_inventory(inv: Vec<pf_win_display::win_display::TargetInventory>) -> Vec<PhysicalMonitor> {
inv.into_iter()
.map(|t| {
// The GDI name is what an operator recognises and what capture pins on; an inactive
// path has none, so fall back to the stable target id rather than an empty string —
@@ -167,7 +201,7 @@ pub fn list_windows() -> Result<Vec<PhysicalMonitor>> {
managed: t.ours,
}
})
.collect())
.collect()
}
/// Resolve a configured monitor name against `monitors`, exactly then case-insensitively.
@@ -257,6 +291,24 @@ mod tests {
assert_eq!(describe(" ", "unknown", "DP-2"), "DP-2");
}
/// The two spaces this type carries: the mode is pixels, `x`/`y` are logical, and `scale` is
/// the only thing that relates them. A 4K panel at KDE's 150 % really does occupy 2560x1440
/// logical units, which is what a consumer comparing against `x`/`y` must use.
#[test]
fn logical_size_divides_the_mode_by_the_scale() {
let mut m = mon("DP-1");
m.width = 3840;
m.height = 2160;
m.scale = 1.5;
assert_eq!(m.logical_size(), (2560.0, 1440.0));
// Unscaled: the two spaces coincide, which is why the trap goes unnoticed on most desks.
m.scale = 1.0;
assert_eq!(m.logical_size(), (3840.0, 2160.0));
// A backend that reported nonsense must not produce an infinity or a NaN.
m.scale = 0.0;
assert_eq!(m.logical_size(), (3840.0, 2160.0));
}
#[test]
fn mode_label_drops_an_unknown_refresh() {
let mut m = mon("DP-1");
@@ -265,3 +317,84 @@ mod tests {
assert_eq!(m.mode_label(), "1920x1080");
}
}
/// The Windows inventory mapping. Windows-only because it maps a Windows-only type — the CI leg
/// that runs it (`windows-host.yml`, `cargo test --release -p pf-vdisplay`) already exists; until
/// [`from_inventory`] was split out of the OS call there was simply nothing there to run.
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
use pf_win_display::win_display::TargetInventory;
/// One inventory row. Built through a single helper so a field rename shows up in one place —
/// the struct is another crate's and carries no `Default`.
fn target(target_id: u32, gdi_name: &str, active: bool) -> TargetInventory {
TargetInventory {
target_id,
active,
external_physical: true,
internal_panel: false,
tech: "HDMI",
friendly: "ACME TV".into(),
monitor_device_path: r"\\?\DISPLAY#ACM1234#".into(),
ours: false,
gdi_name: gdi_name.into(),
x: 0,
y: 0,
width: 1920,
height: 1080,
refresh_mhz: 59940,
primary: active,
}
}
/// An INACTIVE path has no source and therefore no GDI name. It must still be listed (the
/// "why can't I pick it?" contract) under an id that can actually be pinned — a blank connector
/// could never be resolved, and an operator would have no way to name the head at all.
#[test]
fn an_inactive_path_gets_a_target_id_connector_and_enabled_false() {
let mons = from_inventory(vec![target(4352, "", false)]);
assert_eq!(mons.len(), 1);
assert_eq!(mons[0].connector, "target-4352");
assert!(!mons[0].enabled);
// Windows applies DPI per application rather than a compositor-global logical scale, so
// the geometry above is pixels and the factor is honestly 1.0 — see the fn doc.
assert_eq!(mons[0].scale, 1.0);
}
/// The two halves must agree: whatever connector this mapping synthesizes has to be a name
/// [`resolve`] can find, because that pair is the whole pin round-trip the console offers.
#[test]
fn resolve_can_find_a_synthesized_target_name() {
let mons = from_inventory(vec![
target(4352, "", false),
target(1, r"\\.\DISPLAY1", true),
]);
assert_eq!(
resolve(&mons, "target-4352")
.expect("synthesized name")
.width,
1920
);
// An active path keeps its GDI name — the id an operator recognises.
assert_eq!(
resolve(&mons, r"\\.\DISPLAY1").expect("gdi name").connector,
r"\\.\DISPLAY1"
);
assert!(
resolve(&mons, r"\\.\display1").is_ok(),
"and case-insensitively, as `resolve` promises"
);
}
/// Our own IddCx display is flagged, so a picker can grey it out — the one thing Windows can
/// answer reliably and the Linux backends cannot.
#[test]
fn our_own_idd_is_marked_managed() {
let mut ours = target(257, r"\\.\DISPLAY2", true);
ours.ours = true;
let mons = from_inventory(vec![ours]);
assert!(mons[0].managed);
assert!(!from_inventory(vec![target(1, r"\\.\DISPLAY1", true)])[0].managed);
}
}
File diff suppressed because it is too large Load Diff
+233 -24
View File
@@ -12,21 +12,51 @@
//! take their existing failure path instead of hanging.
//!
//! What the budget bounds is the whole **process tree**, not just the process we spawned — see
//! [`tree`] for why that distinction is the entire difference on Windows.
//! [`tree`] for why that distinction is the entire difference on Windows, and for the one Unix
//! case (a unit the *user manager* forks for us) that even a process group cannot reach.
use std::io::{Error, ErrorKind, Result};
// `Read` is in scope for `Take::read_to_end` below — a `Take<R>` is a concrete type, so the
// generic bound alone does not bring the trait's methods with it.
use std::io::{Error, ErrorKind, Read, Result};
use std::process::{Command, ExitStatus, Output};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::time::{Duration, Instant};
/// Poll interval while waiting for a child to exit. Short enough that a fast helper (the normal
/// case — `kscreen-doctor` answers in tens of ms) isn't measurably delayed.
const POLL: Duration = Duration::from_millis(20);
/// Ceiling on how long [`output_within`] waits for its two reader threads once the child **and the
/// process group under it** are dead.
///
/// This is not a working budget — with every write end we can reach closed, the readers hit EOF
/// within a scheduler slice — it is the bound on the one case we cannot reach. [`tree`] ends a
/// *group*, so a descendant that deliberately left it keeps the write end open: `systemd-run
/// --pipe` (the gamescope bind probe) hands our pipes to a transient unit the **user manager**
/// forks, in its own group and session, and `killpg` by construction cannot touch it. Waiting on
/// that reader would pin the caller — on the host, the session's stream thread — for as long as
/// the unit lives, which is exactly the unbounded wait this module exists to prevent. So the
/// *call* is bounded here and the reader thread, not the call, is what gets left behind. The
/// price, paid only in that case, is that a call can return up to this much after its own budget —
/// still a bound, which an unreachable EOF is not.
const DRAIN_GRACE: Duration = Duration::from_secs(2);
/// Ceiling on what one drained pipe may buffer.
///
/// `read_to_end` is unbounded in memory, and a reader thread that outlived its call (see
/// [`DRAIN_GRACE`]) has nobody left to stop it — the cap is what keeps such a thread finite in
/// both memory and lifetime, and closing its read end is also what finally gives the escaped
/// writer an EPIPE. 16 MiB is an order of magnitude above the largest `pw-dump` a populated
/// PipeWire graph produces, so hitting it means a helper that ran away rather than one that was
/// busy; it is logged instead of being returned as quietly short output.
const DRAIN_CAP: u64 = 16 * 1024 * 1024;
/// Run `cmd` to completion, killing it if it outlives `budget`.
///
/// Stdout/stderr are left as the caller configured them (inherited by default), so this is for
/// commands run for their exit status alone — see [`output_within`] when the output is read.
pub(crate) fn status_within(cmd: &mut Command, budget: Duration) -> Result<ExitStatus> {
tree::prepare(cmd);
let mut child = cmd.spawn()?;
let tree = tree::Guard::attach(&child);
let deadline = Instant::now() + budget;
@@ -51,37 +81,132 @@ pub(crate) fn status_within(cmd: &mut Command, budget: Duration) -> Result<ExitS
/// Run `cmd` to completion and capture its stdout/stderr, killing it if it outlives `budget`.
///
/// The output is read only after the child has exited, so a helper that fills the pipe buffer and
/// stalls is caught by the budget rather than deadlocking the reader (these helpers emit at most a
/// few hundred KiB, well under any real pipe pressure).
/// Both pipes are drained **concurrently with the wait**, on their own threads. Reading them only
/// after exit — the obvious shape, and what this did originally — deadlocks on any helper that
/// outtalks the pipe buffer: a pipe holds **64 KiB** on Linux (`/proc/sys/fs/pipe-max-size`'s page
/// default), not the "few hundred KiB" the old comment claimed, so a chatty helper blocks in
/// `write()`, never reaches exit, is killed at the budget, and its output is discarded as a
/// timeout. `pw-dump` on a populated PipeWire graph clears 64 KiB routinely, and it is polled from
/// the 45 s gamescope loops — so the failure was not hypothetical, it was the busiest caller.
pub(crate) fn output_within(cmd: &mut Command, budget: Duration) -> Result<Output> {
tree::prepare(cmd);
let mut child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let tree = tree::Guard::attach(&child);
// Taken off the `Child` so the reader threads own them outright: `wait_with_output` must not
// also be reading these, and `try_wait` below needs `&mut child` while they run.
let (stdout, stderr) = (child.stdout.take(), child.stderr.take());
let (out_rx, err_rx) = (drain(stdout), drain(stderr));
let deadline = Instant::now() + budget;
loop {
let status = loop {
match child.try_wait()? {
Some(_) => {
// Exited: `wait_with_output` now only drains already-buffered pipes — but only if
// nothing else still holds their WRITE end. A grandchild that outlived the helper
// does, and `wait_with_output` reads to an EOF that would then never arrive, which
// is the one way this "bounded" helper could still hang forever. End the tree first.
Some(status) => {
// The helper is gone, but a grandchild it left behind still holds the pipes' WRITE
// ends, so the readers below would wait for an EOF that never arrives. Ending the
// tree closes them for every descendant that stayed in the group — which is all of
// them for a direct exec, but NOT for one that left it (see [`DRAIN_GRACE`]), so
// the collection below is bounded rather than a plain join.
tree.terminate();
return child.wait_with_output();
break status;
}
None if Instant::now() >= deadline => {
tree.terminate();
let _ = child.kill();
let _ = child.wait();
let _ = child.wait(); // reap it — never leave a zombie behind
// Reap the READERS too. This arm used to just drop their handles, i.e. detach two
// threads still blocked in `read_to_end` and still owning the pipes' read ends —
// so a writer that escaped the group (a `systemd-run --pipe` unit) never even got
// the EPIPE the pre-drain implementation gave it by closing those fds with the
// `Child`. Joining unconditionally instead would be worse: it would hand the
// escaped writer the caller's thread, forever, which is the failure this whole
// module exists to prevent. So: a bounded collection, and an honest log when one
// of them cannot be reclaimed.
let until = Instant::now() + DRAIN_GRACE;
let (out, err) = (collect(&out_rx, until), collect(&err_rx, until));
if out.is_none() || err.is_none() {
stuck_reader(cmd, "killed at its budget");
}
return Err(timed_out(cmd, budget));
}
None => std::thread::sleep(POLL),
}
};
// Both halves of the output, or none: a caller parsing half a `pw-dump` is a caller being lied
// to, and its failure path is the one it already has for a helper that did not answer.
let until = Instant::now() + DRAIN_GRACE;
let (Some(stdout), Some(stderr)) = (collect(&out_rx, until), collect(&err_rx, until)) else {
stuck_reader(cmd, "exited");
let program = cmd.get_program().to_string_lossy().to_string();
return Err(Error::new(
ErrorKind::TimedOut,
format!(
"`{program}` exited but its output could not be drained within {DRAIN_GRACE:?}"
),
));
};
Ok(Output {
status,
stdout,
stderr,
})
}
/// Read one of a child's pipes on its own thread, so the child never blocks in `write()` waiting
/// for us to catch up, and hand the result back over a channel — not a `JoinHandle`, because the
/// caller must be able to give up on a reader it cannot unblock (see [`DRAIN_GRACE`]) and a
/// `join` offers no way to. Returns whatever was read; a read error yields the partial buffer,
/// because the caller's failure signal is the budget, not a short pipe.
fn drain<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(r) = pipe {
let mut r = r.take(DRAIN_CAP);
let _ = r.read_to_end(&mut buf);
if buf.len() as u64 >= DRAIN_CAP {
tracing::warn!(
cap_bytes = DRAIN_CAP,
"a helper outran the drain cap — its output is truncated here, which the \
caller sees as an unparseable answer (i.e. a failed query)"
);
}
}
// The receiver is gone whenever the call has already returned — a timeout, or a grace that
// ran out. That is the only way this send fails, and it is a case we chose.
let _ = tx.send(buf);
});
rx
}
/// Take one drained pipe, waiting no longer than `until`. `None` means the reader is still parked
/// on a write end nothing we can signal is holding open.
fn collect(rx: &Receiver<Vec<u8>>, until: Instant) -> Option<Vec<u8>> {
match rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
Ok(buf) => Some(buf),
// The reader panicked: that loses its half of the output, never the call.
Err(RecvTimeoutError::Disconnected) => Some(Vec::new()),
Err(RecvTimeoutError::Timeout) => None,
}
}
/// Say plainly what a stuck reader costs, because the thread is genuinely leaked and there is no
/// portable way to unblock a thread already inside `read()` on a pipe (a `dup2` over the fd does
/// not re-target a read in flight, and closing it under the thread is a use-after-free waiting for
/// an fd number to be reused). It ends when the escaped writer closes or [`DRAIN_CAP`] is reached.
fn stuck_reader(cmd: &Command, what: &str) {
tracing::warn!(
program = %cmd.get_program().to_string_lossy(),
grace_ms = DRAIN_GRACE.as_millis() as u64,
"helper {what} but its pipes never reached EOF — something it started is outside our \
process group and still holds the write end (`systemd-run --pipe` is the known case). \
The call is bounded; the reader thread is detached until that writer closes."
);
}
fn timed_out(cmd: &Command, budget: Duration) -> Error {
let program = cmd.get_program().to_string_lossy().to_string();
tracing::warn!(
@@ -240,11 +365,10 @@ fn undecorate(name: &str) -> &str {
/// Ending the *tree* the helper started, not just the process we spawned.
///
/// [`std::process::Child::kill`] is one `TerminateProcess` / one `SIGKILL`: it ends exactly the
/// process we launched. On Unix that is the whole story here — `kscreen-doctor`, `systemctl`,
/// `pw-dump` and friends are single processes we exec directly, and none of them forks a worker
/// that outlives it.
/// process we launched. That is never the whole story — see the Unix twin below for why it is not
/// enough there either — but Windows is where it fails hardest.
///
/// On Windows it is not, because there is no direct exec: every helper is reached through a shell
/// On Windows there is no direct exec: every helper is reached through a shell
/// (`cmd /c …`, `powershell -Command "… | pnputil …"`), so the process that actually hangs is a
/// **grandchild**. Killing the shell leaves it running — holding the stdio handles and the working
/// directory it inherited from us — and a budget that leaves that behind has not bounded anything.
@@ -273,6 +397,10 @@ mod tree {
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
/// Nothing to arrange before the spawn: job membership is assigned to the live process, so
/// [`Guard::attach`] does all of it. The Unix twin has to act here instead.
pub(super) fn prepare(_cmd: &mut std::process::Command) {}
/// Owns a Job object holding the spawned helper and everything it spawns. `None` when the job
/// could not be set up (see the module doc: degrade, don't fail).
pub(super) struct Guard(Option<HANDLE>);
@@ -352,18 +480,62 @@ mod tree {
}
}
/// The Unix half: `Child::kill` already ends the only process there is (see the Windows module doc
/// for why that is not true there). Kept as a real type rather than `cfg`ing the call sites, so the
/// two platforms read as one flow.
/// The Unix half — a **process group**, which is what Unix offers in place of a Job object.
///
/// This used to be an empty stub whose doc said `Child::kill` "already ends the only process there
/// is". Most Linux helpers here really are a single exec — `kscreen-doctor`, `pw-dump`, `hyprctl`,
/// `swaymsg` — but not all of them: `systemd-run --user` and `systemctl --user` do their work
/// through the user manager, which forks the actual process, so what hangs is routinely something
/// `Child::kill` cannot reach. With the reader threads in [`output_within`] waiting until every
/// write end of a pipe closes, one surviving relative is all it takes to keep a "bounded" call
/// going, which is why the group exists here too.
///
/// [`prepare`] puts the child in a new process group (it becomes the leader, so the group id is its
/// pid) and [`Guard::terminate`] `killpg`s that group, reaching every descendant that has not
/// deliberately left it. `process_group` changes only the group — not the session — so the helper
/// keeps its controlling terminal and login session, which anything doing a logind/polkit session
/// lookup depends on. Note the limit that follows from this and is NOT closed here: a process the
/// **user manager** forks on our behalf (`systemd-run --pipe`, whose transient unit inherits our
/// pipe write ends) is in another group and session by construction, so `killpg` misses it — see
/// [`DRAIN_GRACE`] for how the reader side is bounded in spite of that. The crate's one privileged
/// path, `pkexec` for the DM helper, deliberately does not come through this module at all: it
/// calls `Command::output()` directly and is documented as unbounded, because a `stop`/`restore`
/// verb legitimately takes seconds and killing it mid-flight is worse than waiting.
///
/// Best-effort in the same way as the Windows half: a failed `killpg` is ignored, and the
/// single-process `Child::kill` on the timeout path still runs.
#[cfg(not(windows))]
mod tree {
pub(super) struct Guard;
use std::os::unix::process::CommandExt;
/// The child's process-group id, captured while the child is still ours to reap.
pub(super) struct Guard(Option<i32>);
/// Make the child the leader of its own process group, so its descendants are reachable as one.
pub(super) fn prepare(cmd: &mut std::process::Command) {
cmd.process_group(0);
}
impl Guard {
pub(super) fn attach(_child: &std::process::Child) -> Self {
Self
pub(super) fn attach(child: &std::process::Child) -> Self {
// `prepare` asked for `process_group(0)`, so the group id IS the child's pid.
Self(i32::try_from(child.id()).ok())
}
/// End every process still in the group. A no-op once they have all exited, so this is safe
/// to call on the success path as well as the timeout one.
pub(super) fn terminate(&self) {
let Some(pgid) = self.0 else { return };
// `killpg` is a signal to a group we created and whose leader is the child we spawned;
// it cannot name a process we did not start. The one theoretical hazard is pid reuse
// between the leader's reap and this call, which needs a brand-new process to land on
// exactly that pid AND be a group leader — Linux hands out pids sequentially to
// `pid_max`, so there is no window to speak of, and the alternative (not killing) is
// the unbounded wait this module exists to prevent.
// SAFETY: a plain signal send by group id. No pointer is passed, nothing is aliased,
// and the result is deliberately ignored — ESRCH just means the group is already gone.
unsafe { libc::killpg(pgid, libc::SIGKILL) };
}
pub(super) fn terminate(&self) {}
}
}
@@ -390,6 +562,43 @@ mod tests {
);
}
/// A helper whose output exceeds one pipe buffer must still be captured IN FULL.
///
/// This is the case that fails against a `wait_with_output`-after-exit implementation: the
/// child blocks in `write()` with the pipe full, never exits, and the budget turns a perfectly
/// successful query into a `TimedOut` with its output thrown away. 1 MiB is ~16× a Linux pipe
/// (64 KiB) and ~64× the smallest macOS one, so it cannot be absorbed by a buffer on either.
#[test]
fn a_child_that_outruns_the_pipe_buffer_is_captured_in_full() {
const BYTES: usize = 1024 * 1024;
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(format!("yes punktfunk | head -c {BYTES}; echo done >&2"));
let out = output_within(&mut cmd, Duration::from_secs(20)).expect("must not time out");
assert!(out.status.success(), "helper failed: {:?}", out.status);
assert_eq!(out.stdout.len(), BYTES, "stdout was truncated");
assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "done");
}
/// A helper that exits while a background child of its own still holds the pipe must not park
/// the caller: the reader waits for EOF on ALL write ends, so the grandchild's copy is what
/// would keep it there. Ending the process group is what closes it — and the collection is
/// bounded ([`DRAIN_GRACE`]) so that even the one relative a `killpg` cannot reach (a unit the
/// user manager forked for us) costs a detached thread rather than the calling thread.
#[test]
fn a_grandchild_holding_the_pipe_does_not_park_the_caller() {
let started = Instant::now();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 30 & echo punktfunk");
let out = output_within(&mut cmd, Duration::from_secs(10)).expect("the helper exited");
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "punktfunk");
assert!(
started.elapsed() < Duration::from_secs(5),
"the call waited on the grandchild's EOF (took {:?})",
started.elapsed()
);
}
/// The normal path is unaffected: a quick command still yields its status and its output.
#[test]
fn a_quick_child_returns_normally() {
File diff suppressed because it is too large Load Diff
+25 -21
View File
@@ -77,29 +77,22 @@ fn pick_gamescope_mode(
}
}
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
/// tears the TV's autologin down on connect, restored on a debounced idle; only where
/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
/// forces managed over all of it.
/// The operator's gamescope overrides, sampled ONCE — before this module has written anything.
/// The operator's gamescope overrides, sampled ONCE — at first use, and never written back.
///
/// [`apply_input_env`] both WRITES `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` (to publish the sub-mode it
/// chose) and READS them as operator overrides. Reading them live therefore fed the ladder its own
/// previous output: the Attach arm sets `_NODE=auto`, and `node_env` sits at rung 2 of
/// `apply_input_env` used to both WRITE `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` (to publish the
/// sub-mode it chose) and READ them as operator overrides. Reading them live therefore fed the
/// ladder its own previous output: the Attach arm set `_NODE=auto`, and `node_env` sits at rung 2 of
/// [`pick_gamescope_mode`] — 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 (`_MANAGED`) could escape, because the Spawn arm that would clear the keys sits below the rung
/// that by then always fired.
///
/// Sampling at first use keeps the override's actual meaning — "the operator set this before we
/// ran" — and makes it immune to our own writes. The live reads that remain
/// ([`launch_is_nested`], gamescope's `poolable_now`) are deliberate: those consume the PUBLISHED
/// decision, which is what the keys carry after this function has run.
/// ran". Nothing publishes these keys any more (see [`resolve_gamescope_route`]): the resolved
/// decision travels as a [`GamescopeRoute`] VALUE carried on the backend instance, and every
/// consumer takes it that way — [`launch_is_nested`] by parameter, gamescope's `poolable_now` off
/// `self.route`, `crate::gamescope_hdr_available` by re-resolving the ladder. A change that
/// "restores" the write to serve some reader would restore the latch with it.
#[cfg(target_os = "linux")]
static OPERATOR_GAMESCOPE: std::sync::OnceLock<OperatorGamescope> = std::sync::OnceLock::new();
@@ -138,6 +131,16 @@ fn operator_gamescope() -> &'static OperatorGamescope {
})
}
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors.
///
/// For gamescope the sub-mode ladder ([`pick_gamescope_mode`]) selects **managed** (a host-managed
/// session at the client's mode — tears the TV's autologin down on connect, restored on a debounced
/// idle; only where session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at
/// its own mode; explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the
/// fallback for a foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless
/// gamescope nesting the session's launch command — the plain-distro default).
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over all of it.
///
/// Returns the resolved [`GamescopeRoute`] when `chosen` is gamescope — the caller must carry it to
/// the backend instance via `VirtualDisplay::set_gamescope_route`. It is a RETURN VALUE and no
@@ -449,11 +452,12 @@ mod tests {
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
}
/// The ladder must not be able to read back its own output. `apply_input_env`'s Attach arm
/// writes `PUNKTFUNK_GAMESCOPE_NODE=auto`, and `node_env` outranks `dedicated_launch` — so when
/// the override was read live, one Attach latched Attach for the host's lifetime and silently
/// overrode `game_session=dedicated`. Sampling once is what breaks the loop; this pins that the
/// sample does not move when the key is written afterwards.
/// The ladder must not be able to read back its own output. `apply_input_env`'s Attach arm used
/// to write `PUNKTFUNK_GAMESCOPE_NODE=auto`, and `node_env` outranks `dedicated_launch` — so
/// while the override was read live, one Attach latched Attach for the host's lifetime and
/// silently overrode `game_session=dedicated`. Sampling once is what breaks the loop, and it is
/// what makes restoring the write a non-event rather than a relapse; this pins that the sample
/// does not move when the key is written afterwards.
#[test]
#[cfg(target_os = "linux")]
fn operator_overrides_do_not_see_our_own_writes() {
+143 -12
View File
@@ -58,22 +58,21 @@ pub fn observe_session_instance(active: &ActiveSession) {
let changed = {
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
let prev = *last;
*last = Some(cur);
// A `None` scan result is NOT an observation (see [`classify_instance_change`]), so it must
// not become the baseline either: recording it would make the NEXT poll — the one that sees
// the still-running desktop again — read as `None → DesktopKde`, i.e. a fresh instance, and
// bump the epoch out from under every pooled display. Leave the baseline on the last REAL
// instance and a transient miss is fully inert, in both directions.
if cur.0 != ActiveKind::None {
*last = Some(cur);
}
prev
};
if let Some(prev) = changed {
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
if let InstanceChange::NewInstance { invalidate } = classify_instance_change(prev, cur) {
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
if is_desktop_kind(prev.0) {
if let Some(old) = compositor_for_kind(prev.0) {
if let Some(old_kind) = invalidate {
if let Some(old) = compositor_for_kind(old_kind) {
registry::invalidate_backend(old.id());
}
// The dead desktop's socket vars may still sit in the systemd --user manager env
@@ -95,6 +94,54 @@ pub fn observe_session_instance(active: &ActiveSession) {
}
}
/// What a `prev` → `cur` observation means for the session epoch — the pure core of
/// [`observe_session_instance`], so the (surprisingly load-bearing) rules below are unit-tested
/// without the process-global baseline.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum InstanceChange {
/// The same instance, or a change the epoch does not track — do nothing.
Nothing,
/// A new compositor instance: bump the epoch. `invalidate` names the OUTGOING desktop
/// compositor whose kept displays must be dropped (its PipeWire nodes died with it); `None`
/// when the outgoing session was gamescope / nothing, which owns no pooled displays.
NewInstance { invalidate: Option<ActiveKind> },
}
/// The epoch's rules, in one place:
///
/// * A `cur` of [`ActiveKind::None`] is **never** a change. `detect_active_session` answers `None`
/// both for "no graphical session is running" and for a scan that simply saw nothing — its whole
/// probe hangs off `if let Ok(entries) = std::fs::read_dir("/proc")`, and every per-PID rung
/// (`metadata`, `match_name`) can lose a race with a re-exec. Treating that as "the desktop
/// changed" ran `registry::invalidate_backend`, which removes pool entries in ANY lifecycle state
/// — Active ones included — so one unlucky `/proc` read tore down displays that were mid-stream,
/// and scrubbed the live session's socket vars out of the systemd `--user` manager on the way.
/// A real logout is picked up by the NEXT real observation (a different kind, or the same kind at
/// a new PID), which is the evidence-carrying end of the same transition.
/// * Only a **desktop** compositor (KWin / Mutter / wlroots) instance change counts. A **gamescope**
/// session ([`ActiveKind::Gaming`]) is not the epoch's subject: the box's game-mode / managed
/// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
/// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming
/// winning-PID flap (e.g. B1 stopping the autologin before a dedicated spawn), or a
/// coexisting-gamescope set change must NOT bump/invalidate — that would tear down a live/kept
/// dedicated session (review findings #6/#7/#10).
/// * A same-kind PID change IS a change: a fresh KWin's node-id space is unrelated to the dead
/// one's (A4).
fn classify_instance_change(
prev: (ActiveKind, Option<u32>),
cur: (ActiveKind, Option<u32>),
) -> InstanceChange {
if cur.0 == ActiveKind::None
|| prev == cur
|| !(is_desktop_kind(prev.0) || is_desktop_kind(cur.0))
{
return InstanceChange::Nothing;
}
InstanceChange::NewInstance {
invalidate: is_desktop_kind(prev.0).then_some(prev.0),
}
}
/// Counterpart to [`settle_desktop_portal`]'s `import-environment`: drop the desktop session's
/// socket vars from the systemd `--user` manager env once that desktop instance is GONE. They
/// persist in the manager otherwise, and every later user unit inherits them — including
@@ -702,6 +749,90 @@ pub fn settle_desktop_portal(chosen: Compositor) {
#[cfg(not(target_os = "linux"))]
pub fn settle_desktop_portal(_chosen: Compositor) {}
/// The epoch rules are platform-neutral (they are pure over [`ActiveKind`] + PID), so — unlike the
/// `/proc`-and-socket tests below — these run on every host this crate builds on.
#[cfg(test)]
mod instance_change_tests {
use super::*;
/// The 10.9 regression: a scan that answered `None` while KDE was in fact still up used to
/// satisfy `is_desktop_kind(prev)` and run the full invalidate — which drops pool entries in
/// ANY state, live streaming ones included.
#[test]
fn a_none_observation_is_never_a_change() {
for prev in [
(ActiveKind::DesktopKde, Some(42)),
(ActiveKind::DesktopGnome, Some(7)),
(ActiveKind::Gaming, Some(9)),
(ActiveKind::None, None),
] {
assert_eq!(
classify_instance_change(prev, (ActiveKind::None, None)),
InstanceChange::Nothing,
"a None scan result must not invalidate {prev:?}"
);
}
}
#[test]
fn a_desktop_swap_invalidates_the_outgoing_desktop() {
assert_eq!(
classify_instance_change(
(ActiveKind::DesktopKde, Some(1)),
(ActiveKind::DesktopGnome, Some(2))
),
InstanceChange::NewInstance {
invalidate: Some(ActiveKind::DesktopKde)
}
);
// Desktop → gamescope (Game Mode): the dead KWin's kept displays go with it.
assert_eq!(
classify_instance_change(
(ActiveKind::DesktopKde, Some(1)),
(ActiveKind::Gaming, Some(2))
),
InstanceChange::NewInstance {
invalidate: Some(ActiveKind::DesktopKde)
}
);
// gamescope → desktop: a new epoch, but gamescope owns no pooled entries to invalidate.
assert_eq!(
classify_instance_change(
(ActiveKind::Gaming, Some(1)),
(ActiveKind::DesktopKde, Some(2))
),
InstanceChange::NewInstance { invalidate: None }
);
}
#[test]
fn a_same_kind_restart_is_a_new_instance_but_a_gamescope_flap_is_not() {
// A fresh KWin (new PID) has an unrelated node-id space — A4.
assert_eq!(
classify_instance_change(
(ActiveKind::DesktopKde, Some(1)),
(ActiveKind::DesktopKde, Some(2))
),
InstanceChange::NewInstance {
invalidate: Some(ActiveKind::DesktopKde)
}
);
// The same instance re-detected: inert.
assert_eq!(
classify_instance_change(
(ActiveKind::DesktopKde, Some(1)),
(ActiveKind::DesktopKde, Some(1))
),
InstanceChange::Nothing
);
// Gaming↔Gaming winning-PID flap: never the epoch's business (findings #6/#7/#10).
assert_eq!(
classify_instance_change((ActiveKind::Gaming, Some(1)), (ActiveKind::Gaming, Some(2))),
InstanceChange::Nothing
);
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
@@ -1,15 +1,16 @@
//! Host-lifetime virtual-display **ownership model** (Goal-1 §2.5). One reference-counted monitor
//! lifecycle, shared by both Windows backends (SudoVDA + pf-vdisplay) instead of the two verbatim-
//! duplicated `MGR: Mutex<Mgr>` globals each backend used to carry.
//! lifecycle, born as the shared half of two Windows backends (SudoVDA + pf-vdisplay) so the two
//! verbatim-duplicated `MGR: Mutex<Mgr>` globals could go; the SudoVDA backend has since been
//! removed, so pf-vdisplay is the sole driver behind the seam.
//!
//! [`VirtualDisplayManager`] owns the earned Idle/Active/Lingering refcount machine + the linger timer +
//! a **typed** [`OwnedHandle`] control device (no more raw `isize` smuggled across the pinger/linger
//! threads). The backend differences — the IOCTL protocol and the per-monitor REMOVE key — are the only
//! threads). The driver-specific part — the IOCTL protocol and the per-monitor REMOVE key — is the only
//! thing behind the [`VdisplayDriver`] seam; the state machine, the render-adapter pin decision, the
//! GDI/CCD glue (`pf_win_display::win_display`), and the generation-stamped [`MonitorLease`] are backend-neutral.
//! GDI/CCD glue (`pf_win_display::win_display`), and the generation-stamped [`MonitorLease`] are driver-neutral.
//!
//! It's a process-wide singleton ([`vdm`]) initialised once with the chosen backend's driver — the
//! host runs exactly one virtual-display backend per process. The session holds a [`MonitorLease`];
//! It's a process-wide singleton ([`vdm`]) initialised once with the driver — the host runs exactly
//! one virtual-display backend per process. The session holds a [`MonitorLease`];
//! its `Drop` releases the refcount (a *stale* lease — its monitor was preempted + recreated under it —
//! is a no-op, so it can never tear down the live monitor).
@@ -86,7 +87,28 @@ struct Monitor {
/// is why WUDFHost death is ALL-slot shared fate.
wudf_pid: u32,
gdi_name: Option<String>,
/// The mode the OS actually COMMITTED for this monitor, not the one the client asked for — all
/// three paths that write it (create, re-arrival, in-place resize) read it back through
/// [`committed_mode_or`]. It is what `output_for` hands the capturer as `preferred_mode` and
/// what `/display/state` reports, so a requested-but-never-committed refresh here mis-paces the
/// encoder. It is NOT, on its own, the resize discriminator — see `requested_mode` below.
mode: Mode,
/// The mode the monitor was ASKED for at its last ADD/mode-set — the client's negotiated mode,
/// verbatim.
///
/// Kept beside the committed one because [`needs_resize`] is a two-sided question and `mode`
/// alone cannot answer it. `set_active_mode` deliberately commits the highest advertised refresh
/// <= the requested one rather than lose the client's resolution, so on a box that will not
/// advertise the negotiated rate (5120x1440@240 = 1.77 Gpix/s is the documented example) the two
/// fields PERMANENTLY disagree. If `acquire` diffed the incoming request against `mode` only,
/// every later acquire at the very mode the session already negotiated would read as a
/// mid-stream resize — and since `slot_id_for` keys on resolution alone, a refresh-only
/// divergence stays in the same slot and the divergence re-records itself on each pass. That is
/// not academic: `build_pipeline_with_retry` takes a retry-hold lease and then EVERY build
/// attempt re-`create`s the identical mode expecting a refcount++ join, so the slot would take
/// an in-place-resize attempt and then a full REMOVE→ADD hotplug per attempt — the exact churn
/// that exhausts the IddCx monitor-slot pool and wedges ADD at 0x80070490.
requested_mode: Mode,
/// The monitor id the driver actually resolved (the EDID serial / ConnectorIndex) — equals the
/// slot key when the per-client preference was honored, or the auto-allocated id (diagnostics).
resolved_monitor_id: u32,
@@ -165,7 +187,7 @@ struct GroupState {
ccd_exclusive: bool,
}
/// How a mid-stream re-arrival ([`ManagerInner::re_add`]) ended.
/// How a mid-stream re-arrival ([`VirtualDisplayManager::re_add`]) ended.
///
/// Three-way on purpose. `re_add` REMOVEs the old driver monitor before it ADDs the new one, so
/// once the ADD fails the old monitor is GONE — and the caller used to answer that by putting its
@@ -188,7 +210,7 @@ enum ReAdd {
/// What a NON-LAST-member teardown owes the group's topology.
///
/// Split out of [`ManagerInner::teardown_removed`] so the gate is testable without a driver, a CCD
/// Split out of [`VirtualDisplayManager::teardown_removed`] so the gate is testable without a driver, a CCD
/// device or a desktop — the Windows half of this crate has no other way to pin a decision.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ShrinkAction {
@@ -200,11 +222,7 @@ enum ShrinkAction {
Nothing,
}
/// `ccd_exclusive` is the discriminator, NOT `ccd_saved.is_some()`: `Topology::Primary` stores a
/// snapshot too (from `set_virtual_primary_ccd`), so keying on the snapshot ran the EXCLUSIVE
/// isolate on a Primary group — clearing `DISPLAYCONFIG_PATH_ACTIVE` on every non-kept path, i.e.
/// blanking the very physical displays `Primary` exists to keep lit.
/// One stage of [`ManagerInner::resolve_target_gdi`]'s ladder: poll for the target's GDI name until
/// One stage of [`VirtualDisplayManager::resolve_target_gdi`]'s ladder: poll for the target's GDI name until
/// the 3 s ceiling. 50 ms sampling (latency plan P0.5) — a typical activation resolves on an early
/// poll, so finer sampling shaves ~150 ms off every stage crossing.
///
@@ -257,6 +275,15 @@ fn isolate_displays_ccd_seam(keep_target_ids: &[u32]) -> Option<SavedConfig> {
isolate_displays_ccd(keep_target_ids)
}
/// Decide the [`ShrinkAction`] a NON-LAST-member teardown owes the group.
///
/// `ccd_exclusive` is the discriminator, NOT `has_saved`: `Topology::Primary` stores a `ccd_saved`
/// snapshot too (from `set_virtual_primary_ccd`), so keying on the snapshot ran the EXCLUSIVE
/// isolate on a Primary group — clearing `DISPLAYCONFIG_PATH_ACTIVE` on every non-kept path, i.e.
/// blanking the very physical displays `Primary` exists to keep lit. (This paragraph had been
/// concatenated onto `poll_gdi_name`'s doc with no blank line between them, so the only written
/// record of the Phase-3.3 gate documented an unrelated polling helper and this fn read as
/// undocumented — a maintainer's invitation to "simplify" it back to the broken predicate.)
fn shrink_action(ccd_exclusive: bool, has_saved: bool) -> ShrinkAction {
if ccd_exclusive {
ShrinkAction::Reisolate
@@ -267,6 +294,69 @@ fn shrink_action(ccd_exclusive: bool, has_saved: bool) -> ShrinkAction {
}
}
/// The mode `target_id` is ACTUALLY running, for a caller about to RECORD it, with `requested` as
/// the fallback whenever the read-back cannot be trusted.
///
/// Every path that stores a `Monitor.mode` owes this call: `set_active_mode` deliberately commits
/// the highest advertised refresh <= the requested one rather than lose the client's resolution, and
/// the `wait_mode_settled` that precedes every store verifies the RESOLUTION only — so a `true`
/// settle is no evidence at all about the refresh. `active_mode`'s own doc states the contract:
/// "Callers that RECORD a mode must record this, or they claim a refresh the display is not
/// running."
///
/// Deliberately narrowed to the REFRESH. A read-back that FAILS, or that reports a different
/// RESOLUTION, keeps `requested`: the create path proceeds even when its settle timed out, so the
/// OS may still be sitting on its own default there, and recording that would hand the capturer +
/// the client a size nobody negotiated. The capturer already re-resolves the live size on its own
/// (`active_resolution` poll, game-capture GB1); the refresh is the field only this read-back can
/// answer.
fn committed_mode_or(target_id: u32, requested: Mode) -> Mode {
let Some((width, height, refresh_hz)) = pf_win_display::win_display::active_mode(target_id)
else {
return requested;
};
if (width, height) != (requested.width, requested.height) {
tracing::warn!(
target_id,
requested = format!("{}x{}", requested.width, requested.height),
active = format!("{width}x{height}"),
"the OS is not running the requested resolution after the settle — recording the \
requested mode (the capturer re-resolves the live size itself)"
);
return requested;
}
if refresh_hz != requested.refresh_hz {
tracing::info!(
target_id,
requested_hz = requested.refresh_hz,
committed_hz = refresh_hz,
"the OS committed a different refresh than requested (the driver does not advertise \
it) recording what the display actually runs"
);
}
Mode {
width,
height,
refresh_hz,
}
}
/// Does an acquire for `want` on a live monitor need a mid-stream resize, or can it JOIN?
///
/// Two modes describe one monitor and the caller may legitimately name either: `requested` is what
/// the session negotiated and re-asks for on every rebuild attempt, `committed` is what the OS
/// actually runs (they differ exactly when the driver would not advertise the negotiated refresh —
/// see [`committed_mode_or`]). Matching EITHER means there is nothing to do: asking again for the
/// negotiated mode cannot get a better result than the ADD already got, and asking for the mode the
/// display is already running is satisfied by definition. Only a genuinely NEW mode is a resize.
///
/// Keying on `committed` alone was the trap: it turned every same-mode re-acquire on a
/// refresh-clamping box into an in-place-resize attempt followed by a REMOVE→ADD hotplug, forever
/// (nothing ever re-records the negotiated rate, so the mismatch is self-perpetuating).
fn needs_resize(requested: Mode, committed: Mode, want: Mode) -> bool {
want != requested && want != committed
}
/// The manager's guarded state: the slot map + the (single) group record. One lock for both — every
/// group mutation happens on a slot transition, so splitting them would only invite lock-order bugs.
#[derive(Default)]
@@ -528,11 +618,11 @@ impl VirtualDisplayManager {
}
let reap = !slot.opened_once;
claim_instance()?;
// SAFETY: `VdisplayDriver::open` is `unsafe` only because it issues SetupAPI + `DeviceIoControl`
// FFI in the caller's apartment; the `device` mutex (held here) serializes it, so there is no
// concurrent open. `open` has no handle precondition to uphold, and the `OwnedHandle` it
// returns is the sole owner of the device.
let (handle, watchdog_s, driver_proto) = unsafe { self.driver.open(reap)? };
// `open` is a SAFE fn: it discharges every FFI precondition inside its own body (it opens the
// handle it then IOCTLs) and returns an `OwnedHandle` that is the sole owner of the device.
// The `device` mutex held here serializes racing opens — a *serialization* requirement, not
// a soundness one, which is exactly why it is not expressed as `unsafe`.
let (handle, watchdog_s, driver_proto) = self.driver.open(reap)?;
slot.opened_once = true;
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
self.driver_proto.store(driver_proto, Ordering::Relaxed);
@@ -690,11 +780,17 @@ impl VirtualDisplayManager {
// advertised mode list at ADD time, so we can't reach an arbitrary new mode in place — RE-
// ARRIVE the monitor at the exact mode instead (Fix 1). Own the slot for the swap: `re_add`
// needs `&mut inner` for the topology re-isolate, which the borrowed `mon` would block.
let cur_mode = match inner.slots.get(&slot) {
Some(SlotState::Active { mon, .. }) => mon.mode,
// Diff against BOTH of the slot's modes ([`needs_resize`]): the negotiated one the
// session keeps re-asking for and the one the OS actually committed. `mon.mode` alone is
// not the discriminator — on a box that clamps the negotiated refresh the two disagree
// for the monitor's whole life, and a request for the negotiated mode would then look
// like a resize on every acquire (`build_pipeline_with_retry` makes exactly that request
// once per build attempt, expecting a refcount++ join).
let (req_mode, cur_mode) = match inner.slots.get(&slot) {
Some(SlotState::Active { mon, .. }) => (mon.requested_mode, mon.mode),
_ => unreachable!("just matched Active"),
};
if cur_mode != mode {
if needs_resize(req_mode, cur_mode, mode) {
// IN-PLACE mode set first (latency plan P2): an already-advertised resolution
// (arrival list + the driver's same-id mode history) is CCD-forced on the SAME
// monitor — no REMOVE→ADD, so the monitor's OS identity (saved per-monitor DPI),
@@ -909,36 +1005,57 @@ impl VirtualDisplayManager {
let interval =
Duration::from_millis(self.watchdog_s.load(Ordering::Relaxed) as u64 * 1000 / 3);
let stop_t = stop.clone();
let thread = thread::spawn(move || {
let mut warned = false;
while !stop_t.load(Ordering::Relaxed) {
if let Some(h) = vdm().device_handle() {
// SAFETY: `ping` requires `dev` to be a valid control handle. The `h` Arc from
// `device_handle()` is held across this call, so the handle stays open even if
// it is retired concurrently — at worst the IOCTL fails (the retire drops only
// the manager's reference; see `DeviceSlot`). The pinger thread only spins
// while the `&'static` manager singleton lives.
match unsafe { vdm().driver.ping(dev_raw(&h)) } {
Ok(()) => warned = false,
Err(e) if is_device_gone(&e) => {
// The device itself is gone (driver upgrade / WUDFHost restart) — pings
// can only keep failing on this handle. Retire it so the next session's
// `ensure_device` reopens; the monitors are already dead driver-side.
vdm().invalidate_device(&e);
}
Err(e) => {
if !warned {
tracing::warn!(
"virtual-display keepalive PING failed (control handle lost?): {e:#}"
);
warned = true;
let thread = thread::Builder::new()
.name("vdisplay-pinger".into())
.spawn(move || {
let mut warned = false;
while !stop_t.load(Ordering::Relaxed) {
if let Some(h) = vdm().device_handle() {
// SAFETY: `ping` requires `dev` to be a valid control handle. The `h` Arc
// from `device_handle()` is held across this call, so the handle stays open
// even if it is retired concurrently — at worst the IOCTL fails (the retire
// drops only the manager's reference; see `DeviceSlot`). The pinger thread
// only spins while the `&'static` manager singleton lives.
match unsafe { vdm().driver.ping(dev_raw(&h)) } {
Ok(()) => warned = false,
Err(e) if is_device_gone(&e) => {
// The device itself is gone (driver upgrade / WUDFHost restart) —
// pings can only keep failing on this handle. Retire it so the next
// session's `ensure_device` reopens; the monitors are already dead
// driver-side.
vdm().invalidate_device(&e);
}
Err(e) => {
if !warned {
tracing::warn!(
"virtual-display keepalive PING failed (control handle lost?): {e:#}"
);
warned = true;
}
}
}
}
thread::sleep(interval);
}
thread::sleep(interval);
});
// NOT `thread::spawn` (which PANICS when the OS refuses the thread), for the same reason
// `ensure_exclusive_watch` was moved off it: this runs holding `pinger` and — via
// `create_monitor` ← `acquire` — the manager `state` guard, so an unwind here poisons the
// two locks the whole manager runs on, and every later `acquire`/`release`/`snapshot`
// `.lock().unwrap()` panics for the rest of the process. A missing pinger degrades to the
// driver's watchdog tearing the displays down (recoverable, and loud); a poisoned manager is
// neither. It also gains the thread name its two siblings already have.
let thread = match thread {
Ok(t) => t,
Err(e) => {
tracing::error!(
error = %e,
"could not spawn the virtual-display keepalive pinger — the driver's host-gone \
watchdog will tear this monitor down when it expires"
);
return;
}
});
};
*guard = Some(Pinger { stop, thread });
}
@@ -1167,9 +1284,14 @@ impl VirtualDisplayManager {
/// commits the target's path directly (supplied-config apply, the same thing display Settings
/// does), which doesn't consult the lid policy at all.
///
/// # Safety
/// Runs the CCD (QueryDisplayConfig / SetDisplayConfig) FFI; call under the `state` lock.
unsafe fn resolve_target_gdi(&self, target_id: u32) -> Option<String> {
/// Call under the `state` lock: this mutates the LIVE CCD topology (force-EXTEND, explicit path
/// activation), and the manager's sole-topology-mutator contract is what keeps two acquires from
/// interleaving path commits. A *serialization* requirement, not a soundness one — every CCD
/// helper it calls is a safe fn in `pf_win_display::win_display`, so this function performs no
/// unsafe operation at all. It was an `unsafe fn` back when the FFI was inline here, and stayed
/// one after the FFI moved out: three call sites then carried `unsafe {}` blocks whose SAFETY
/// proofs asserted things about FFI that is no longer in the body.
fn resolve_target_gdi(&self, target_id: u32) -> Option<String> {
// 50 ms sampling (latency plan P0.5): the SAME 3 s per-stage ceilings — the 3-stage ladder
// structure encodes real failure modes (headless auto-activate, integrated-panel clone,
// lid-closed path activation) and is untouched — but a typical activation resolves on an
@@ -1194,12 +1316,16 @@ impl VirtualDisplayManager {
/// (first member isolates and captures the restore; a later member re-issues the isolate with
/// the grown managed set — a sibling slot is never deactivated).
///
/// The returned `Monitor.mode` is what the OS COMMITTED, which need not be `mode` — see the
/// read-back after the settle. `Monitor.requested_mode` keeps `mode` verbatim, because that is
/// what the session re-asks for and `acquire`'s join/resize gate has to recognise.
///
/// # Safety
/// `dev` must be the live control handle.
unsafe fn create_monitor(
&'static self,
dev: HANDLE,
mode: Mode,
mut mode: Mode,
slot: u32,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
hw_cursor: bool,
@@ -1209,6 +1335,11 @@ impl VirtualDisplayManager {
// Windows reapplies the client's saved per-monitor config (DPI scaling) on reconnect;
// `0` (anonymous) = the driver auto-allocates the lowest-free id.
let preferred_id = slot;
// The client's negotiated mode, before the post-settle read-back below overwrites `mode`
// with what the OS committed. Both end up on the `Monitor`: the session re-asks for THIS one
// on every rebuild attempt, so it — not the committed one — is what `acquire`'s join/resize
// gate must recognise (see `Monitor::requested_mode`).
let requested_mode = mode;
let render_pin = resolve_render_pin();
// Hardware cursor only against a driver that implements the v5 channel: an older driver
// ignores the AddRequest field anyway (composited cursor), but gating here keeps the
@@ -1230,9 +1361,8 @@ impl VirtualDisplayManager {
// Resolve the capture target — wait for Windows to auto-activate the freshly-ADDed IDD into its
// OWN display path, with the integrated-screen clone fallback (shared by the re-arrival path).
// SAFETY: `resolve_target_gdi` runs the CCD FFI (a `Copy` `u32` target by value, owned return),
// under the `state` lock.
let gdi_name = unsafe { self.resolve_target_gdi(added.target_id) };
// Its `state`-lock discipline is satisfied: `acquire` holds the lock across this whole call.
let gdi_name = self.resolve_target_gdi(added.target_id);
match &gdi_name {
Some(n) => {
tracing::info!(
@@ -1368,6 +1498,17 @@ impl VirtualDisplayManager {
verified = settled,
"topology settle (verified-state wait)"
);
// Record what actually COMMITTED, not what was asked for — the same read-back
// `resize_in_place` does, for the same reason. `set_active_mode` deliberately falls
// back to the highest advertised refresh <= requested rather than lose the client's
// resolution, and `wait_mode_settled` verifies the RESOLUTION only, so `settled`
// says nothing about the refresh. Storing the request would make `mon.mode` claim a
// rate the display is not running: `output_for` hands the capturer that as
// `preferred_mode` (the encoder then paces to a rate the output never reaches),
// `/display/state` reports it, and the next Reconfigure diffs against it — a client
// re-requesting the rate it actually has would pay a needless resize, while one
// re-requesting the phantom rate takes the plain JOIN branch and never tries again.
mode = committed_mode_or(added.target_id, mode);
// EXPERIMENTAL `pnp_disable_monitors`, second selector (ANY topology): monitors
// that are connected but NOT part of the desktop — the standby TV/monitor the
@@ -1412,6 +1553,7 @@ impl VirtualDisplayManager {
wudf_pid: added.wudf_pid,
gdi_name,
mode,
requested_mode,
resolved_monitor_id: added.resolved_monitor_id,
position: (0, 0),
gen: self.gen.fetch_add(1, Ordering::Relaxed),
@@ -1507,29 +1649,10 @@ impl VirtualDisplayManager {
"in-place mode set did not commit within 1.5s (advertised after {advertised_ms} ms)"
);
}
// Record what actually COMMITTED, not what was asked for. `set_active_mode` deliberately
// falls back to the highest advertised refresh <= requested rather than lose the client's
// resolution, so `mon.mode = mode` claimed a rate the display might not be running — and
// `mon.mode` is what the next resize diffs against and what `/display/state` reports.
let committed = pf_win_display::win_display::active_mode(mon.target_id);
let landed = match committed {
Some((w, h, hz)) => Mode {
width: w,
height: h,
refresh_hz: hz,
},
// The settle above already verified the resolution; if the read-back races we still
// know the size took, so trust the request rather than leaving `mon.mode` stale.
None => mode,
};
if landed.refresh_hz != mode.refresh_hz {
tracing::info!(
requested_hz = mode.refresh_hz,
committed_hz = landed.refresh_hz,
"in-place resize: the OS committed a different refresh than requested (the driver \
does not advertise it) recording what it actually runs"
);
}
// Record what actually COMMITTED, not what was asked for — see [`committed_mode_or`], which
// the fresh-create and re-arrival paths share with this one so all three store the same
// truth: `mon.mode` is what `/display/state` reports and what the capturer paces to.
let landed = committed_mode_or(mon.target_id, mode);
tracing::info!(
advertised_ms,
settle_ms = settle_start.elapsed().as_millis() as u64,
@@ -1537,6 +1660,12 @@ impl VirtualDisplayManager {
"in-place resize committed (verified-state wait)"
);
mon.mode = landed;
// …and separately what was ASKED for, because that is what the session will re-request on
// its next acquire (a build retry, a build-then-drop overlap). Dropping it here would leave
// the slot only knowing a clamped refresh, and every such re-acquire would re-enter this
// function — or, once `wait_mode_advertised` refuses the un-advertised rate, the re-arrival
// hotplug below it.
mon.requested_mode = mode;
Ok(())
}
@@ -1607,7 +1736,7 @@ impl VirtualDisplayManager {
// values passed by value — no borrow crosses the call.
// SAFETY (both ADDs): `dev` is the live control handle; `render_pin`/`client_hdr` are owned
// `Copy`/`Option` values passed by value — no borrow crosses the call.
let (added, mode, rollback_err) = match unsafe {
let (added, mut mode, rollback_err) = match unsafe {
self.driver
.add_monitor(dev, mode, render_pin, slot, client_hdr, old.hw_cursor)
} {
@@ -1616,6 +1745,12 @@ impl VirtualDisplayManager {
// The old monitor is already REMOVEd, so there is nothing to "keep". Re-ADD it at
// the mode it had: the resize fails, but the session keeps streaming instead of
// being handed a slot whose driver monitor does not exist.
//
// At its REQUESTED mode, not its committed one — those differ exactly when the OS
// clamped the negotiated refresh, and this ADD is meant to replay the original one
// (same advertised mode list, same identity). Re-ADDing at the clamped rate would
// also make the clamp the slot's new negotiated mode, so the session's next acquire
// at the rate it still believes it has would read as yet another resize.
let e = e.context("re-arrival ADD at the new mode");
tracing::warn!(
slot,
@@ -1626,14 +1761,14 @@ impl VirtualDisplayManager {
match unsafe {
self.driver.add_monitor(
dev,
old.mode,
old.requested_mode,
render_pin,
slot,
client_hdr,
old.hw_cursor,
)
} {
Ok(a) => (a, old.mode, Some(e)),
Ok(a) => (a, old.requested_mode, Some(e)),
Err(e2) => {
tracing::error!(
slot,
@@ -1645,10 +1780,15 @@ impl VirtualDisplayManager {
}
}
};
// What the surviving ADD actually asked for (the new mode, or the old monitor's on a
// rollback) — pinned before the post-settle read-back below overwrites `mode` with the
// committed one. The session re-asks for THIS on its next acquire, so it is the join/resize
// gate's side of the pair (see `Monitor::requested_mode`).
let requested_mode = mode;
self.ensure_pinger();
// 3. Resolve the NEW target's GDI name (target_id changes across a re-arrival).
// SAFETY: CCD FFI over a `Copy` target id, under the `state` lock.
let gdi_name = unsafe { self.resolve_target_gdi(added.target_id) };
// 3. Resolve the NEW target's GDI name (target_id changes across a re-arrival). Under the
// `state` lock, as its topology-mutator discipline requires.
let gdi_name = self.resolve_target_gdi(added.target_id);
match &gdi_name {
Some(n) => {
tracing::info!(
@@ -1659,9 +1799,9 @@ impl VirtualDisplayManager {
// ADD only advertises the mode; force it active so DXGI/IDD captures the new size.
set_active_mode(n, mode);
// 4. Re-isolate the composited set with the NEW target replacing the old — preserving
// the group's first-member restore snapshot.
// SAFETY: CCD FFI over borrowed Copy target ids, under the `state` lock.
unsafe { self.reisolate_after_swap(inner, added.target_id) };
// the group's first-member restore snapshot. Under the `state` lock (the caller
// holds it and lent us `inner`), as its topology-mutator discipline requires.
self.reisolate_after_swap(inner, added.target_id);
// Topology settle before capture reopens: verified-state wait, ceiling = the old
// fixed 1500 ms sleep (latency plan P0.2 — the re-arrival twin).
let settle_start = std::time::Instant::now();
@@ -1671,6 +1811,12 @@ impl VirtualDisplayManager {
verified = settled,
"re-arrival topology settle (verified-state wait)"
);
// Store what COMMITTED, not what was asked for — the settle above verifies the
// resolution only, so it is no evidence about the refresh (see
// [`committed_mode_or`]). Doing this here rather than at the `Monitor` construction
// below keeps it on the arm where a path actually exists: with no GDI name there is
// no committed mode to read, and the request stands.
mode = committed_mode_or(added.target_id, mode);
}
None => tracing::warn!(
"re-arrival target {} not yet an active display path (auto-activate, EXTEND preset \
@@ -1688,6 +1834,7 @@ impl VirtualDisplayManager {
wudf_pid: added.wudf_pid,
gdi_name,
mode,
requested_mode,
resolved_monitor_id: added.resolved_monitor_id,
position: old.position,
gen: old.gen,
@@ -1708,9 +1855,11 @@ impl VirtualDisplayManager {
/// old slot has already been removed from the map by the caller, so `inner.target_ids()` is the
/// surviving siblings; the new target joins them.
///
/// # Safety
/// Drives the CCD topology FFI; call under the `state` lock.
unsafe fn reisolate_after_swap(&self, inner: &mut MgrInner, new_target: u32) {
/// Call under the `state` lock — it commits a new CCD topology, so it must not interleave with
/// another slot transition's commit. A *serialization* requirement, not a soundness one: every
/// helper it reaches (`isolate_displays_ccd_seam`, `set_virtual_primary_ccd`) is a safe fn, so
/// this body performs no unsafe operation. (`&mut MgrInner` already proves the lock is held.)
fn reisolate_after_swap(&self, inner: &mut MgrInner, new_target: u32) {
use crate::policy::Topology;
match topology_action() {
Topology::Exclusive => {
@@ -2271,7 +2420,15 @@ pub(crate) fn force_release(slot: Option<u64>) -> usize {
#[cfg(test)]
mod tests {
use super::{shrink_action, ShrinkAction};
use super::{needs_resize, shrink_action, Mode, ShrinkAction};
const fn m(width: u32, height: u32, refresh_hz: u32) -> Mode {
Mode {
width,
height,
refresh_hz,
}
}
/// The gate a non-last-member teardown keys off. It used to be `ccd_saved.is_some()`, which is
/// true for BOTH topologies — so a `Primary` group shrinking ran the exclusive isolate and
@@ -2297,4 +2454,35 @@ mod tests {
fn exclusivity_decides_without_a_snapshot() {
assert_eq!(shrink_action(true, false), ShrinkAction::Reisolate);
}
/// The join/resize gate on a box that CLAMPED the negotiated refresh (the driver would not
/// advertise 240 Hz at that pixel rate, so the OS committed 120). The session still re-requests
/// its negotiated mode on every build attempt — that must JOIN. Keying the gate on the committed
/// mode alone (which is what recording the read-back into `Monitor.mode` without keeping the
/// request amounts to) makes each of those a resize, i.e. an in-place attempt that fails on an
/// un-advertised rate and then a REMOVE→ADD hotplug, once per attempt, forever.
#[test]
fn a_reacquire_at_the_negotiated_mode_joins_even_when_the_os_clamped_the_refresh() {
let requested = m(5120, 1440, 240);
let committed = m(5120, 1440, 120);
assert!(
!needs_resize(requested, committed, requested),
"re-asking for the negotiated mode must JOIN, not hotplug the monitor"
);
// The other side of the pair: a client that re-asks for the rate the display actually runs
// has nothing to change either.
assert!(!needs_resize(requested, committed, committed));
// A genuinely new mode is still a resize — that is the branch's whole reason to exist.
assert!(needs_resize(requested, committed, m(3840, 2160, 120)));
assert!(needs_resize(requested, committed, m(5120, 1440, 60)));
}
/// The ordinary box (the OS advertises and commits exactly what was asked): both fields agree,
/// so the gate behaves exactly as the single-field one did.
#[test]
fn without_a_clamp_the_gate_is_plain_mode_equality() {
let mode = m(1920, 1080, 60);
assert!(!needs_resize(mode, mode, mode));
assert!(needs_resize(mode, mode, m(2560, 1440, 60)));
}
}
@@ -1,12 +1,20 @@
//! The backend-specific virtual-display **seam** (SudoVDA vs pf-vdisplay), carved out of the manager
//! (plan §W3): the REMOVE-key type, the `add_monitor` reply, and the IOCTL trait. This is the ONLY
//! thing that differs between the two Windows backends — the refcount machine, linger, pinger, and
//! CCD/GDI glue are all backend-neutral in [`super::VirtualDisplayManager`].
//! The virtual-display driver **seam**, carved out of the manager (plan §W3): the REMOVE-key type,
//! the `add_monitor` reply, and the IOCTL trait. It isolates the DRIVER's wire protocol from the
//! lifecycle — the refcount machine, linger, pinger and CCD/GDI glue are all driver-neutral in
//! [`super::VirtualDisplayManager`]. It was born as a two-backend seam (SudoVDA vs pf-vdisplay) and
//! has exactly one implementor since SudoVDA was removed: `crate::driver::PfVdisplayDriver` (the
//! flattened module name of `vdisplay/windows/pf_vdisplay.rs`). Kept as a trait because it is also
//! the only place the IOCTL surface can be faked, not because a second backend is expected.
use super::*;
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id.
/// The per-driver REMOVE key stamped on ADD and consumed on REMOVE. pf-vdisplay keys monitors by a
/// monotonic `u64` session id.
///
/// `Guid` is a RETAINED, UNUSED variant: it keyed SudoVDA's monitors (a fresh `GUID` per monitor) and
/// nothing constructs it since that backend was removed — the `else` arms in `pf_vdisplay`'s
/// `update_modes`/`remove_monitor` that reject it are therefore dead today. Left in place so the
/// enum still documents that the key is a per-driver choice rather than a `u64` by nature.
#[derive(Clone, Copy)]
pub(crate) enum MonitorKey {
Guid(windows::core::GUID),
@@ -29,10 +37,10 @@ pub(crate) struct AddedMonitor {
pub cursor_excluded: bool,
}
/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay.
/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in
/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a
/// `&'static` singleton reached from the pinger + linger threads.
/// The driver's IOCTL surface — everything else (the refcount machine, the linger, the pinger, the
/// CCD/GDI glue) is driver-neutral and shared in [`VirtualDisplayManager`]. `Send + Sync` because the
/// manager (and so the boxed driver) is a `&'static` singleton reached from the pinger + linger
/// threads.
pub(crate) trait VdisplayDriver: Send + Sync {
fn name(&self) -> &'static str;
/// Find + open the control device, validate it (version handshake), and read the watchdog
@@ -42,9 +50,14 @@ pub(crate) trait VdisplayDriver: Send + Sync {
/// owned handle + watchdog seconds + the driver's reported protocol version (the in-place
/// resize gates on it).
///
/// # Safety
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)>;
/// SAFE, and owning — unlike every other method here, which takes the raw `dev` handle. It has
/// no caller obligation: it takes only a `bool`, opens the handle it then IOCTLs, and hands back
/// an `OwnedHandle` that closes on drop. It used to be an `unsafe fn` whose `# Safety` section
/// ("issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment") restated what
/// the body does rather than naming anything a caller could uphold — an un-checkable proof
/// obligation at the one call site, which trains a reviewer to wave through the neighbouring
/// blocks where the `dev` precondition is real.
fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)>;
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr`
/// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the
@@ -85,3 +98,62 @@ pub(crate) trait VdisplayDriver: Send + Sync {
/// `dev` must be the live control handle.
unsafe fn ping(&self, dev: HANDLE) -> Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
/// A driver that implements nothing but the required methods — so the DEFAULTED `update_modes`
/// is what gets called.
struct FakeDriver;
impl VdisplayDriver for FakeDriver {
fn name(&self) -> &'static str {
"fake"
}
fn open(&self, _reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
anyhow::bail!("fake driver has no control device")
}
unsafe fn add_monitor(
&self,
_dev: HANDLE,
_mode: Mode,
_render_luid: Option<LUID>,
_preferred_monitor_id: u32,
_client_hdr: Option<punktfunk_core::quic::HdrMeta>,
_hw_cursor: bool,
) -> Result<AddedMonitor> {
anyhow::bail!("fake driver adds no monitors")
}
unsafe fn remove_monitor(&self, _dev: HANDLE, _key: &MonitorKey) -> Result<()> {
Ok(())
}
unsafe fn ping(&self, _dev: HANDLE) -> Result<()> {
Ok(())
}
}
/// The `update_modes` default must ERR, not silently succeed: `resize_in_place` treats `Ok(())`
/// as "the driver refreshed the monitor's advertised mode list" and goes straight on to the CCD
/// force-set + settle — so a default that returned `Ok` would burn the full 1.5 s settle against
/// a mode list nobody updated, on every mid-stream resize, before falling back to the
/// re-arrival it should have taken immediately.
#[test]
fn the_defaulted_update_modes_reports_not_supported() {
let d = FakeDriver;
let mode = Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
};
// SAFETY: the defaulted `update_modes` discharges its `dev` obligation by never using it —
// the body discards all three arguments and errs — so the null handle is never touched.
let err = unsafe { d.update_modes(HANDLE::default(), &MonitorKey::Session(1), mode) }
.expect_err("the default must not report success");
assert!(
err.to_string()
.contains("does not support in-place mode updates"),
"unexpected error text: {err:#}"
);
}
}
@@ -64,16 +64,27 @@ fn acquire_single_instance() -> Result<OwnedHandle> {
unsafe {
let h = match CreateMutexW(Some(&sa), false, w!("Global\\punktfunk-vdisplay-manager")) {
Ok(h) => h,
// The name exists but its creator's DACL denies this token the implicit OPEN (the SCM
// service creates it as SYSTEM; a second elevated-admin host lands here instead of in
// the ALREADY_EXISTS branch — validated on-glass). Legitimately that means an instance
// is live; it is ALSO exactly what a squat looks like, so say both.
// ACCESS_DENIED has THREE causes here and the handle alone cannot tell them apart, so
// name all three rather than assert one. (1) The name exists but its creator's DACL
// denies this token the implicit OPEN — the SCM service creates it as SYSTEM, so a
// second elevated-admin host lands here instead of in the ALREADY_EXISTS branch
// (validated on-glass); that is a live instance. (2) The same shape is exactly what a
// SQUAT looks like. (3) `CreateMutexW` also fails ACCESS_DENIED when the caller holds no
// SeCreateGlobalPrivilege at all — granted by default to Administrators, SYSTEM and the
// SERVICE groups but NOT to an ordinary interactive user, so an un-elevated
// `punktfunk-host serve` reaches this arm with no such object existing anywhere. Naming
// only (1)+(2) sent that operator hunting a process that does not exist and a
// `handle.exe` that finds nothing — the same misdiagnosis family as 2026-08-05 L-16,
// which this block exists to remove.
Err(e) if e.code().0 == 0x8007_0005u32 as i32 => anyhow::bail!(
"{IN_USE}\n\nIf no other punktfunk-host is running, the name \
`Global\\punktfunk-vdisplay-manager` has been SQUATTED by another process any \
account with SeCreateGlobalPrivilege can create it first and deny us access, \
which disables virtual-display streaming until that process exits. Find the \
holder with Sysinternals `handle.exe -a punktfunk-vdisplay-manager`."
"{IN_USE}\n\nIf no other punktfunk-host is running, either this process cannot \
create a `Global\\` kernel object at all (it needs SeCreateGlobalPrivilege run \
the host ELEVATED or as the installed service account; an ordinary interactive \
user does not hold it), or the name `Global\\punktfunk-vdisplay-manager` has been \
SQUATTED by another process any account with that privilege can create it first \
and deny us access, which disables virtual-display streaming until that process \
exits. Sysinternals `handle.exe -a punktfunk-vdisplay-manager` tells the two \
apart: a holder means a squat, NOTHING means the privilege."
),
Err(e) => {
return Err(e).context("CreateMutexW(punktfunk-vdisplay single-instance guard)");
@@ -190,6 +201,48 @@ fn object_owner_sid(h: HANDLE) -> Option<String> {
/// SYSTEM, BUILTIN\Administrators, or a member of the Administrators-owned set — the principals a
/// legitimate pf-vdisplay manager runs as.
///
/// Deliberately NARROW, and the narrowness is the security property: this predicate is what decides
/// whether an existing single-instance name is reported as "another punktfunk-host" (benign, wait it
/// out) or as a SQUAT (an attack on virtual-display availability). Widening it — `S-1-5-32-` as a
/// prefix, or any `S-1-5-21-…` domain account — silently reclassifies a non-administrative squatter
/// as one of ours and restores the exact misdiagnosis the 2026-08-05 L-16 fix removed. LocalService
/// (`S-1-5-19`) and NetworkService (`S-1-5-20`) are excluded ON PURPOSE: the plugin runner is forced
/// to LocalService, so a name owned by it is a plugin, not a host.
fn is_privileged_sid(sid: &str) -> bool {
matches!(sid, "S-1-5-18" | "S-1-5-32-544") || sid.starts_with("S-1-5-80-") // service SIDs
}
#[cfg(test)]
mod tests {
use super::is_privileged_sid;
/// Pins the classification above — the only pure decision in this module, and the one whose
/// widening is silent (nothing fails; a squat merely starts reading as a sibling host).
#[test]
fn is_privileged_sid_accepts_system_admins_and_service_sids_only() {
assert!(is_privileged_sid("S-1-5-18"), "SYSTEM");
assert!(is_privileged_sid("S-1-5-32-544"), "BUILTIN\\Administrators");
assert!(
is_privileged_sid("S-1-5-80-3139157870-2983391045-3678747466-658725712-1809340420"),
"an NT SERVICE\\… per-service SID"
);
assert!(!is_privileged_sid("S-1-5-32-545"), "BUILTIN\\Users");
assert!(
!is_privileged_sid("S-1-5-21-1004336348-1177238915-682003330-1001"),
"a local/domain user account"
);
// LocalService / NetworkService: the plugin runner's accounts, deliberately NOT ours.
assert!(!is_privileged_sid("S-1-5-19"), "LocalService");
assert!(!is_privileged_sid("S-1-5-20"), "NetworkService");
assert!(
!is_privileged_sid(""),
"an unreadable owner is never 'fine'"
);
// Prefix discipline: `S-1-5-80` without the trailing dash is a different SID string, and
// `S-1-5-8` (Proxy) must not slip in under a loosened prefix.
assert!(!is_privileged_sid("S-1-5-8"), "Proxy");
assert!(!is_privileged_sid("S-1-5-800-1"), "not a service SID");
}
}
@@ -2,25 +2,44 @@
//! carved out of the manager (plan §W3): the linger window, the keep-alive-forever pin, and the
//! per-monitor topology action. Pure readers of [`crate::policy`] + env — no manager state.
/// The historical Windows linger window, and the fallback for every rung that cannot answer.
const DEFAULT_LINGER_MS: u64 = 10_000;
/// Linger window before a session-less monitor is torn down. The console display-management policy
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
/// else the 10 s default.
pub(super) fn linger_ms() -> u64 {
use crate::policy::{prefs, Linger};
if let Some(eff) = prefs().configured_effective() {
return match eff.keep_alive.linger() {
Linger::Immediate => 0,
Linger::For(d) => d.as_millis() as u64,
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
// check) — fall back to the default rather than a huge linger.
Linger::Forever => 10_000,
};
resolve_linger_ms(
crate::policy::prefs()
.configured_effective()
.map(|eff| eff.keep_alive.linger()),
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok()),
)
}
/// The precedence itself, lifted out of the readers so it is pinnable without a settings file, an
/// environment or a manager (this module's decisions are the ONLY ones on the Windows lifecycle path
/// that need neither a driver nor a desktop, and they had no tests at all).
///
/// `configured` is the console policy's resolved [`Linger`](crate::policy::Linger) (`None` = the
/// host was never configured), `env_ms` the parsed legacy knob. The configured policy outranks the
/// env knob entirely — an operator who set the console must not have it silently overridden by a
/// leftover variable.
fn resolve_linger_ms(configured: Option<crate::policy::Linger>, env_ms: Option<u64>) -> u64 {
use crate::policy::Linger;
match configured {
Some(Linger::Immediate) => 0,
Some(Linger::For(d)) => d.as_millis() as u64,
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
// check) — fall back to the default rather than a huge linger.
Some(Linger::Forever) => DEFAULT_LINGER_MS,
// Unconfigured: the legacy env knob, else the historical default. An unparseable value
// arrives here as `None` (the caller's `parse().ok()`), i.e. it reads as unset.
None => env_ms.unwrap_or(DEFAULT_LINGER_MS),
}
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
}
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
@@ -50,13 +69,79 @@ pub(super) fn exclusive_reassert_ms() -> u64 {
/// extended; `Primary` makes it primary while keeping the physical(s) active; `Exclusive` disables the
/// physical(s) so the IDD is the sole composited desktop.
pub(super) fn topology_action() -> crate::policy::Topology {
let configured = crate::policy::prefs()
.configured_effective()
.map(|_| crate::effective_topology());
resolve_topology_action(configured, std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok())
}
/// The precedence for [`topology_action`], lifted out for the same reason as [`resolve_linger_ms`].
/// `configured` is [`crate::effective_topology`]'s answer when the console configured anything at
/// all (that fn is the rung responsible for never returning `Auto`); `no_isolate_env` is the legacy
/// `PUNKTFUNK_NO_ISOLATE` opt-out, which an unconfigured host still honors.
fn resolve_topology_action(
configured: Option<crate::policy::Topology>,
no_isolate_env: bool,
) -> crate::policy::Topology {
use crate::policy::Topology;
if crate::policy::prefs().configured_effective().is_some() {
return crate::effective_topology();
match configured {
Some(t) => t,
None if no_isolate_env => Topology::Extend,
None => Topology::Exclusive,
}
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() {
Topology::Extend
} else {
Topology::Exclusive
}
#[cfg(test)]
mod tests {
use super::{resolve_linger_ms, resolve_topology_action, DEFAULT_LINGER_MS};
use crate::policy::{Linger, Topology};
use std::time::Duration;
/// The console policy is the top rung: a host that configured `keep_alive` must not have it
/// silently overridden by a leftover `PUNKTFUNK_MONITOR_LINGER_MS`.
#[test]
fn configured_policy_beats_the_legacy_env_knob() {
assert_eq!(
resolve_linger_ms(Some(Linger::For(Duration::from_secs(3))), Some(60_000)),
3_000
);
assert_eq!(resolve_linger_ms(Some(Linger::Immediate), Some(60_000)), 0);
}
/// Unconfigured hosts keep the historical behavior: the env knob, else the 10 s default. An
/// unparseable value reaches this fn as `None` (the reader's `parse().ok()`), so it reads as
/// unset rather than as zero — a `linger_ms = 0` would tear the monitor down on every
/// disconnect.
#[test]
fn an_unconfigured_host_honours_the_env_knob_then_the_default() {
assert_eq!(resolve_linger_ms(None, Some(250)), 250);
assert_eq!(resolve_linger_ms(None, None), DEFAULT_LINGER_MS);
}
/// `Forever` is the `Pinned` lifecycle, resolved by `keep_alive_forever()` before any ms are
/// asked for; reaching this fn with it means a caller skipped the pin check, and the answer is
/// the default window — NOT an effectively infinite linger that would keep the physical panels
/// dark with nothing to release them.
#[test]
fn forever_resolves_to_the_default_not_a_huge_linger() {
assert_eq!(
resolve_linger_ms(Some(Linger::Forever), None),
DEFAULT_LINGER_MS
);
}
/// The unconfigured rungs are `Exclusive` by default, `Extend` under the legacy opt-out — and
/// neither is `Auto`, which the manager's `match` would treat as plain extend without ever
/// saying so.
#[test]
fn the_unconfigured_topology_rungs_never_yield_auto() {
assert_eq!(resolve_topology_action(None, false), Topology::Exclusive);
assert_eq!(resolve_topology_action(None, true), Topology::Extend);
// A configured host's answer is whatever `effective_topology()` resolved — passed through
// verbatim, env knob or not.
assert_eq!(
resolve_topology_action(Some(Topology::Primary), true),
Topology::Primary
);
}
}
@@ -8,11 +8,13 @@
//! the wire contract OWNED by [`pf_driver_proto::control`] (versioned + `#[repr(C)] Pod` structs,
//! NOT the SudoVDA ABI). No DLL, no named pipe. See `design/windows-host-rewrite.md`.
//!
//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver:
//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those
//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS
//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes,
//! request/reply structs, the version handshake) differ, per `pf_driver_proto`.
//! punktfunk's IddCx driver is the SOLE Windows backend — the legacy SudoVDA fallback was removed and
//! its driver is no longer shipped (`lib.rs`), so nothing here is a "clone of the fallback" any more.
//! The backend-NEUTRAL half — the reference-counted/lingering monitor lifecycle, the CCD isolation and
//! the active-mode forcing — lives in [`super::manager`] and `pf_win_display::win_display` (a
//! pf-vdisplay monitor's `target_id` is a real OS target id, so that CCD/DXGI code applies unchanged).
//! Only the driver-specific bits (GUID, IOCTL codes, request/reply structs, the version handshake) are
//! here, per `pf_driver_proto`.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -97,9 +99,10 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
/// pinning an OS VidPN target against the IddCx adapter's fixed monitor-slot budget; once ~16 accumulate,
/// `IOCTL_ADD` wedges at 0x80070490 (`ERROR_NOT_FOUND`) and every session black-screens until a manual
/// reset/reboot. Removing the not-present PDOs frees the slots — the in-process equivalent of
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only NOT-present nodes
/// (`Status != OK`) are removed, so the LIVE session's monitor (`Status OK`) is never touched; any
/// failure is logged and swallowed. Returns the number removed.
/// `reset-pf-vdisplay.ps1` step 2 (proven on-box). Best-effort + idempotent: only ABSENT nodes
/// (`Present` false AND `Status` `Unknown`) are removed, so a LIVE session's monitor is never
/// touched — not even while it is in a transient problem state; any failure is logged and
/// swallowed. Returns the number removed.
///
/// The outcome is logged UNCONDITIONALLY, as found + removed: the old script counted only removals
/// and the host spoke only when that count was positive, so a reap whose pnputil never launched and
@@ -108,8 +111,17 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result
/// wedge with every sleep cycle.
fn reap_ghost_monitors() -> u32 {
// Mirrors reset-pf-vdisplay.ps1 step 2. powershell is always present for the SYSTEM service; the
// matched tokens ('OK', 'punktfunk', the InstanceId) are locale-invariant, so this is safe on a
// non-English box (unlike a .ps1 *file* read in the machine codepage).
// matched tokens ('Unknown', 'punktfunk', the InstanceId) are locale-invariant, so this is safe
// on a non-English box (unlike a .ps1 *file* read in the machine codepage).
//
// The selector asks about PRESENCE, not health — the exact complement of the liveness predicate
// the adapter reload below uses (`$_.Present -or $_.Status -ne 'Unknown'`). It used to read
// `Status -ne 'OK'`, which is a HEALTH field: `Error`, `Degraded` and `Unknown` all satisfy it,
// so a PRESENT virtual monitor in a transient problem state was handed to `pnputil
// /remove-device` — and this runs mid-session from `add_monitor`'s 0x80070490 recovery, i.e.
// while sibling sessions are live, so it could rip out a live client's monitor. `Present` is the
// authoritative bit; the `Status -eq 'Unknown'` conjunct is the guard for `Present` reading null
// (`-not $null` is TRUE, which alone would select every device on the box).
//
// pnputil is resolved by full path and `$LASTEXITCODE` pre-seeded to failure before every
// launch, exactly like the reload path below: a LocalSystem service's PATH need not include
@@ -117,7 +129,7 @@ fn reap_ghost_monitors() -> u32 {
// elevated), and the old bare-name call failed INVISIBLY there — `SilentlyContinue` swallowed
// the miss, no exit code was written, and the ghosts stayed to wedge `IOCTL_ADD` at 0x80070490.
const REAP_PS: &str = "$ErrorActionPreference='SilentlyContinue'; \
$g = @(Get-PnpDevice -Class Monitor | Where-Object { $_.Status -ne 'OK' -and $_.FriendlyName -match 'punktfunk' }); \
$g = @(Get-PnpDevice -Class Monitor | Where-Object { -not $_.Present -and $_.Status -eq 'Unknown' -and $_.FriendlyName -match 'punktfunk' }); \
$pnp = ($env:SystemRoot + '\\System32\\pnputil.exe'); \
$n = 0; foreach ($d in $g) { $LASTEXITCODE = 1; if (Test-Path $pnp) { & $pnp /remove-device $d.InstanceId *> $null }; if ($LASTEXITCODE -eq 0) { $n++ } }; \
Write-Output ($g.Count.ToString() + ' ' + $n)";
@@ -593,14 +605,20 @@ fn probe_device() -> Probe {
// SAFETY: `buf` is at least `required` bytes and aligned to 8 (so also to the struct's 4),
// so stamping `cbSize` and letting the API fill up to `required` bytes stays in bounds;
// `detail` aliases `buf` only within this iteration, and the `DevicePath` pointer is read
// before `buf` is dropped.
// before `buf` is dropped. That path pointer is taken as a RAW place projection off
// `detail`, so it keeps the whole `buf` allocation's provenance: `DevicePath` is declared
// `[u16; 1]` (a flexible-array-member stub), so `.as_ptr()` would auto-ref it and hand
// `CreateFileW` a pointer tagged for TWO bytes while the API reads the full NUL-terminated
// path (100+ bytes) — everything past `DevicePath[0]` out of bounds for that tag, and a
// compiler entitled to fold the zero-init back in and pass an EMPTY device name. Same
// defect class (and same fix) as the `MONITORINFOEXW` retag in `vdisplay/ddc.rs`.
let opened = unsafe {
(*detail).cbSize = size_of::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() as u32;
SetupDiGetDeviceInterfaceDetailW(hdev.0, &idata, Some(detail), required, None, None)
.context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")
.and_then(|()| {
CreateFileW(
PCWSTR((*detail).DevicePath.as_ptr()),
PCWSTR((&raw const (*detail).DevicePath).cast::<u16>()),
0xC000_0000, // GENERIC_READ | GENERIC_WRITE
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
@@ -635,7 +653,7 @@ impl VdisplayDriver for PfVdisplayDriver {
"pf-vdisplay"
}
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
// A short re-probe, and deliberately NO adapter reload — this replaces the second, impatient
// copy of the recovery that used to live here. Session bring-up already ran the full
// `ensure_available` before constructing the backend, so anything left for this open to
@@ -686,18 +704,42 @@ impl VdisplayDriver for PfVdisplayDriver {
);
}
let watchdog_s = info.watchdog_timeout_s.max(1);
if info.protocol_version < pf_driver_proto::PROTOCOL_VERSION {
// UNCONDITIONAL: this line is the only place the negotiated watchdog is reported, and the
// pinger's cadence (`watchdog/3`) is derived from it — yet it used to sit in the `else` of
// the version warning, so exactly the hosts where the number is worth having (anything but
// an exact-version pair) logged nothing at all.
tracing::info!(
"pf-vdisplay protocol {} (host drives {}..={}, watchdog timeout {}s)",
info.protocol_version,
pf_driver_proto::MIN_DRIVER_PROTOCOL_VERSION,
pf_driver_proto::PROTOCOL_VERSION,
watchdog_s
);
// Version-SPECIFIC capability gaps, reported independently. Every bump since v3 is ADDITIVE,
// so the old blanket `< PROTOCOL_VERSION` test named the WRONG gap: it told a v4 or v5
// driver it "lacks the in-place resize" — added IN v4 — purely because it was not v6. Each
// rung below names the capability the host actually gates on that version.
if info.protocol_version < 4 {
tracing::warn!(
"pf-vdisplay protocol {} (host supports {}): driver lacks the in-place resize \
mid-stream resizes use the monitor re-arrival path until the driver is updated",
info.protocol_version,
pf_driver_proto::PROTOCOL_VERSION
"pf-vdisplay protocol {}: driver lacks the in-place mid-stream resize \
(IOCTL_UPDATE_MODES, added in v4) every mid-stream resize costs a monitor \
re-arrival (one hotplug per switch) until the driver is updated",
info.protocol_version
);
} else {
}
if info.protocol_version < 5 {
tracing::warn!(
"pf-vdisplay protocol {}: driver lacks the IddCx hardware-cursor channel (added in \
v5) the pointer stays composited into the captured frame",
info.protocol_version
);
}
if info.protocol_version < 6 {
tracing::info!(
"pf-vdisplay protocol {} (watchdog timeout {}s)",
info.protocol_version,
watchdog_s
"pf-vdisplay protocol {}: driver lacks the mid-stream cursor-forward flip \
(IOCTL_SET_CURSOR_FORWARD, added in v6) the cursor model declared at monitor ADD \
stands for the whole session",
info.protocol_version
);
}
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns
+5
View File
@@ -14,6 +14,11 @@
//! because they cannot panic: [`punktfunk_abi_version`] returns a constant, and the
//! `punktfunk_connect*` shims forward every argument unchanged into a guarded implementation.
// The crate denies `unsafe_code` (lib.rs): the network plane is safe Rust by compiler-enforced
// invariant. This module is one of the two documented carve-outs — an `extern "C"` surface for
// the platform clients cannot exist without unsafe, and every site below carries its proof.
#![allow(unsafe_code)]
// THE ABI CONTRACT, stated once - most `// SAFETY:` proofs below are an instance of it.
//
// Every pointer crossing this boundary is C memory the CALLER owns, and the header
+5
View File
@@ -11,6 +11,11 @@
//! invariant) plus a blocking data-plane pump; frames cross to the embedder over a bounded
//! channel. All methods are safe to call from any single embedder thread.
// The crate denies `unsafe_code` (lib.rs); this client-side module is one of the two documented
// carve-outs (with `abi`) — its few sites are platform glue (thread ids, priorities) for the
// embedders, each with its `// SAFETY:` proof. The host serves nothing from this module.
#![allow(unsafe_code)]
use crate::clipboard::{ClipCommand, ClipEventCore};
use crate::config::{CompositorPref, GamepadPref, Mode};
use crate::error::{PunktfunkError, Result};
+10
View File
@@ -36,6 +36,16 @@
// Unsafe-proof program: every `unsafe {}` / `unsafe impl` in this crate carries a `// SAFETY:`
// proof. The bulk lives in `abi.rs`, whose sites are instances of the ABI contract stated once at
// the top of that file rather than 141 independent arguments.
//
// Beyond the proofs: `unsafe` is DENIED crate-wide, making the census result a compiler-enforced
// invariant (rust-safety programme): **everything that PARSES network bytes is safe Rust** —
// quic, packet, session, fec, crypto, tls, and the rest. The documented `#![allow(unsafe_code)]`
// carve-outs are exactly two classes, and neither interprets attacker bytes:
// * the CLIENT surface — `abi` (the `extern "C"` boundary itself) and `client` (embedder glue);
// * the platform syscall-batching shims under `transport` (`udp/{apple,linux,windows}`,
// `qos_windows`) — sendmmsg/recvmsg_x/USO/qWAVE move caller-owned buffers, nothing more.
// A new module parsing wire data may NOT add a carve-out.
#![deny(unsafe_code)]
#![deny(clippy::undocumented_unsafe_blocks)]
#![forbid(unsafe_op_in_unsafe_fn)]
@@ -13,6 +13,10 @@
//! Same contract as the rest of [`super::qos`]: opt-in (`dscp_enabled`), and every step
//! debug-logs and continues — QoS is a nicety, never required for correctness.
// Crate-wide deny(unsafe_code) carve-out (lib.rs): platform syscall glue — qWAVE FFI moves
// flow handles, never network bytes; each site carries its proof.
#![allow(unsafe_code)]
use super::qos::MediaClass;
use std::net::UdpSocket;
use std::os::windows::io::AsRawSocket;
@@ -1,6 +1,10 @@
//! Apple/BSD batched UDP receive: Darwin `recvmsg_x`, `recv`-loop fallback on other BSDs.
//! The platform body of [`super::UdpTransport`]'s `recv_batch` override.
// Crate-wide deny(unsafe_code) carve-out (lib.rs): platform syscall-batching glue — `recvmsg_x`
// fills caller-owned buffers; nothing here interprets network bytes. Proofs at each site.
#![allow(unsafe_code)]
use super::{is_transient_io, UdpTransport};
/// Apple (macOS/iOS) batched-receive enable state. Darwin has no `recvmmsg(2)`, so without this our
@@ -2,6 +2,10 @@
//! The platform bodies of [`super::UdpTransport`]'s `send_batch`/`send_gso`/`recv_batch`
//! overrides live here (called by the cfg-gated delegators in the parent `impl Transport`).
// Crate-wide deny(unsafe_code) carve-out (lib.rs): platform syscall-batching glue —
// `sendmmsg`/`recvmmsg`/GSO move caller-owned buffers; nothing here interprets network bytes.
#![allow(unsafe_code)]
use super::{is_transient_io, UdpTransport};
#[cfg(target_os = "android")]
@@ -1,6 +1,10 @@
//! Windows batched UDP send: `WSASendMsg` UDP Send Offload (USO). The platform body of
//! [`super::UdpTransport`]'s `send_gso` override, plus the standalone [`send_uso_all`].
// Crate-wide deny(unsafe_code) carve-out (lib.rs): platform syscall-batching glue — `WSASendMsg`
// USO sends caller-owned buffers; nothing here interprets network bytes. Proofs at each site.
#![allow(unsafe_code)]
use super::{is_transient_io, UdpTransport};
use crate::transport::Transport;
@@ -5,6 +5,14 @@
//! Everything it does lives in [`pf_encode::worker`]; this file exists so the capability has a
//! **file of its own** (see this crate's Cargo.toml for why that is not negotiable).
// This binary is the one that carries a CAPABILITY (`cap_sys_nice`), and the header above makes
// a minimal-attack-surface claim: no Wayland, no D-Bus, no network, no plugins. `forbid` makes
// the memory-safety half of that claim mechanical rather than aspirational — a capability-
// carrying process is the last place a raw pointer should appear, and `forbid` (unlike `deny`)
// cannot be re-opened by an `#[allow]` further down. The heavy lifting lives in
// `pf_encode::worker`, which is a separate crate and keeps its own discipline.
#![forbid(unsafe_code)]
fn main() -> std::process::ExitCode {
// Stderr, inherited from the host, so the worker's lines land in the host's journal next to
// the session that spawned it. `RUST_LOG` is inherited too, so raising the host's level
+20 -3
View File
@@ -67,7 +67,11 @@ mac_address = "1"
if-addrs = "0.13"
tokio = { version = "1", features = ["full"] }
parking_lot = "0.12"
rsa = "0.9"
# GameStream-only (behind the `gamestream` feature): the Moonlight RSA-2048 identity generator +
# pairing signer (cert.rs, pairing.rs) and the legacy-client-cert leniency verifier (tls.rs).
# The native planes use the P-256 identity (src/identity.rs) and never touch this crate — so a
# native-only build also sheds the accepted Marvin advisory (RUSTSEC-2023-0071, .cargo/audit.toml).
rsa = { version = "0.9", optional = true }
sha2 = { version = "0.10", features = ["oid"] }
aes = "0.8"
aes-gcm = "0.10"
@@ -105,7 +109,13 @@ futures-util = "0.3"
# Webhook signing (X-Punktfunk-Signature: sha256=<hex HMAC>) for operator hooks; pairs with
# the existing sha2. Already in the lockfile transitively.
hmac = "0.12"
rusty_enet = "0.4"
# GameStream control-stream ENet — a c2rust-style transpile of C ENet (158 unsafe sites; raw
# pointer arithmetic, manual allocation). Its port binds only while a pairing exists (rust-safety
# WP0, gamestream/control.rs) and the whole crate exists only behind the `gamestream` feature
# (WP19) — a native-only build contains no transpiled C ENet at all. Pinned EXACTLY so a bump is
# a deliberate, reviewed act rather than a lockfile refresh, and left to the cargo-audit job
# (audit.yml) to flag advisories against it.
rusty_enet = { version = "=0.4.0", optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Management/control-plane REST API + OpenAPI (control pane, M2). `axum_extras` wires
@@ -303,7 +313,14 @@ bytemuck = { version = "1.19", features = ["derive"] }
[features]
# PyroWave ships in every default build (the codec stays strictly opt-in per session — a client
# must explicitly prefer CODEC_PYROWAVE; nothing changes for normal HEVC/AV1 sessions).
default = ["pyrowave"]
# GameStream ships in every default build too (still runtime-OPT-IN via --gamestream /
# PUNKTFUNK_GAMESTREAM); building with `--no-default-features --features pyrowave` produces the
# hardened NATIVE-ONLY host — no Moonlight-compat planes, no `rusty_enet` (transpiled C), no
# `rsa` (rust-safety WP19). `serve --gamestream` on such a binary refuses to start, loudly.
default = ["pyrowave", "gamestream"]
# The GameStream/Moonlight-compat planes (nvhttp pairing, RTSP, ENet control, `_nvstream` mDNS)
# at COMPILE time — see the `default` note above for what leaving it off buys.
gamestream = ["dep:rusty_enet", "dep:rsa"]
# NVENC hardware encode (Linux CUDA + Windows D3D11). OFF by default; entry points resolved at
# RUNTIME from the driver DLL/so, so the same binary starts fine on AMD/Intel boxes. Build the GPU
# host with `--features nvenc`.
+10 -2
View File
@@ -10,12 +10,20 @@ use anyhow::Result;
// The shared frame vocabulary lives in `pf-frame`; re-export the pieces host modules still name via
// `crate::capture::*` (the capture mechanics that used the rest moved into pf-capture).
pub use pf_frame::{CapturedFrame, OutputFormat, PixelFormat};
pub use pf_frame::{CapturedFrame, OutputFormat};
// `PixelFormat` is named through `crate::capture::` only by the GameStream media path; the Linux
// pyrowave-modifier plumbing below uses it in-module. Off both (a native-only Windows build,
// WP19), the re-export would be dead and -D warnings rejects it.
#[cfg(any(target_os = "linux", feature = "gamestream"))]
pub use pf_frame::PixelFormat;
// The capturer types + trait + synthetics live in `pf-capture`; re-export them at the old paths.
// `capturer_supports_hdr` is deliberately NOT re-exported: on Linux it is only the platform floor,
// and a caller reaching for it by that name would silently miss the gamescope arm. The host's
// answer is [`capturer_supports_hdr_for`] below.
pub use pf_capture::{capturer_supports_444, Capturer, FastSyntheticCapturer, SyntheticCapturer};
pub use pf_capture::{capturer_supports_444, Capturer, SyntheticCapturer};
// Only the GameStream compat media path uses the fast synthetic source (WP19).
#[cfg(feature = "gamestream")]
pub use pf_capture::FastSyntheticCapturer;
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
#[cfg(target_os = "windows")]
+121 -6
View File
@@ -20,7 +20,12 @@
//! `hex::decode(rikey)`. We auto-detect the exact scheme via [`decrypt_control`] on the first
//! packet that authenticates, since GCM gives no partial credit.
//!
//! Runs on its own native thread for the host's lifetime.
//! Runs on its own native thread — but only while at least one client is paired. `rusty_enet`
//! is a c2rust-style transpile of C ENet (raw-pointer arithmetic, manual allocation), and its
//! fragment reassembly / peer state machine run BEFORE the AES-GCM decrypt below — the host's
//! only pre-authentication unsafe surface (rust-safety WP0). Pairing itself never touches
//! 47999 (the PIN ceremony is HTTPS on nvhttp), so [`sync`] keeps the port closed until the
//! first pairing lands and tears it down when the last one is removed.
use super::{AppState, CONTROL_PORT};
use crate::inject::gamepad::GamepadManager;
@@ -29,12 +34,93 @@ use punktfunk_core::input::InputEvent;
use punktfunk_core::quic::HdrMeta;
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
use std::net::UdpSocket;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Bind the ENet control host on 47999 and service it forever on a dedicated thread.
pub fn spawn(state: Arc<AppState>) -> Result<()> {
/// Lifecycle gate for the control port (rust-safety WP0): binds 47999 only while the
/// paired-client list is non-empty, so a never-paired host exposes no ENet at all.
pub(crate) struct Gate {
/// Set once by `serve` when the GameStream planes are enabled (`--gamestream`). Without it
/// [`sync`] is a no-op — the management API's unpair endpoint also runs on native-only
/// hosts, and those must never bind a GameStream port.
enabled: AtomicBool,
/// The live listener; `None` = port closed. The mutex serializes concurrent reconciles
/// (two pairings in quick succession must not double-bind), and the bind/teardown
/// decision re-reads the paired list inside it so a pair racing an unpair cannot leave
/// the port in the wrong state.
running: Mutex<Option<Running>>,
}
impl Gate {
pub(crate) fn new() -> Gate {
Gate {
enabled: AtomicBool::new(false),
running: Mutex::new(None),
}
}
/// Arm the gate — [`sync`] stays a no-op until this is called (from `serve`'s
/// GameStream branch, the single existing source of truth for "compat planes on").
pub(crate) fn enable(&self) {
self.enabled.store(true, Ordering::SeqCst);
}
}
/// A bound control port being serviced: the stop signal plus the thread observing it.
struct Running {
/// Tells the service thread to say goodbye to a connected peer and exit (closing the
/// socket with it).
stop: Arc<AtomicBool>,
thread: std::thread::JoinHandle<()>,
}
/// Reconcile the control port to the paired-client list: bound while at least one pairing
/// exists, closed when none remain. Idempotent and race-free (see [`Gate::running`]); call it
/// wherever the paired list changes — startup, pairing phase 4, unpair.
pub(crate) fn sync(state: &Arc<AppState>) -> Result<()> {
let gate = &state.control_gate;
if !gate.enabled.load(Ordering::SeqCst) {
return Ok(());
}
let mut slot = gate.running.lock().unwrap_or_else(|e| e.into_inner());
// Reap a listener whose thread died (a panic would otherwise leave a Running that
// serves nobody and blocks every future rebind).
if slot.as_ref().is_some_and(|r| r.thread.is_finished()) {
if let Some(r) = slot.take() {
let _ = r.thread.join();
}
}
let want = !state
.paired
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty();
match (slot.is_some(), want) {
(false, true) => {
*slot = Some(spawn(state.clone())?);
Ok(())
}
(true, false) => {
let r = slot.take().expect("slot checked non-empty");
r.stop.store(true, Ordering::SeqCst);
// Join before returning: it guarantees the socket is closed before a re-pair can
// ask for a rebind. Bounded — the loop ticks every 2 ms, plus a ~100 ms farewell
// flush when a client was connected — and unpair-all is a rare operator action.
let _ = r.thread.join();
tracing::info!(
port = CONTROL_PORT,
"ENet control torn down — no paired clients remain"
);
Ok(())
}
_ => Ok(()),
}
}
/// Bind the ENet control host on 47999 and service it on a dedicated thread until `stop`.
fn spawn(state: Arc<AppState>) -> Result<Running> {
let socket = UdpSocket::bind(("0.0.0.0", CONTROL_PORT)).context("bind control UDP")?;
socket
.set_nonblocking(true)
@@ -52,7 +138,9 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
.map_err(|e| anyhow!("ENet host init: {e:?}"))?;
tracing::info!(port = CONTROL_PORT, "ENet control listening");
std::thread::Builder::new()
let stop = Arc::new(AtomicBool::new(false));
let stop_seen = stop.clone();
let thread = std::thread::Builder::new()
.name("punktfunk-control".into())
.spawn(move || {
// GCM scheme detected from the first authenticating packet; reused thereafter.
@@ -83,6 +171,33 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
// has to go out *because* the session ended could no longer be sealed.
let mut last_key: Option<[u8; 16]> = None;
loop {
// WP0 teardown: the last pairing was removed while we were live. Tell a
// connected client the session is over — termination + disconnect, the same
// farewell the host-side session end below uses — rather than vanish on it,
// flush briefly so the disconnect actually reaches the wire, then end the
// session and exit. Dropping `host` closes the socket.
if stop_seen.load(Ordering::SeqCst) {
if let Some(pid) = peer {
if let (Some(scheme), Some(key)) = (detected, last_key) {
let pt = termination_plaintext();
let wire = encrypt_control(&key, &scheme, host_seq, &pt);
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
{
tracing::warn!(error = ?e, "control: termination send failed");
}
}
host.peer_mut(pid).disconnect_later(0);
// Bounded flush: enough ticks for ENet to emit the termination and
// the disconnect handshake; we are exiting either way.
for _ in 0..50 {
while matches!(host.service(), Ok(Some(_))) {}
std::thread::sleep(Duration::from_millis(2));
}
}
state.end_session("control stream stopped — last pairing removed");
tracing::info!(port = CONTROL_PORT, "control: stopped (no paired clients)");
return;
}
loop {
match host.service() {
Ok(Some(event)) => match event {
@@ -282,7 +397,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
}
})
.context("spawn control thread")?;
Ok(())
Ok(Running { stop, thread })
}
/// Decode the lost-frame range from an invalidate-reference-frames (0x0301) control message: two
+188 -47
View File
@@ -6,24 +6,45 @@
//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and
//! the media streams follow (see the GameStream host task list / plan).
// The Moonlight-protocol modules exist only behind the `gamestream` cargo feature (rust-safety
// WP19): a native-only build (`--no-default-features --features pyrowave`) contains none of this
// code — and none of its dependencies (`rusty_enet`, `rsa`). What stays unconditional in this
// module is the shared vocabulary history parked here: `AppState`, `Host`, the port/version
// constants, the paired-list persistence, `serve` itself, and `tls` (the mgmt API's TLS lives
// there; only its Moonlight-client-cert leniency is feature-gated).
#[cfg(feature = "gamestream")]
pub mod apps;
// Platform-neutral wire/negotiation logic + the Linux capture/encode pipeline (non-Linux
// builds get a stub `start` inside the module).
#[cfg(feature = "gamestream")]
mod audio;
#[cfg(feature = "gamestream")]
pub(crate) mod cert;
#[cfg(feature = "gamestream")]
mod control;
#[cfg(feature = "gamestream")]
mod crypto;
#[cfg(feature = "gamestream")]
pub mod gamepad;
#[cfg(feature = "gamestream")]
mod input;
#[cfg(feature = "gamestream")]
mod mdns;
#[cfg(feature = "gamestream")]
mod nvhttp;
#[cfg(feature = "gamestream")]
mod pairing;
/// Moonlight `SS_PEN`/`SS_TOUCH` → the native pen model / wire touch (design/pen-tablet-input.md §4).
#[cfg(feature = "gamestream")]
mod pen;
#[cfg(feature = "gamestream")]
mod rtsp;
#[cfg(feature = "gamestream")]
mod serverinfo;
#[cfg(feature = "gamestream")]
mod stream;
pub(crate) mod tls;
#[cfg(feature = "gamestream")]
mod video;
use anyhow::{Context, Result};
@@ -170,16 +191,28 @@ pub struct LaunchSession {
/// Shared control-plane state used as the axum app state.
pub struct AppState {
pub host: Host,
/// The GameStream (RSA-2048) identity — Moonlight pins it, its pairing hashes bind its X.509
/// signature bytes. The native planes present `crate::identity` instead (the identity split).
#[cfg(feature = "gamestream")]
pub identity: cert::ServerIdentity,
#[cfg(feature = "gamestream")]
pub pairing: pairing::Pairing,
/// Pinned (paired) client certificate DERs — the post-pair allow-list.
/// Pinned (paired) client certificate DERs — the post-pair allow-list. Unconditional on
/// purpose: the mgmt list/unpair endpoints stay in every build (a native-only host can still
/// list + revoke pairings made by a GameStream-featured build sharing the config dir).
pub paired: std::sync::Mutex<Vec<Vec<u8>>>,
/// The ENet control port's lifecycle gate (rust-safety WP0): 47999 is bound only while
/// `paired` is non-empty — see [`control::sync`] / [`sync_control`].
#[cfg(feature = "gamestream")]
pub(crate) control_gate: control::Gate,
/// The active launch session (set by `/launch`, consumed by RTSP/media).
pub launch: std::sync::Mutex<Option<LaunchSession>>,
/// Negotiated video config from RTSP ANNOUNCE (consumed by the stream on PLAY).
#[cfg(feature = "gamestream")]
pub stream: std::sync::Mutex<Option<stream::StreamConfig>>,
/// Negotiated audio parameters from RTSP ANNOUNCE (channels/quality/packet duration);
/// defaults to stereo when a client never ANNOUNCEs them.
#[cfg(feature = "gamestream")]
pub audio_params: std::sync::Mutex<audio::AudioParams>,
/// True while the video stream thread is running (also its keep-running flag).
pub streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
@@ -207,6 +240,7 @@ pub struct AppState {
/// it was opened with the HDR (10-bit PQ) offer — a stream whose negotiated `hdr` differs
/// drops the pooled capturer and opens a fresh screencast session at the right depth
/// (mirroring the audio capturer's channel-count reuse gate).
#[cfg(feature = "gamestream")]
pub video_cap: stream::CapturerSlot,
/// Persistent audio capturer, reused across streams when the channel count still matches
/// (avoids a PipeWire stream setup per reconnect); drained on reuse so no stale audio is
@@ -245,6 +279,7 @@ impl AppState {
.unwrap_or_else(|e| e.into_inner())
.take()
.is_some();
#[cfg(feature = "gamestream")]
self.stream.lock().unwrap_or_else(|e| e.into_inner()).take();
if was_streaming || was_audio || had_launch {
tracing::info!(
@@ -271,7 +306,9 @@ impl AppState {
/// Fresh control-plane state: no active session; the pairing allow-list is loaded from
/// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the
/// mgmt API and the streaming loops.
/// mgmt API and the streaming loops. (The native-only build's variant is below — same state
/// minus the Moonlight identity/pairing machinery.)
#[cfg(feature = "gamestream")]
pub fn new(
host: Host,
identity: cert::ServerIdentity,
@@ -282,6 +319,7 @@ impl AppState {
identity,
pairing: pairing::Pairing::new(),
paired: std::sync::Mutex::new(load_paired()),
control_gate: control::Gate::new(),
launch: std::sync::Mutex::new(None),
stream: std::sync::Mutex::new(None),
audio_params: std::sync::Mutex::new(audio::AudioParams::default()),
@@ -295,6 +333,41 @@ impl AppState {
stats,
}
}
/// The native-only build's [`AppState::new`]: identical control-plane state minus the
/// Moonlight machinery (identity/pairing/control-gate and the RTSP-negotiated stream slots),
/// which does not exist in this build.
#[cfg(not(feature = "gamestream"))]
pub fn new(host: Host, stats: Arc<crate::stats_recorder::StatsRecorder>) -> AppState {
AppState {
host,
paired: std::sync::Mutex::new(load_paired()),
launch: std::sync::Mutex::new(None),
streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
quit: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
audio_streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
force_idr: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)),
audio_cap: std::sync::Arc::new(std::sync::Mutex::new(None)),
stats,
}
}
}
/// Reconcile the ENet control port to the paired-client list — bound iff at least one pairing
/// exists (rust-safety WP0; see [`control::sync`]). A crate-visible wrapper so callers outside
/// `gamestream` (the management API's unpair) can reach it past the private `control` module.
/// A no-op unless `serve` armed the gate (`--gamestream`).
#[cfg(feature = "gamestream")]
pub(crate) fn sync_control(state: &Arc<AppState>) -> Result<()> {
control::sync(state)
}
/// Native-only build: there is no ENet control port to reconcile — the callers (the mgmt unpair)
/// stay uniform and this is the whole implementation.
#[cfg(not(feature = "gamestream"))]
pub(crate) fn sync_control(_state: &Arc<AppState>) -> Result<()> {
Ok(())
}
/// Run the host (blocks): mDNS, the nvhttp servers, and the management REST API.
@@ -312,18 +385,38 @@ pub fn serve(
native: crate::native::NativeServe,
gamestream: bool,
) -> Result<()> {
// WP19: `serve --gamestream` / PUNKTFUNK_GAMESTREAM=1 against a native-only binary is an
// explicit ask this build cannot honor — refuse loudly rather than quietly serve less than
// the operator configured.
#[cfg(not(feature = "gamestream"))]
if gamestream {
anyhow::bail!(
"this punktfunk-host was built WITHOUT the 'gamestream' feature — stock-Moonlight \
compat is unavailable in this binary. Remove --gamestream / PUNKTFUNK_GAMESTREAM \
from the configuration, or install a standard (GameStream-featured) build."
);
}
let host = Host::detect()?;
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
let state = Arc::new(AppState::new(host, identity, stats.clone()));
#[cfg(feature = "gamestream")]
let state = {
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
Arc::new(AppState::new(host, identity, stats.clone()))
};
#[cfg(not(feature = "gamestream"))]
let state = Arc::new(AppState::new(host, stats.clone()));
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
// and the management API) always exists.
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(None, None, false)
.context("native pairing store")?,
);
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
// the two planes cannot race the first-run adoption. See `crate::identity`.
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
tracing::info!(
hostname = %state.host.hostname,
uniqueid = %state.host.uniqueid,
@@ -379,36 +472,58 @@ pub fn serve(
gamestream,
});
let served: anyhow::Result<()> = if gamestream {
// Unified host: GameStream compat planes + native + mgmt. The `_nvstream` advert is
// fatal on failure when enabled (Moonlight clients can't find the host without it) —
// `--no-mdns` / PUNKTFUNK_MDNS=0 skips it for multicast-dead environments (stock
// Moonlight then needs a manually-added host).
let _advert = if native.mdns {
Some(mdns::advertise(&state.host).context("mDNS advertise")?)
} else {
// WP19: `gamestream` can only be true when the feature is compiled in — serve()'s
// top bails otherwise — so the native-only build's arm is a plain unreachable.
#[cfg(not(feature = "gamestream"))]
{
unreachable!("serve() refuses --gamestream in a native-only build")
}
#[cfg(feature = "gamestream")]
{
// Unified host: GameStream compat planes + native + mgmt. The `_nvstream` advert is
// fatal on failure when enabled (Moonlight clients can't find the host without it) —
// `--no-mdns` / PUNKTFUNK_MDNS=0 skips it for multicast-dead environments (stock
// Moonlight then needs a manually-added host).
let _advert = if native.mdns {
Some(mdns::advertise(&state.host).context("mDNS advertise")?)
} else {
tracing::info!(
"GameStream mDNS advertisement disabled (--no-mdns / PUNKTFUNK_MDNS)"
);
None
};
rtsp::spawn(state.clone()).context("start RTSP server")?;
// WP0 (rust-safety): the ENet control port is the host's one pre-auth-reachable
// unsafe surface (`rusty_enet` is a transpiled C stack), so it binds only while a
// pairing exists — a never-paired host on a hostile LAN exposes no ENet at all.
// Pairing is HTTPS on nvhttp and never touches 47999; phase 4 re-syncs the port the
// moment the first client pins, so it is up before that client can `/launch`.
state.control_gate.enable();
sync_control(&state).context("start ENet control server")?;
tracing::info!(
"GameStream mDNS advertisement disabled (--no-mdns / PUNKTFUNK_MDNS)"
port = native.port,
"unified host: GameStream/Moonlight compat + native punktfunk/1 (QUIC)"
);
None
};
rtsp::spawn(state.clone()).context("start RTSP server")?;
control::spawn(state.clone()).context("start ENet control server")?;
tracing::info!(
port = native.port,
"unified host: GameStream/Moonlight compat + native punktfunk/1 (QUIC)"
);
tokio::try_join!(
nvhttp::run(state.clone()),
crate::mgmt::run(
state.clone(),
mgmt,
Some(np.clone()),
stats.clone(),
gamestream
),
crate::native::serve(native_opts, native.mgmt_port, np, stats.clone()),
)
.map(|_| ())
tokio::try_join!(
nvhttp::run(state.clone()),
crate::mgmt::run(
state.clone(),
mgmt,
Some(np.clone()),
stats.clone(),
gamestream,
native_ident.clone(),
),
crate::native::serve(
native_opts,
native.mgmt_port,
np,
stats.clone(),
native_ident
),
)
.map(|_| ())
}
} else {
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
tracing::info!(
@@ -422,9 +537,16 @@ pub fn serve(
mgmt,
Some(np.clone()),
stats.clone(),
gamestream
gamestream,
native_ident.clone(),
),
crate::native::serve(
native_opts,
native.mgmt_port,
np,
stats.clone(),
native_ident
),
crate::native::serve(native_opts, native.mgmt_port, np, stats.clone()),
)
.map(|_| ())
};
@@ -442,6 +564,13 @@ fn hostname_string() -> String {
if let Some(n) = pf_host_config::config().host_name.as_deref() {
return sanitize_display_name(n);
}
machine_hostname()
}
/// The raw machine hostname (no `PUNKTFUNK_HOST_NAME` override, no display sanitizing) — what a
/// certificate SAN or a DNS-ish consumer wants, as opposed to [`hostname_string`]'s free-text
/// display name.
pub(crate) fn machine_hostname() -> String {
#[cfg(target_os = "windows")]
if let Some(n) = std::env::var_os("COMPUTERNAME") {
let s = n.to_string_lossy().trim().to_string();
@@ -601,13 +730,21 @@ mod session_tests {
os_chain: "linux".into(),
os_name: "Linux".into(),
};
let identity = cert::ServerIdentity::ephemeral().expect("ephemeral identity");
let stats = crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
"pf-gs-endsession-{}-{:p}",
std::process::id(),
&0u8 as *const u8
)));
AppState::new(host, identity, stats)
// Both build flavors: the session teardown under test is feature-independent.
#[cfg(feature = "gamestream")]
{
let identity = cert::ServerIdentity::ephemeral().expect("ephemeral identity");
AppState::new(host, identity, stats)
}
#[cfg(not(feature = "gamestream"))]
{
AppState::new(host, stats)
}
}
/// `end_session` is THE compat-plane teardown: one call must clear the whole session — both
@@ -630,22 +767,26 @@ mod session_tests {
peer_ip: None,
owner_fp: None,
});
*state.stream.lock().unwrap() = Some(stream::StreamConfig {
width: 1920,
height: 1080,
fps: 60,
packet_size: 1024,
bitrate_kbps: 20_000,
codec: crate::encode::Codec::H265,
min_fec: 0,
hdr: false,
slices: 1, // the no-request default — hardware decoders get single-slice AUs
});
#[cfg(feature = "gamestream")]
{
*state.stream.lock().unwrap() = Some(stream::StreamConfig {
width: 1920,
height: 1080,
fps: 60,
packet_size: 1024,
bitrate_kbps: 20_000,
codec: crate::encode::Codec::H265,
min_fec: 0,
hdr: false,
slices: 1, // the no-request default — hardware decoders get single-slice AUs
});
}
assert!(state.end_session("test"), "video was live");
assert!(!state.streaming.load(Ordering::SeqCst));
assert!(!state.audio_streaming.load(Ordering::SeqCst));
assert!(state.launch.lock().unwrap().is_none());
#[cfg(feature = "gamestream")]
assert!(state.stream.lock().unwrap().is_none());
// Idempotent: a second end (e.g. `/cancel` racing the ENet Disconnect) is a no-op.
@@ -389,7 +389,15 @@ async fn h_pair(
} else if let Some(v) = q.get("serverchallengeresp") {
st.pairing.serverchallengeresp(&st.identity, &uniqueid, v)
} else if let Some(v) = q.get("clientpairingsecret") {
st.pairing.clientpairingsecret(&uniqueid, v, &st.paired)
let r = st.pairing.clientpairingsecret(&uniqueid, v, &st.paired);
// Phase 4 may just have pinned the FIRST pairing — bring the ENet control port up now
// (idempotent; rust-safety WP0) so this client's imminent /launch finds the control
// stream listening. Moonlight connects control before video, so "eventually up" would
// be an aborted session.
if let Err(e) = super::sync_control(&st) {
tracing::warn!(error = %format!("{e:#}"), "control port sync after pairing failed");
}
r
} else {
Ok(pair_error_xml())
};
+15 -6
View File
@@ -133,13 +133,17 @@ impl ClientCertVerifier for AcceptAnyClientCert {
cert: &CertificateDer,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls12_signature(
let verdict = verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
.or_else(|e| accept_legacy_moonlight_cert(message, cert, dss, e))
);
// The Moonlight-client-cert leniency exists only when the compat planes do (WP19) —
// native clients present webpki-clean certs and never need it.
#[cfg(feature = "gamestream")]
let verdict = verdict.or_else(|e| accept_legacy_moonlight_cert(message, cert, dss, e));
verdict
}
fn verify_tls13_signature(
@@ -148,13 +152,17 @@ impl ClientCertVerifier for AcceptAnyClientCert {
cert: &CertificateDer,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(
let verdict = verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
.or_else(|e| accept_legacy_moonlight_cert(message, cert, dss, e))
);
// The Moonlight-client-cert leniency exists only when the compat planes do (WP19) —
// native clients present webpki-clean certs and never need it.
#[cfg(feature = "gamestream")]
let verdict = verdict.or_else(|e| accept_legacy_moonlight_cert(message, cert, dss, e));
verdict
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
@@ -187,6 +195,7 @@ impl ClientCertVerifier for AcceptAnyClientCert {
/// signature still fails, and any non-RSA / unsupported scheme falls through to webpki's original
/// error `webpki_err`. Moonlight/Sunshine client certs are RSA-2048, so this matches Sunshine's
/// leniency without loosening the pinned trust model.
#[cfg(feature = "gamestream")]
fn accept_legacy_moonlight_cert(
message: &[u8],
cert: &CertificateDer,
+247
View File
@@ -0,0 +1,247 @@
//! The native plane's host identity — the identity split (rust-safety programme).
//!
//! History: ONE RSA-2048 identity (`gamestream::cert`) served every plane, because Moonlight
//! mandates RSA and the planes grew out of the GameStream host. The native punktfunk/1 QUIC
//! plane and the management API now share THIS identity instead: **ECDSA P-256** — generated by
//! ring via rcgen (no `rsa` crate on the native path, so the accepted Marvin advisory
//! RUSTSEC-2023-0071 stops applying to native-only hosts once WP19 gates the compat planes),
//! browser-compatible (an operator opens `/api/docs` in one, and no mainstream browser accepts an
//! Ed25519 server cert), and carrying real SANs (localhost, loopback, the machine hostname) —
//! which the legacy cert never had. The GameStream plane keeps the RSA identity untouched:
//! Moonlight pins it and its pairing hashes bind its X.509 signature bytes.
//!
//! ## Migration — the rule that keeps every pinned fingerprint valid
//!
//! Clients TOFU-pin the SHA-256 of the leaf cert DER at pairing and use that one pin for BOTH the
//! QUIC plane and the mgmt/library API, so the two must always present the same identity, and it
//! must never change under a client that pinned it. Hence [`load_or_adopt`]:
//!
//! * `native-cert.pem` + `native-key.pem` exist → use them (steady state);
//! * else, the native trust store is EMPTY (nobody has ever pinned this host) → generate P-256,
//! persist, adopt — fresh installs get the split immediately;
//! * else (an upgraded host with live native pairings) → keep presenting the **legacy RSA**
//! identity those clients pinned, and log how to migrate (unpair all native clients, restart).
//! Nothing breaks silently; the operator chooses the re-pair moment.
use anyhow::{Context, Result};
use pf_paths::config_dir;
use std::fs;
/// The cert/key the native QUIC plane and the management API present. Plain PEMs — every
/// consumer (quinn via `punktfunk_core::quic::endpoint`, the mgmt rustls config) parses PEM
/// generically, so RSA (legacy fallback) and P-256 (the split identity) both fit.
#[derive(Clone)]
pub struct NativeIdentity {
pub cert_pem: String,
pub key_pem: String,
}
/// Resolve the native identity per the migration rule in the module docs. Call it ONCE per
/// process (in `serve`, before the planes start) and hand the result to both consumers — two
/// concurrent callers could otherwise race the first-run file writes.
pub fn load_or_adopt(np: &crate::native_pairing::NativePairing) -> Result<NativeIdentity> {
let dir = config_dir();
let cert_path = dir.join("native-cert.pem");
let key_path = dir.join("native-key.pem");
// Harden the directory BEFORE the first read (the same 2026-08-05 M-1 rationale as the legacy
// identity): a pre-planted cert/key pair must not be adoptable out of a user-writable dir.
pf_paths::create_private_dir(&dir).ok();
if let (Ok(c), Ok(k)) = (
fs::read_to_string(&cert_path),
fs::read_to_string(&key_path),
) {
if !c.trim().is_empty() && !k.trim().is_empty() {
return Ok(NativeIdentity {
cert_pem: c,
key_pem: k,
});
}
}
if np.list().is_empty() {
let (cert_pem, key_pem) = generate()?;
// The key is the trust root for the QUIC identity and the mgmt TLS — owner-only, exactly
// like the legacy key. The cert is public but written the same way for consistency.
pf_paths::write_secret_file(&key_path, key_pem.as_bytes())
.with_context(|| format!("write {}", key_path.display()))?;
pf_paths::write_secret_file(&cert_path, cert_pem.as_bytes())
.with_context(|| format!("write {}", cert_path.display()))?;
tracing::info!(
path = %cert_path.display(),
"generated the native host identity (ECDSA P-256, SANs, key 0600)"
);
return Ok(NativeIdentity { cert_pem, key_pem });
}
// Live native pairings pinned the legacy RSA cert — switching identities now would strand
// every one of them (the pin is the SHA-256 of the leaf DER). Keep serving what they pinned.
// A pem-only read on purpose (WP19): rustls/ring can SERVE an existing RSA cert without the
// `rsa` crate, so the native-only build never links it — the crate exists solely behind the
// `gamestream` feature (generation + the pairing signer).
if let (Ok(c), Ok(k)) = (
fs::read_to_string(dir.join("cert.pem")),
fs::read_to_string(dir.join("key.pem")),
) {
if !c.trim().is_empty() && !k.trim().is_empty() {
tracing::info!(
"native identity: keeping the legacy RSA cert — paired native clients pinned it. \
To migrate to the P-256 identity: unpair ALL native clients, restart the host, \
re-pair."
);
return Ok(NativeIdentity {
cert_pem: c,
key_pem: k,
});
}
}
// Degenerate: native pairings exist but the cert they pinned is gone from disk — those
// clients are stranded whatever we serve, so mint the P-256 identity and say so.
tracing::warn!(
"native identity: paired native clients exist but the legacy cert.pem/key.pem they \
pinned is missing minting the P-256 identity; those clients must re-pair"
);
let (cert_pem, key_pem) = generate()?;
pf_paths::write_secret_file(&key_path, key_pem.as_bytes())
.with_context(|| format!("write {}", key_path.display()))?;
pf_paths::write_secret_file(&cert_path, cert_pem.as_bytes())
.with_context(|| format!("write {}", cert_path.display()))?;
Ok(NativeIdentity { cert_pem, key_pem })
}
/// Throwaway in-memory identity — nothing touches the config dir (tests).
#[cfg(test)]
pub fn ephemeral() -> Result<NativeIdentity> {
let (cert_pem, key_pem) = generate()?;
Ok(NativeIdentity { cert_pem, key_pem })
}
/// Generate the P-256 identity: ring CAN generate EC keys (unlike RSA — see `gamestream::cert`'s
/// note), so rcgen's ring backend does the whole thing. SANs cover the names a browser or a
/// loopback poller actually dials; LAN IPs are deliberately absent (they change, and the native
/// clients pin the fingerprint rather than verify names).
fn generate() -> Result<(String, String)> {
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
.context("generate P-256 host key")?;
let mut sans = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"::1".to_string(),
];
// The machine hostname, when it is a clean DNS label (a display name with spaces is not).
let hn = crate::gamestream::machine_hostname();
if !hn.is_empty()
&& hn.len() <= 63
&& hn
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
{
sans.insert(0, hn);
}
let mut params = rcgen::CertificateParams::new(sans).context("cert params")?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, "punktfunk");
// Mirror the legacy identity's fixed window: no clock-skew surprises, and clients pin the
// fingerprint rather than check validity.
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
let cert = params.self_signed(&key).context("self-sign cert")?;
Ok((cert.pem(), key.serialize_pem()))
}
/// Serializes every test in this crate that overrides `PUNKTFUNK_CONFIG_DIR` — the env var is
/// process-global, so two such tests running in parallel threads would read each other's
/// throwaway config dirs. Lock it BEFORE installing the override.
#[cfg(test)]
pub(crate) static CONFIG_DIR_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
/// Scoped `PUNKTFUNK_CONFIG_DIR` override, restored on drop even if an assertion panics —
/// these tests write identity files and must never touch a real config dir.
struct EnvGuard(Option<std::ffi::OsString>);
impl EnvGuard {
fn set(dir: &std::path::Path) -> EnvGuard {
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
std::env::set_var("PUNKTFUNK_CONFIG_DIR", dir);
EnvGuard(prev)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("PUNKTFUNK_CONFIG_DIR", v),
None => std::env::remove_var("PUNKTFUNK_CONFIG_DIR"),
}
}
}
fn empty_store(dir: &std::path::Path) -> crate::native_pairing::NativePairing {
crate::native_pairing::NativePairing::load_with(
Some(dir.join("native-trust.json")),
None,
false,
)
.unwrap()
}
#[test]
fn adopts_p256_when_no_client_ever_pinned() {
let _serial = super::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _env = EnvGuard::set(tmp.path());
let np = empty_store(tmp.path());
let id = load_or_adopt(&np).unwrap();
// A P-256 (id-ecPublicKey) cert with the promised SANs, persisted…
let (_, pem) = x509_parser::pem::parse_x509_pem(id.cert_pem.as_bytes()).unwrap();
let x509 = pem.parse_x509().unwrap();
assert_eq!(
x509.public_key().algorithm.algorithm.to_id_string(),
"1.2.840.10045.2.1", // id-ecPublicKey
);
assert!(id.cert_pem.contains("BEGIN CERTIFICATE"));
assert!(tmp.path().join("native-cert.pem").exists());
assert!(tmp.path().join("native-key.pem").exists());
// …and stable: a second resolve loads the same identity (same fingerprint forever).
let again = load_or_adopt(&np).unwrap();
assert_eq!(again.cert_pem, id.cert_pem);
}
#[test]
fn keeps_legacy_rsa_while_native_pairings_exist() {
let _serial = super::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _env = EnvGuard::set(tmp.path());
let np = empty_store(tmp.path());
np.add("old-client", &"ab".repeat(32)).unwrap();
// The legacy identity that client pinned. Contents are opaque to the fallback — it
// serves the files verbatim (pem-only read; no `rsa` crate involved, WP19).
std::fs::write(tmp.path().join("cert.pem"), "legacy cert pem").unwrap();
std::fs::write(tmp.path().join("key.pem"), "legacy key pem").unwrap();
let id = load_or_adopt(&np).unwrap();
// The legacy RSA identity is what that client pinned — it must be what we serve…
assert_eq!(id.cert_pem, "legacy cert pem");
// …and no P-256 identity may be minted while the pin is live.
assert!(!tmp.path().join("native-cert.pem").exists());
}
/// Degenerate: pairings exist but the legacy cert they pinned is gone — those clients are
/// stranded whatever we serve, so the P-256 identity is minted rather than failing to start.
#[test]
fn mints_p256_when_legacy_files_vanished() {
let _serial = super::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _env = EnvGuard::set(tmp.path());
let np = empty_store(tmp.path());
np.add("stranded-client", &"cd".repeat(32)).unwrap();
let id = load_or_adopt(&np).unwrap();
assert!(id.cert_pem.contains("BEGIN CERTIFICATE"));
assert!(tmp.path().join("native-cert.pem").exists());
}
}
+22 -1
View File
@@ -29,7 +29,10 @@ mod bringup;
mod capture;
mod detect;
mod devtest;
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below).
#[forbid(unsafe_code)]
mod discovery;
#[forbid(unsafe_code)]
mod wol;
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
// keeps the `crate::*` module names flat (every existing path is unchanged).
@@ -59,6 +62,9 @@ mod gamestream;
#[path = "linux/gpuclocks.rs"]
mod gpuclocks;
mod hooks;
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below).
#[forbid(unsafe_code)]
mod identity;
// The input-injection backends live in the `pf-inject` subsystem crate (plan §W6); this shim keeps
// every existing `crate::inject::*` path valid (the native/gamestream input planes + devtest consume
// the trait, factory, and per-device backends through it).
@@ -77,9 +83,18 @@ mod interactive;
mod launchreg;
mod library;
mod log_capture;
// The native punktfunk/1 plane and the management API — everything a SECURE-DEFAULT host
// exposes — are safe Rust by compiler-enforced invariant (rust-safety programme): `forbid`
// here means a future edit cannot quietly introduce unsafe into a network-facing module.
// (`native` carves out its `#[cfg(test)]` C-ABI roundtrip tests, which exercise the CLIENT
// side of punktfunk-core against this host in-process and are unsafe by nature.)
#[forbid(unsafe_code)]
mod mgmt;
#[forbid(unsafe_code)]
mod mgmt_token;
#[cfg_attr(not(test), forbid(unsafe_code))]
mod native;
#[forbid(unsafe_code)]
mod native_pairing;
mod osinfo;
mod pipeline;
@@ -831,6 +846,10 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, b
data_port,
mdns: !no_mdns && discovery::mdns_enabled(),
};
// The Moonlight-compat planes are opt-in from EITHER source: the `--gamestream` CLI flag or
// `PUNKTFUNK_GAMESTREAM` in host.env — the packaged systemd units ship a fixed native-only
// ExecStart, so the env knob is how a package user opts in without editing the unit.
let gamestream = gamestream || pf_host_config::config().gamestream;
Ok((opts, native, gamestream))
}
@@ -980,7 +999,9 @@ SERVE OPTIONS:
--gamestream (--moonlight) ALSO run the GameStream/Moonlight-compat planes (nvhttp pairing,
RTSP, ENet control, _nvstream mDNS). OFF by default they carry
inherent on-path weaknesses (plain-HTTP pairing + legacy GCM nonce
reuse, security-review #5/#9); enable only on a TRUSTED LAN
reuse, security-review #5/#9); enable only on a TRUSTED LAN.
Also PUNKTFUNK_GAMESTREAM=1 in host.env (how a packaged install
opts in the shipped units run native-only)
--native no-op (the native punktfunk/1 plane always runs in `serve` now)
--native-port <PORT> native QUIC port (default 9777)
--data-port <PORT> pin the per-session video data plane to this fixed UDP port and
+98 -92
View File
@@ -111,6 +111,10 @@ pub async fn run(
native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
gamestream_enabled: bool,
// The identity split (`crate::identity`): the mgmt API must present the SAME identity as
// the native QUIC plane — paired clients hold ONE pinned fingerprint for both — so the
// caller resolves it once and hands it to both.
identity: crate::identity::NativeIdentity,
) -> Result<()> {
// Close out any update-intent record a previous apply left behind (the update reports its
// own outcome across its own restart — update/jobs.rs). Once per boot, before serving.
@@ -123,11 +127,10 @@ pub async fn run(
.token
.filter(|t| !t.trim().is_empty())
.context("management API has no token — internal error: parse_serve must provide one")?;
// Serve over HTTPS with the host's persistent identity (the cert clients already pin) and
// OPTIONAL client-cert auth: a paired native client presents its cert (authorized by
// fingerprint, no token), a browser presents none and uses the bearer token. See `require_auth`.
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
.context("load host identity for the management API TLS")?;
// Serve over HTTPS with the native identity (the cert clients already pin — see the
// `identity` parameter) and OPTIONAL client-cert auth: a paired native client presents its
// cert (authorized by fingerprint, no token), a browser presents none and uses the bearer
// token. See `require_auth`.
let tls = crate::gamestream::tls::server_config_optional_client(
&identity.cert_pem,
&identity.key_pem,
@@ -189,94 +192,97 @@ fn app(
/// The versioned API routes + the OpenAPI document collected from them. Single source of
/// truth for both the live server and the `openapi` subcommand.
fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
let api_v1 = OpenApiRouter::new()
.routes(routes!(host::get_health))
.routes(routes!(host::get_host_info))
.routes(routes!(host::list_compositors))
.routes(routes!(gpu::list_gpus))
.routes(routes!(gpu::set_gpu_preference))
.routes(routes!(display::get_display_settings))
.routes(routes!(display::set_display_settings))
.routes(routes!(display::get_display_state))
.routes(routes!(display::get_display_monitors))
.routes(routes!(display::release_display))
.routes(routes!(display::set_display_layout))
.routes(routes!(
display::list_custom_presets,
display::create_custom_preset
))
.routes(routes!(
display::update_custom_preset,
display::delete_custom_preset
))
.routes(routes!(host::get_status))
.routes(routes!(host::get_local_summary))
.routes(routes!(clients::list_paired_clients))
.routes(routes!(clients::unpair_client));
// The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only
// build's API (and its OpenAPI document) simply has no such endpoints.
#[cfg(feature = "gamestream")]
let api_v1 = api_v1
.routes(routes!(clients::get_pairing_status))
.routes(routes!(clients::submit_pairing_pin));
let api_v1 = api_v1
.routes(routes!(native::get_native_pairing))
.routes(routes!(native::arm_native_pairing))
.routes(routes!(native::disarm_native_pairing))
.routes(routes!(native::list_native_clients))
.routes(routes!(native::unpair_native_client))
.routes(routes!(native::list_pending_devices))
.routes(routes!(native::approve_pending_device))
.routes(routes!(native::deny_pending_device))
.routes(routes!(session::stop_session))
.routes(routes!(session::request_idr))
.routes(routes!(
session::get_session_settings,
session::set_session_settings
))
.routes(routes!(session::end_game))
.routes(routes!(library::get_library))
.routes(routes!(library::list_library_scanners))
.routes(routes!(library::set_library_scanner))
.routes(routes!(library::set_library_entry_hidden))
.routes(routes!(library::create_custom_game))
.routes(routes!(
library::update_custom_game,
library::delete_custom_game
))
.routes(routes!(
library::reconcile_provider_entries,
library::delete_provider_entries
))
.routes(routes!(library::get_library_art))
.routes(routes!(stats::stats_capture_start))
.routes(routes!(stats::stats_capture_stop))
.routes(routes!(stats::stats_capture_status))
.routes(routes!(stats::stats_capture_live))
.routes(routes!(stats::stats_recordings_list))
.routes(routes!(
stats::stats_recording_get,
stats::stats_recording_delete
))
.routes(routes!(stats::logs_get))
.routes(routes!(events::stream_events))
.routes(routes!(hooks::get_hooks, hooks::set_hooks))
.routes(routes!(plugins::list_plugins))
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
.routes(routes!(plugins::get_ui_credential))
.routes(routes!(plugins::ingest_plugin_logs))
.routes(routes!(store::get_catalog))
.routes(routes!(store::refresh_catalog))
.routes(routes!(store::list_installed))
.routes(routes!(store::install_plugin))
.routes(routes!(store::uninstall_plugin))
.routes(routes!(store::list_jobs))
.routes(routes!(store::get_job))
.routes(routes!(store::list_sources))
.routes(routes!(store::put_source, store::delete_source))
.routes(routes!(store::get_runtime, store::set_runtime))
.routes(routes!(update::get_update_status))
.routes(routes!(update::force_update_check))
.routes(routes!(update::apply_update));
OpenApiRouter::with_openapi(ApiDoc::openapi())
.nest(
"/api/v1",
OpenApiRouter::new()
.routes(routes!(host::get_health))
.routes(routes!(host::get_host_info))
.routes(routes!(host::list_compositors))
.routes(routes!(gpu::list_gpus))
.routes(routes!(gpu::set_gpu_preference))
.routes(routes!(display::get_display_settings))
.routes(routes!(display::set_display_settings))
.routes(routes!(display::get_display_state))
.routes(routes!(display::get_display_monitors))
.routes(routes!(display::release_display))
.routes(routes!(display::set_display_layout))
.routes(routes!(
display::list_custom_presets,
display::create_custom_preset
))
.routes(routes!(
display::update_custom_preset,
display::delete_custom_preset
))
.routes(routes!(host::get_status))
.routes(routes!(host::get_local_summary))
.routes(routes!(clients::list_paired_clients))
.routes(routes!(clients::unpair_client))
.routes(routes!(clients::get_pairing_status))
.routes(routes!(clients::submit_pairing_pin))
.routes(routes!(native::get_native_pairing))
.routes(routes!(native::arm_native_pairing))
.routes(routes!(native::disarm_native_pairing))
.routes(routes!(native::list_native_clients))
.routes(routes!(native::unpair_native_client))
.routes(routes!(native::list_pending_devices))
.routes(routes!(native::approve_pending_device))
.routes(routes!(native::deny_pending_device))
.routes(routes!(session::stop_session))
.routes(routes!(session::request_idr))
.routes(routes!(
session::get_session_settings,
session::set_session_settings
))
.routes(routes!(session::end_game))
.routes(routes!(library::get_library))
.routes(routes!(library::list_library_scanners))
.routes(routes!(library::set_library_scanner))
.routes(routes!(library::set_library_entry_hidden))
.routes(routes!(library::create_custom_game))
.routes(routes!(
library::update_custom_game,
library::delete_custom_game
))
.routes(routes!(
library::reconcile_provider_entries,
library::delete_provider_entries
))
.routes(routes!(library::get_library_art))
.routes(routes!(stats::stats_capture_start))
.routes(routes!(stats::stats_capture_stop))
.routes(routes!(stats::stats_capture_status))
.routes(routes!(stats::stats_capture_live))
.routes(routes!(stats::stats_recordings_list))
.routes(routes!(
stats::stats_recording_get,
stats::stats_recording_delete
))
.routes(routes!(stats::logs_get))
.routes(routes!(events::stream_events))
.routes(routes!(hooks::get_hooks, hooks::set_hooks))
.routes(routes!(plugins::list_plugins))
.routes(routes!(plugins::register_plugin, plugins::delete_plugin))
.routes(routes!(plugins::get_ui_credential))
.routes(routes!(plugins::ingest_plugin_logs))
.routes(routes!(store::get_catalog))
.routes(routes!(store::refresh_catalog))
.routes(routes!(store::list_installed))
.routes(routes!(store::install_plugin))
.routes(routes!(store::uninstall_plugin))
.routes(routes!(store::list_jobs))
.routes(routes!(store::get_job))
.routes(routes!(store::list_sources))
.routes(routes!(store::put_source, store::delete_source))
.routes(routes!(store::get_runtime, store::set_runtime))
.routes(routes!(update::get_update_status))
.routes(routes!(update::force_update_check))
.routes(routes!(update::apply_update)),
)
.nest("/api/v1", api_v1)
.split_for_parts()
}
+39 -4
View File
@@ -19,6 +19,7 @@ pub(crate) struct PairedClient {
}
/// Pairing-flow status.
#[cfg(feature = "gamestream")]
#[derive(Serialize, ToSchema)]
pub(crate) struct PairingStatus {
/// True while a pairing handshake is parked waiting for the user's PIN.
@@ -26,6 +27,7 @@ pub(crate) struct PairingStatus {
}
/// The PIN Moonlight displays during pairing.
#[cfg(feature = "gamestream")]
#[derive(Deserialize, ToSchema)]
pub(crate) struct SubmitPin {
/// 116 ASCII digits (Moonlight shows 4).
@@ -76,10 +78,13 @@ pub(crate) fn client_info(der: &[u8]) -> PairedClient {
/// Unpair a client
///
/// Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer
/// does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed
/// client cert — a planned hardening step), so until that lands this removes the client
/// from the listing without severing its ability to reconnect.
/// Removes the client's certificate from the pairing store (persisted — the removal survives a
/// host restart). Revocation is complete: a LIVE GameStream session owned by this certificate is
/// ended (the client gets the standard TERMINATION+disconnect), and removing the last pairing
/// also closes the ENet control port (UDP 47999), which is only bound while at least one pairing
/// exists. The nvhttp TLS layer still completes a handshake with any well-formed client cert BY
/// DESIGN (authorization is per-request via the paired-fingerprint check) — an unpaired client
/// that reconnects is rejected at every post-pair endpoint.
#[utoipa::path(
delete,
path = "/clients/{fingerprint}",
@@ -110,6 +115,34 @@ pub(crate) async fn unpair_client(
let before = paired.len();
paired.retain(|der| !hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint));
if paired.len() < before {
// Persist the removal — without this the unpair lasted only until the next host
// restart, which now also matters below: a resurrected pairing would silently
// re-open the control port.
crate::gamestream::save_paired(&paired);
drop(paired);
// Revocation reaches a LIVE session too: a mid-stream client whose pairing was just
// removed must not keep streaming until it chooses to leave. Clearing the launch makes
// the ENet control thread give it the standard TERMINATION+disconnect farewell. (An
// owner-less launch — the cert was unreadable at /launch — cannot be attributed and is
// left to the port teardown below when this was the last pairing.)
let removed_fp: Option<[u8; 32]> = hex::decode(&fingerprint)
.ok()
.and_then(|v| v.try_into().ok());
let live_owner = st
.app
.launch
.lock()
.unwrap_or_else(|e| e.into_inner())
.and_then(|l| l.owner_fp);
if removed_fp.is_some() && removed_fp == live_owner {
st.app.quit_session("client unpaired");
}
// The last pairing going away closes the ENet control port (rust-safety WP0). A
// no-op while other pairings remain — or on a native-only host, where the gate is
// never armed.
if let Err(e) = crate::gamestream::sync_control(&st.app) {
tracing::warn!(error = %format!("{e:#}"), "control port sync after unpair failed");
}
tracing::info!(fingerprint, "management API: client unpaired");
StatusCode::NO_CONTENT.into_response()
} else {
@@ -123,6 +156,7 @@ pub(crate) async fn unpair_client(
/// Pairing-flow status
///
/// Poll this to know when to prompt the user for the PIN Moonlight displays.
#[cfg(feature = "gamestream")]
#[utoipa::path(
get,
path = "/pair",
@@ -143,6 +177,7 @@ pub(crate) async fn get_pairing_status(State(st): State<Arc<MgmtState>>) -> Json
///
/// Delivers the PIN the Moonlight client is displaying, completing the out-of-band half
/// of the pairing handshake.
#[cfg(feature = "gamestream")]
#[utoipa::path(
post,
path = "/pair/pin",
+47 -30
View File
@@ -432,6 +432,9 @@ pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
// GameStream plane (set by RTSP/nvhttp on the compat path).
let gs_launch = *st.app.launch.lock().unwrap_or_else(|e| e.into_inner());
// The RTSP-negotiated stream slot only exists in GameStream-featured builds (WP19); a
// native-only build never has a compat-plane stream to report.
#[cfg(feature = "gamestream")]
let gs_stream = *st.app.stream.lock().unwrap_or_else(|e| e.into_inner());
let gs_video = st.app.streaming.load(Ordering::SeqCst);
let gs_audio = st.app.audio_streaming.load(Ordering::SeqCst);
@@ -454,39 +457,41 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
fps: s.fps,
})
});
let stream = gs_stream
.map(|c| StreamInfo {
width: c.width,
height: c.height,
fps: c.fps,
bitrate_kbps: c.bitrate_kbps,
packet_size: c.packet_size as u32,
min_fec: c.min_fec,
codec: c.codec.into(),
// Transition latencies are traced on the native plane only (latency plan P0.1).
time_to_first_frame_ms: None,
last_resize_ms: None,
#[cfg(feature = "gamestream")]
let gs_stream_info = gs_stream.map(|c| StreamInfo {
width: c.width,
height: c.height,
fps: c.fps,
bitrate_kbps: c.bitrate_kbps,
packet_size: c.packet_size as u32,
min_fec: c.min_fec,
codec: c.codec.into(),
// Transition latencies are traced on the native plane only (latency plan P0.1).
time_to_first_frame_ms: None,
last_resize_ms: None,
});
#[cfg(not(feature = "gamestream"))]
let gs_stream_info: Option<StreamInfo> = None;
let stream = gs_stream_info.or_else(|| {
native.first().map(|s| StreamInfo {
width: s.width,
height: s.height,
fps: s.fps,
bitrate_kbps: s.bitrate_kbps,
// FEC/packetization are RTSP-negotiated (GameStream only); the native QUIC plane
// shards differently, so these are 0 (not applicable) for a native session.
packet_size: 0,
min_fec: 0,
codec: s.codec.into(),
time_to_first_frame_ms: (s.time_to_first_frame_ms > 0)
.then_some(s.time_to_first_frame_ms),
last_resize_ms: (s.last_resize_ms > 0).then_some(s.last_resize_ms),
})
.or_else(|| {
native.first().map(|s| StreamInfo {
width: s.width,
height: s.height,
fps: s.fps,
bitrate_kbps: s.bitrate_kbps,
// FEC/packetization are RTSP-negotiated (GameStream only); the native QUIC plane
// shards differently, so these are 0 (not applicable) for a native session.
packet_size: 0,
min_fec: 0,
codec: s.codec.into(),
time_to_first_frame_ms: (s.time_to_first_frame_ms > 0)
.then_some(s.time_to_first_frame_ms),
last_resize_ms: (s.last_resize_ms > 0).then_some(s.last_resize_ms),
})
});
});
Json(RuntimeStatus {
video_streaming: gs_video || !native.is_empty(),
audio_streaming: gs_audio || !native.is_empty(),
pin_pending: st.app.pairing.pin.awaiting_pin(),
pin_pending: gs_pin_pending(&st),
paired_clients: st
.app
.paired
@@ -575,7 +580,7 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
.unwrap_or_else(|e| e.into_inner())
.len() as u32,
native_paired_clients,
pin_pending: st.app.pairing.pin.awaiting_pin(),
pin_pending: gs_pin_pending(&st),
pending_approvals,
kept_displays: crate::vdisplay::registry::snapshot()
.displays
@@ -594,3 +599,15 @@ pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<
.collect(),
})
}
/// Whether the GameStream PIN flow is parked waiting for a PIN — `false` by construction in a
/// native-only build (WP19), where the pairing machinery does not exist. The API field stays so
/// the schema (and every console) is identical across build flavors.
#[cfg(feature = "gamestream")]
fn gs_pin_pending(st: &Arc<MgmtState>) -> bool {
st.app.pairing.pin.awaiting_pin()
}
#[cfg(not(feature = "gamestream"))]
fn gs_pin_pending(_st: &Arc<MgmtState>) -> bool {
false
}
+11
View File
@@ -231,6 +231,17 @@ pub(crate) async fn unpair_native_client(
};
match np.remove(&fingerprint) {
Ok(true) => {
// Revocation reaches a LIVE session too: without this, a mid-stream client kept
// streaming after its pairing was removed, until it chose to disconnect.
let stopped =
crate::session_status::stop_by_fingerprint(&fingerprint.to_ascii_lowercase());
if stopped > 0 {
tracing::info!(
fingerprint,
stopped,
"unpair: live native session(s) stopped"
);
}
tracing::info!(fingerprint, "management API: native client unpaired");
StatusCode::NO_CONTENT.into_response()
}
+142 -25
View File
@@ -3,8 +3,10 @@
use super::*;
use crate::encode::Codec;
#[cfg(feature = "gamestream")]
use crate::gamestream::cert::ServerIdentity;
use crate::gamestream::tls::{PeerAddr, PeerCertFingerprint};
use crate::gamestream::{cert::ServerIdentity, Host, LaunchSession, HTTPS_PORT, HTTP_PORT};
use crate::gamestream::{Host, LaunchSession, HTTPS_PORT, HTTP_PORT};
use axum::body::Body;
use axum::http::StatusCode;
use http_body_util::BodyExt;
@@ -32,8 +34,15 @@ fn test_state() -> Arc<AppState> {
os_chain: "linux/arch/steamos".into(),
os_name: "SteamOS".into(),
};
let identity = ServerIdentity::ephemeral().expect("ephemeral identity");
Arc::new(AppState::new(host, identity, test_stats()))
#[cfg(feature = "gamestream")]
{
let identity = ServerIdentity::ephemeral().expect("ephemeral identity");
Arc::new(AppState::new(host, identity, test_stats()))
}
#[cfg(not(feature = "gamestream"))]
{
Arc::new(AppState::new(host, test_stats()))
}
}
// The mgmt API now always requires auth, so the router always has a token. A test that passes
@@ -638,10 +647,10 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
assert_eq!(send(&app, req).await.0, StatusCode::NO_CONTENT);
// The carve-outs answer 403 (authenticated but not authorized), not 401.
for (method, path) in [
#[cfg_attr(not(feature = "gamestream"), allow(unused_mut))]
let mut carveouts = vec![
(Method::GET, "/api/v1/hooks"),
(Method::PUT, "/api/v1/hooks"),
(Method::GET, "/api/v1/pair"),
(Method::POST, "/api/v1/native/pair/arm"),
(Method::GET, "/api/v1/native/pending"),
(Method::DELETE, "/api/v1/clients/aabbcc"),
@@ -652,7 +661,11 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
(Method::POST, "/api/v1/store/uninstall"),
(Method::POST, "/api/v1/store/runtime"),
(Method::PUT, "/api/v1/store/sources/evil"),
] {
];
// The PIN route only exists in GameStream-featured builds (WP19).
#[cfg(feature = "gamestream")]
carveouts.push((Method::GET, "/api/v1/pair"));
for (method, path) in carveouts {
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
assert!(body["error"].as_str().unwrap().contains("plugin token"));
@@ -761,13 +774,38 @@ async fn status_reflects_runtime_state() {
assert!(!body.to_string().contains("gcm"));
}
// Holding `CONFIG_DIR_TEST_LOCK` across the awaits is the POINT: the env override must cover
// the whole test body, and `#[tokio::test]` is a single-threaded runtime — nothing else can
// need the executor while we hold it.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn paired_clients_list_and_unpair() {
// Unpair PERSISTS (save_paired → paired.json in the config dir), so point the config dir
// at a throwaway tempdir — this test must never rewrite the dev box's real pairing store.
// The guard restores the previous value even if an assertion below panics.
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("PUNKTFUNK_CONFIG_DIR", v),
None => std::env::remove_var("PUNKTFUNK_CONFIG_DIR"),
}
}
}
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR"));
std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path());
let state = test_state();
let app = test_app(state.clone(), None);
// Pin the host's own cert DER as a stand-in client.
let (_, pem) = x509_parser::pem::parse_x509_pem(state.identity.cert_pem.as_bytes()).unwrap();
// Pin a throwaway cert DER as a stand-in client (the native ephemeral identity — CN
// "punktfunk" — so this works in both build flavors; WP19).
let stand_in = crate::identity::ephemeral().unwrap();
let (_, pem) = x509_parser::pem::parse_x509_pem(stand_in.cert_pem.as_bytes()).unwrap();
let der = pem.contents.clone();
let fingerprint = hex::encode(Sha256::digest(&der));
// Isolate from any real paired store on the dev box: AppState::new loads
@@ -790,6 +828,27 @@ async fn paired_clients_list_and_unpair() {
.unwrap();
assert_eq!(send(&app, bad).await.0, StatusCode::BAD_REQUEST);
// A LIVE session owned by this client: unpair is a revocation, so it must END the session,
// not just delist the cert — before this, a mid-stream client kept streaming after unpair
// until it chose to leave.
{
use std::sync::atomic::Ordering;
// owner_fp is the sha256 of the cert DER — exactly the bytes `fingerprint` encodes.
let mut owner = [0u8; 32];
owner.copy_from_slice(&hex::decode(&fingerprint).unwrap());
state.streaming.store(true, Ordering::SeqCst);
*state.launch.lock().unwrap() = Some(LaunchSession {
gcm_key: [0; 16],
rikeyid: 0,
width: 1920,
height: 1080,
fps: 60,
appid: 1,
peer_ip: None,
owner_fp: Some(owner),
});
}
// Unpair (uppercase hex must match too) → 204, list empties, second delete → 404.
let del = |fp: String| {
axum::http::Request::delete(format!("/api/v1/clients/{fp}"))
@@ -800,11 +859,33 @@ async fn paired_clients_list_and_unpair() {
send(&app, del(fingerprint.to_uppercase())).await.0,
StatusCode::NO_CONTENT
);
{
use std::sync::atomic::Ordering;
assert!(
state.launch.lock().unwrap().is_none(),
"unpair must end the revoked client's live session"
);
assert!(!state.streaming.load(Ordering::SeqCst));
assert!(
state.quit.load(Ordering::SeqCst),
"the teardown is deliberate (quit), not a drop"
);
}
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert_eq!(body, serde_json::json!([]));
assert_eq!(send(&app, del(fingerprint)).await.0, StatusCode::NOT_FOUND);
// The unpair persisted: paired.json in the (test-scoped) config dir holds the emptied
// list — a restart must not resurrect the pairing (it would re-open the control port).
// (`PUNKTFUNK_CONFIG_DIR` is used verbatim — no `punktfunk` subdirectory appended.)
let disk = std::fs::read(tmp.path().join("paired.json")).expect("unpair persisted paired.json");
assert_eq!(
serde_json::from_slice::<Vec<Vec<u8>>>(&disk).unwrap(),
Vec::<Vec<u8>>::new()
);
}
#[cfg(feature = "gamestream")]
#[tokio::test]
async fn submit_pin_validates_and_requires_pending_pairing() {
let app = test_app(test_state(), None);
@@ -856,9 +937,16 @@ async fn blank_token_rejected() {
token: Some(" ".into()),
plugin_token: None,
};
let err = run(test_state(), opts, None, test_stats(), false)
.await
.unwrap_err();
let err = run(
test_state(),
opts,
None,
test_stats(),
false,
crate::identity::ephemeral().unwrap(),
)
.await
.unwrap_err();
assert!(err.to_string().contains("no token"), "{err}");
}
@@ -1253,6 +1341,15 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
.join("/")
}
// The GameStream PIN routes exist only in gamestream-featured builds (WP19) — drop their
// rows from the expectation when the feature is off (`cfg!` keeps both sides type-checked).
let expected: Vec<(&str, &str, bool, bool)> = EXPECTED
.iter()
.copied()
.filter(|(_, p, _, _)| {
cfg!(feature = "gamestream") || !matches!(*p, "/api/v1/pair" | "/api/v1/pair/pin")
})
.collect();
let doc: serde_json::Value = serde_json::from_str(&openapi_json()).unwrap();
let mut live: Vec<(String, String)> = Vec::new();
for (path, ops) in doc["paths"].as_object().unwrap() {
@@ -1266,7 +1363,7 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
// 1. Every LIVE route has a classification row. A new route fails here until it gets one.
for (method, path) in &live {
assert!(
EXPECTED
expected
.iter()
.any(|(m, p, _, _)| m == method && p == path),
"route {method} {path} has no lane classification — add a row to EXPECTED in this test \
@@ -1275,14 +1372,14 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
);
}
// 2. No STALE rows: a removed route must not leave a classification behind claiming coverage.
for (method, path, _, _) in EXPECTED {
for (method, path, _, _) in &expected {
assert!(
live.iter().any(|(m, p)| m == method && p == path),
"EXPECTED lists {method} {path}, which is not in the live route table — remove the row"
);
}
// 3. The gates agree with the classification, on both lanes.
for (method, path, plugin_ok, cert_ok) in EXPECTED {
for (method, path, plugin_ok, cert_ok) in &expected {
let m = Method::from_bytes(method.as_bytes()).unwrap();
let concrete = concrete(path);
assert_eq!(
@@ -1338,7 +1435,10 @@ fn plugin_allowlist_matches_whole_segments_only() {
}
/// The OpenAPI document lists every route with a unique operationId (codegen relies
/// on both), and the checked-in copy is current.
/// on both), and the checked-in copy is current. Feature-gated: `api/openapi.json` IS the
/// default-features document — a native-only build's spec (no PIN routes) is intentionally
/// different and not checked in (WP19).
#[cfg(feature = "gamestream")]
#[test]
fn openapi_document_is_complete_and_checked_in() {
let json = openapi_json();
@@ -1726,7 +1826,10 @@ async fn gpu_endpoints_list_and_validate() {
async fn logs_endpoint_pages_by_cursor() {
let app = test_app(test_state(), None);
// The ring is a process-wide singleton — start from wherever its cursor currently is.
// The ring is a process-wide singleton — start from wherever its cursor currently is. Other
// tests in this binary legitimately log (e.g. the identity tests' adopt/migrate lines), so a
// page can carry THEIR entries interleaved with ours: assert on OUR markers within the page,
// never on the page being exactly ours (that raced once and failed the suite).
let (s, json) = send(&app, get_req("/api/v1/logs")).await;
assert_eq!(s, StatusCode::OK);
let start = json["next"].as_u64().unwrap();
@@ -1738,18 +1841,32 @@ async fn logs_endpoint_pages_by_cursor() {
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={start}"))).await;
assert_eq!(s, StatusCode::OK);
let entries = json["entries"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["msg"], "first");
assert_eq!(entries[0]["level"], "WARN");
assert_eq!(json["next"].as_u64().unwrap(), start + 2);
let ours: Vec<_> = entries
.iter()
.filter(|e| e["target"] == "mgmt::tests")
.collect();
assert_eq!(ours.len(), 2, "both markers on the page, in order");
assert_eq!(ours[0]["msg"], "first");
assert_eq!(ours[0]["level"], "WARN");
assert_eq!(ours[1]["msg"], "second");
let next = json["next"].as_u64().unwrap();
assert_eq!(
next,
start + entries.len() as u64,
"the cursor advances by exactly the entries served"
);
assert_eq!(json["dropped"], false);
// Nothing newer → empty page, cursor unchanged.
let after = start + 2;
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={after}"))).await;
// Nothing newer than the served cursor at the time we ask — the page may again carry a
// concurrent test's fresh entries, but never our (already-served) markers a second time.
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={next}"))).await;
assert_eq!(s, StatusCode::OK);
assert!(json["entries"].as_array().unwrap().is_empty());
assert_eq!(json["next"].as_u64().unwrap(), after);
assert!(json["entries"]
.as_array()
.unwrap()
.iter()
.all(|e| e["target"] != "mgmt::tests"));
assert!(json["next"].as_u64().unwrap() >= next);
}
// ------------------------------------------------------------------ events (SSE)
+32 -6
View File
@@ -216,8 +216,32 @@ pub fn run(opts: Punktfunk1Options) -> Result<()> {
// (harmless — the loops' `is_armed()` gate is always false). The unified `serve` shares one
// recorder across mgmt + both streaming paths instead.
let stats = StatsRecorder::new(crate::stats_recorder::default_dir());
// Standalone runs resolve the native identity themselves (the unified `serve` resolves it
// once for both planes — see `crate::identity::load_or_adopt`'s once-per-process note).
let ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
// Standalone `punktfunk1-host` runs no management API, so advertise no `mgmt` port (0).
rt.block_on(serve(opts, 0, np, stats))
rt.block_on(serve(opts, 0, np, stats, ident))
}
/// [`run`] with a throwaway in-memory identity — for the in-process tests, which must never
/// read or (worse) MINT identity files in the machine's real config dir: on a dev box that is
/// also a live host, a test-minted `native-cert.pem` would be adopted by the real host at its
/// next restart and strand every pinned client.
#[cfg(test)]
fn run_ephemeral(opts: Punktfunk1Options) -> Result<()> {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.context("tokio runtime")?;
let np = Arc::new(NativePairing::load_with(
opts.paired_store.clone(),
opts.pairing_pin.clone(),
opts.allow_pairing || opts.require_pairing,
)?);
let stats = StatsRecorder::new(crate::stats_recorder::default_dir());
let ident = crate::identity::ephemeral()?;
rt.block_on(serve(opts, 0, np, stats, ident))
}
fn fingerprint_hex(fp: &[u8; 32]) -> String {
@@ -289,9 +313,10 @@ pub(crate) async fn serve(
mgmt_port: u16,
np: Arc<NativePairing>,
stats: Arc<StatsRecorder>,
// The identity split (`crate::identity`): P-256 on hosts no native client ever pinned, the
// legacy RSA cert otherwise — resolved by the caller so the planes cannot race adoption.
identity: crate::identity::NativeIdentity,
) -> Result<()> {
let identity = crate::gamestream::cert::ServerIdentity::load_or_create()
.context("load host identity (~/.config/punktfunk)")?;
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
.map_err(|e| anyhow!("cert fingerprint: {e}"))?;
let ep = endpoint::server_with_identity_idle(
@@ -2220,7 +2245,7 @@ mod tests {
use punktfunk_core::error::PunktfunkStatus;
let host = std::thread::spawn(|| {
run(Punktfunk1Options {
run_ephemeral(Punktfunk1Options {
port: 19777,
source: Punktfunk1Source::Synthetic,
seconds: 0,
@@ -2421,7 +2446,7 @@ mod tests {
std::env::set_var("PUNKTFUNK_CLIPBOARD", "1");
let host = std::thread::spawn(|| {
run(Punktfunk1Options {
run_ephemeral(Punktfunk1Options {
port: 19781,
source: Punktfunk1Source::Synthetic,
seconds: 0,
@@ -2573,6 +2598,7 @@ mod tests {
StatsRecorder::new(
std::env::temp_dir().join(format!("pf-approval-stats-{}", std::process::id())),
),
crate::identity::ephemeral().unwrap(),
))
});
std::thread::sleep(std::time::Duration::from_millis(500));
@@ -2659,7 +2685,7 @@ mod tests {
use punktfunk_core::quic::endpoint;
let host = std::thread::spawn(|| {
run(Punktfunk1Options {
run_ephemeral(Punktfunk1Options {
port: 19778,
source: Punktfunk1Source::Synthetic,
seconds: 0,

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