Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc70a58fb1 | ||
|
|
ce25aca7bd | ||
|
|
5587699a85 | ||
|
|
c946fcdcb5 | ||
|
|
fcf4c9fd63 | ||
|
|
6ca192b9ab | ||
|
|
022ede651f | ||
|
|
9c6e06d3b9 | ||
|
|
1009e14a44 | ||
|
|
e658ad726b | ||
|
|
23d0452157 | ||
|
|
13d5721049 | ||
|
|
d4366e7464 | ||
|
|
cd72f77a3c | ||
|
|
cd3f5474bf | ||
|
|
972af2992f | ||
|
|
df6f270e7b | ||
|
|
27f0834025 | ||
|
|
db6683a585 | ||
|
|
7ffafb5ef3 | ||
|
|
4b686f026a | ||
|
|
d6132f7523 | ||
|
|
dc4d8d6832 | ||
|
|
8b98d0b3ec | ||
|
|
6b33750edc | ||
|
|
ef72d102b6 | ||
|
|
b2c03f1904 | ||
|
|
db65980979 | ||
|
|
a1ff0dde0c | ||
|
|
9d58f4c170 | ||
|
|
61ff543acc | ||
|
|
dd9bbaf1c5 |
@@ -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
|
||||
|
||||
|
||||
+121
@@ -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
|
||||
@@ -63,6 +155,35 @@ and `disable_environment` is then consulted last and wins on **presence alone**,
|
||||
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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (EGL→CUDA 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 (EGL→CUDA 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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), ¬hing_live());
|
||||
let b = m.resolve(&identity_key(fp(2), (1920, 1080), false), ¬hing_live());
|
||||
// per-client: mode ignored
|
||||
let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false), ¬hing_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), ¬hing_live());
|
||||
let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true), ¬hing_live());
|
||||
let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true), ¬hing_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), ¬hing_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), ¬hing_live());
|
||||
let id16 = m
|
||||
.resolve(&identity_key(fp(16), (1920, 1080), false), ¬hing_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), ¬hing_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
|
||||
|
||||
@@ -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 300–500 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
/// 1–16 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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4129,7 +4129,10 @@ fn build_pipeline_with_retry(
|
||||
// SteamOS: every gamescope bring-up burned the full 10 s on attempt 1, then attempt 2 got
|
||||
// frames instantly → 17 s bring-ups). Healthy compositors deliver the first frame well inside
|
||||
// this window (KWin ~0.3 s), and the genuinely-slow cold start above still gets the patient
|
||||
// 10 s window on every later attempt.
|
||||
// 10 s window on every later attempt. The truncated attempt is PROVISIONAL end to end: its
|
||||
// expiry must not latch the capturer's sticky downgrades (see
|
||||
// `Capturer::next_frame_within_provisional`) — only the full-length attempts hand down
|
||||
// negotiation verdicts.
|
||||
const FIRST_ATTEMPT_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(2500);
|
||||
let mut backoff = std::time::Duration::from_millis(500);
|
||||
for attempt in 1..=max_attempts {
|
||||
@@ -4484,7 +4487,13 @@ fn build_pipeline(
|
||||
}
|
||||
capturer.set_active(true);
|
||||
let first = match first_frame_budget {
|
||||
Some(budget) => capturer.next_frame_within(budget),
|
||||
// Provisional: this is the retry loop's deliberately truncated first attempt, and its
|
||||
// expiry is the schedule firing, not a negotiation verdict — the capturer must not latch
|
||||
// its sticky process-wide downgrades (HDR capture, the dmabuf-only offers) from it. A
|
||||
// gamescope cold start regularly outlives this window and then accepts every offer on the
|
||||
// full-length attempt that follows (observed on .41: one truncated expiry pinned the whole
|
||||
// host process to SDR + CPU capture).
|
||||
Some(budget) => capturer.next_frame_within_provisional(budget),
|
||||
None => capturer.next_frame(),
|
||||
};
|
||||
let frame = match first.context("first frame") {
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
//! The task itself runs as **`NT AUTHORITY\LocalService`**, not SYSTEM: plugins are
|
||||
//! operator-installed code, and a plugin defect must cost a throwaway service account, not the
|
||||
//! most privileged principal on the box. `enable` converges the principal (migrating tasks an
|
||||
//! older installer registered as SYSTEM) and grants LocalService read on exactly the two files
|
||||
//! the runner's `connect()` needs — the scoped `plugin-token` and the TLS pin `cert.pem` — never
|
||||
//! the full-admin `mgmt-token`.
|
||||
//! older installer registered as SYSTEM) and grants LocalService read on exactly the files the
|
||||
//! runner's `connect()` needs — the scoped `plugin-token` and the TLS-pin cert
|
||||
//! (`native-cert.pem` on identity-split hosts, `cert.pem` on legacy ones) — never the
|
||||
//! full-admin `mgmt-token`.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::process::Command;
|
||||
@@ -405,11 +406,13 @@ fn systemctl_output(args: &[&str]) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
const LOCAL_SERVICE_SID: &str = "*S-1-5-19";
|
||||
|
||||
/// The two (and only two) secrets the runner needs to read to reach the mgmt API: the scoped
|
||||
/// plugin token and the host identity cert it pins TLS against. `mgmt-token` (full admin) is
|
||||
/// deliberately NOT here.
|
||||
/// The secrets the runner needs to read to reach the mgmt API: the scoped plugin token and the
|
||||
/// host identity cert it pins TLS against — `native-cert.pem` when the identity split minted
|
||||
/// one (what mgmt then serves), `cert.pem` on legacy hosts. `mgmt-token` (full admin) is
|
||||
/// deliberately NOT here. The grant loop tolerates absent files, so listing both is safe on
|
||||
/// either kind of host.
|
||||
#[cfg(target_os = "windows")]
|
||||
const RUNNER_SECRET_FILES: [&str; 2] = ["plugin-token", "cert.pem"];
|
||||
const RUNNER_SECRET_FILES: [&str; 3] = ["plugin-token", "native-cert.pem", "cert.pem"];
|
||||
|
||||
/// The unit directories the runner imports code from. LocalService gets an inheritable
|
||||
/// read+execute+write-attributes grant on these: bun's module loader opens unit files
|
||||
|
||||
@@ -337,6 +337,24 @@ pub fn stop_all() {
|
||||
/// end-game-on-session-end policy sees an intent rather than a network drop. (Before this, a
|
||||
/// management stop was indistinguishable from a client vanishing, which left the display lingering
|
||||
/// for a session nobody was coming back to.)
|
||||
/// Signals the live native sessions belonging to `fp_hex` (lowercase hex cert SHA-256) to tear
|
||||
/// down **deliberately** — the unpair path's revocation reaching a mid-stream client, which must
|
||||
/// not keep streaming just because it was already connected when its pairing was removed.
|
||||
/// Matching is by the registry's client label, which for every pairable client is the
|
||||
/// fingerprint's 12-hex-char prefix (an anonymous/TOFU session carries an IP label and never
|
||||
/// matches — it has no pairing to revoke). Returns how many sessions were signalled.
|
||||
pub fn stop_by_fingerprint(fp_hex: &str) -> usize {
|
||||
let mut n = 0;
|
||||
for s in registry().lock().unwrap().iter() {
|
||||
if s.client.len() == 12 && fp_hex.starts_with(s.client.as_str()) {
|
||||
s.quit.store(true, Ordering::SeqCst);
|
||||
s.stop.store(true, Ordering::SeqCst);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
pub fn stop_all_quit() {
|
||||
for s in registry().lock().unwrap().iter() {
|
||||
s.quit.store(true, Ordering::SeqCst);
|
||||
@@ -356,6 +374,42 @@ pub fn force_idr_all() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_session(client: &str) -> (LiveSessionGuard, Arc<AtomicBool>, Arc<AtomicBool>) {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let quit = Arc::new(AtomicBool::new(false));
|
||||
let guard = register(Registration {
|
||||
mode: Arc::new(AtomicU64::new(0)),
|
||||
bitrate_kbps: Arc::new(AtomicU32::new(20_000)),
|
||||
codec: Codec::H265,
|
||||
stop: stop.clone(),
|
||||
quit: quit.clone(),
|
||||
force_idr: Arc::new(AtomicBool::new(false)),
|
||||
client: client.into(),
|
||||
client_name: None,
|
||||
hdr: false,
|
||||
ttff_ms: Arc::new(AtomicU32::new(0)),
|
||||
last_resize_ms: Arc::new(AtomicU32::new(0)),
|
||||
game: None,
|
||||
});
|
||||
(guard, stop, quit)
|
||||
}
|
||||
|
||||
/// Unpair must revoke a LIVE session — matched by the client label (the fingerprint's
|
||||
/// 12-hex-char prefix), deliberately (quit + stop), and precisely: another client's session
|
||||
/// and an anonymous (IP-labelled) session stay untouched.
|
||||
#[test]
|
||||
fn stop_by_fingerprint_revokes_exactly_the_unpaired_client() {
|
||||
let fp = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899";
|
||||
let (_g1, stop1, quit1) = fake_session(&fp[..12]);
|
||||
let (_g2, stop2, _q2) = fake_session("112233445566"); // a different paired client
|
||||
let (_g3, stop3, _q3) = fake_session("192.168.1.50"); // anonymous: IP label, never matches
|
||||
|
||||
assert_eq!(stop_by_fingerprint(fp), 1);
|
||||
assert!(stop1.load(Ordering::SeqCst) && quit1.load(Ordering::SeqCst));
|
||||
assert!(!stop2.load(Ordering::SeqCst));
|
||||
assert!(!stop3.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
/// A Moonlight client's game has no live-session entry to hang off, so without the compat-plane
|
||||
/// slot it would be missing from `/status` entirely — the Dashboard would show a stream with no
|
||||
/// game while one was plainly running. Publishing must also be strictly scoped to the stream: the
|
||||
|
||||
@@ -273,7 +273,13 @@ fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
|
||||
/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box.
|
||||
fn load_pin() -> Option<[u8; 32]> {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?;
|
||||
let dir = punktfunk_config_dir()?;
|
||||
// The mgmt API presents the NATIVE identity when one exists (the identity split —
|
||||
// host `crate::identity`); `cert.pem` is the legacy/GameStream identity, still what mgmt
|
||||
// serves on hosts that predate the split. Prefer the native cert, fall back to legacy.
|
||||
let pem = std::fs::read(dir.join("native-cert.pem"))
|
||||
.or_else(|_| std::fs::read(dir.join("cert.pem")))
|
||||
.ok()?;
|
||||
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?;
|
||||
Some(punktfunk_core::tls::cert_fingerprint(der.as_ref()))
|
||||
}
|
||||
|
||||
@@ -144,19 +144,21 @@ stock [Moonlight](/docs/moonlight) clients, and needs **both** openers:
|
||||
```sh
|
||||
# ufw — CachyOS (and Ubuntu, once you enable ufw):
|
||||
sudo ufw allow punktfunk-native
|
||||
sudo ufw allow punktfunk-gamestream
|
||||
|
||||
# firewalld — Fedora-like spins (EndeavourOS, …):
|
||||
sudo firewall-cmd --reload # load the installed definitions
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Switched the host to **native-only** — dropped `--gamestream` with a
|
||||
`systemctl --user edit punktfunk-host` drop-in, or you run `punktfunk-host serve` by hand? Then open
|
||||
`punktfunk-native` alone and leave `punktfunk-gamestream` closed. `systemctl --user cat
|
||||
punktfunk-host` shows which one yours is.
|
||||
Enabled **GameStream/Moonlight compat** (`PUNKTFUNK_GAMESTREAM=1` in `host.env` — see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts)), or you pass
|
||||
`--gamestream` by hand? Then also open its service:
|
||||
|
||||
```sh
|
||||
sudo ufw allow punktfunk-gamestream # ufw
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
`punktfunk-native` opens the QUIC control port (UDP 9777), mDNS discovery and the mgmt/library API
|
||||
(TCP 47990); `punktfunk-gamestream` opens the fixed Moonlight ports — TCP 47984, 47989 and 48010,
|
||||
|
||||
@@ -157,21 +157,22 @@ binary path — so a host you enabled with `systemctl --user enable --now punktf
|
||||
```sh
|
||||
sudo firewall-cmd --reload # load the installed definitions
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Enabled **GameStream/Moonlight compat** (`PUNKTFUNK_GAMESTREAM=1` in `host.env` — see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts))? Then also:
|
||||
|
||||
```sh
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
`punktfunk-native` opens UDP 9777 (QUIC control), UDP 5353 (mDNS discovery) and TCP 47990 (the
|
||||
mgmt/library API — HTTPS + mTLS, read-only off loopback). `punktfunk-gamestream` opens the fixed
|
||||
Moonlight ports — TCP 47984, 47989 and 48010, UDP 47998–48000 — plus the same mDNS. The media
|
||||
**data plane** uses an ephemeral UDP port the client opens with a hole-punch, so there is nothing
|
||||
fixed to open for video.
|
||||
|
||||
Switched the host to **native-only** — dropped `--gamestream` with a
|
||||
`systemctl --user edit punktfunk-host` drop-in, or you run `punktfunk-host serve` by hand? Then add
|
||||
`punktfunk-native` alone and leave `punktfunk-gamestream` out. `systemctl --user cat punktfunk-host`
|
||||
shows which one yours is.
|
||||
|
||||
And if you want the web console reachable from another device, open **TCP 47992**:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -64,9 +64,9 @@ systemctl --user enable --now punktfunk-host
|
||||
journalctl --user -u punktfunk-host -f # watch it come up and print its identity fingerprint
|
||||
```
|
||||
|
||||
This unit runs `serve --gamestream`, so it serves stock [Moonlight](/docs/moonlight) clients as well
|
||||
as the native ones. For a native-only host, see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
|
||||
This unit runs the secure native-only host. To also serve stock [Moonlight](/docs/moonlight)
|
||||
clients, GameStream compat is opt-in (`PUNKTFUNK_GAMESTREAM=1` in `host.env`, trusted LANs only) —
|
||||
see [What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
|
||||
|
||||
A desktop-login host should also follow your session's lifetime, or restarting GNOME Shell leaves the
|
||||
host wired to a compositor that is gone — it keeps answering, and every session after that fails at
|
||||
|
||||
@@ -63,8 +63,9 @@ Punktfunk speaks two protocols over the same host:
|
||||
on imperfect networks, and it's what the [native clients](/docs/clients) (Apple, Linux, Windows,
|
||||
Android) use.
|
||||
|
||||
The native `punktfunk/1` plane runs by default (the secure default); add `--gamestream` and both planes
|
||||
serve from a single host process — Moonlight clients use GameStream, the native clients use punktfunk/1.
|
||||
The native `punktfunk/1` plane runs by default (the secure default); add `--gamestream` (or
|
||||
`PUNKTFUNK_GAMESTREAM=1` in `host.env` for a packaged install) and both planes serve from a single
|
||||
host process — Moonlight clients use GameStream, the native clients use punktfunk/1.
|
||||
|
||||
## Pairing and trust
|
||||
|
||||
|
||||
@@ -13,18 +13,21 @@ a browser, a smart TV, or any device without a native client.
|
||||
|
||||
## 1. Make sure the host is running with GameStream enabled
|
||||
|
||||
Moonlight needs the GameStream planes, which are **opt-in** on the host — but most installs already
|
||||
enable them:
|
||||
Moonlight needs the GameStream planes, which are **opt-in on every install route** (the default is
|
||||
the secure native-only host):
|
||||
|
||||
- **Linux packages, NixOS, SteamOS / Steam Deck** — already on: the shipped `punktfunk-host` user
|
||||
unit runs `serve --gamestream`. To turn it *off*, override `ExecStart` with a drop-in so a package
|
||||
upgrade doesn't undo it — see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts) — or set
|
||||
`services.punktfunk.host.gamestream = false` on NixOS, or re-run the Deck installer with
|
||||
`--no-gamestream`.
|
||||
- **Windows** — off unless you ticked the installer's *Enable GameStream (Moonlight) compatibility*
|
||||
checkbox. To turn it on afterwards, run this from an **elevated** prompt (`=off` puts it back) —
|
||||
see [Windows Host](/docs/windows-host):
|
||||
- **Linux packages (apt / dnf / pacman / sysext)** — add `PUNKTFUNK_GAMESTREAM=1` to
|
||||
`~/.config/punktfunk/host.env` and `systemctl --user restart punktfunk-host` — see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts). (Installs from before
|
||||
the opt-in change served Moonlight by default; an upgrade switches them to native-only until you
|
||||
set the knob.)
|
||||
- **NixOS** — set `services.punktfunk.host.gamestream = true;` (also opens the GameStream firewall
|
||||
ports).
|
||||
- **SteamOS / Steam Deck** — run the installer with `--gamestream` (re-running it is safe and
|
||||
keeps the rest of your config).
|
||||
- **Windows** — tick the installer's *Enable GameStream (Moonlight) compatibility* checkbox, or
|
||||
turn it on afterwards from an **elevated** prompt (`=off` puts it back) — see
|
||||
[Windows Host](/docs/windows-host):
|
||||
|
||||
```powershell
|
||||
punktfunk-host service install --gamestream=on
|
||||
|
||||
@@ -52,14 +52,15 @@ journalctl --user -u punktfunk-host -f # watch it come up and print its identi
|
||||
Once up, the host advertises itself on your local network, so clients find it by name. It works out
|
||||
where your compositor is by itself, so there is nothing to export.
|
||||
|
||||
That unit runs `serve --gamestream`: the native `punktfunk/1` plane **plus** the
|
||||
GameStream/Moonlight-compatible planes, so stock [Moonlight](/docs/moonlight) clients work too. Those
|
||||
extra planes pair over plain HTTP and belong on a trusted LAN only — for a native-only host, see
|
||||
That unit runs the **secure native-only host** (Punktfunk clients + the management API). Want stock
|
||||
[Moonlight](/docs/moonlight) clients too? GameStream compat is **opt-in** — add
|
||||
`PUNKTFUNK_GAMESTREAM=1` to `~/.config/punktfunk/host.env` and restart the unit (trusted LANs only;
|
||||
its pairing runs over plain HTTP) — see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
|
||||
|
||||
If the host runs a firewall (Fedora enables firewalld, CachyOS enables ufw), open its ports — the
|
||||
firewall step in your distro guide has the exact commands, for **both** `punktfunk-native` and
|
||||
`punktfunk-gamestream`, because the packaged unit serves both planes.
|
||||
firewall step in your distro guide has the exact commands: `punktfunk-native` always, plus
|
||||
`punktfunk-gamestream` only if you enabled Moonlight compat.
|
||||
|
||||
On **SteamOS** even that is done for you — the install script wrote its own `punktfunk-host` user
|
||||
service and started it (GameStream on by default there too; pass `--no-gamestream` to the install
|
||||
|
||||
@@ -9,36 +9,38 @@ desktop you log into and a fully headless box.
|
||||
|
||||
## What the unit starts
|
||||
|
||||
The bundled unit runs `serve --gamestream`, so it serves both the native `punktfunk/1` plane and
|
||||
stock [Moonlight](/docs/moonlight) clients. Every Linux package installs that unit as it ships and
|
||||
only rewrites the binary path, so a host you installed from apt, dnf, pacman or the Bazzite sysext
|
||||
has **GameStream on**. See what yours starts:
|
||||
The bundled unit runs `serve`: the **secure native-only host** — the `punktfunk/1` plane plus the
|
||||
management API. Stock [Moonlight](/docs/moonlight) (GameStream) support is **opt-in** on every
|
||||
install route: its pairing runs over plain HTTP and its legacy encryption is weaker (see
|
||||
[Security & Safe Use](/docs/security)), so it belongs on trusted LANs you chose to enable it on.
|
||||
See what yours starts:
|
||||
|
||||
```sh
|
||||
systemctl --user cat punktfunk-host
|
||||
```
|
||||
|
||||
For a **native-only** host (no GameStream — its pairing runs over plain HTTP and its legacy
|
||||
encryption is weaker; see [Security & Safe Use](/docs/security)), drop the flag. The packaged unit is
|
||||
a package file that an upgrade replaces, so override `ExecStart` with a drop-in rather than editing
|
||||
it:
|
||||
|
||||
```sh
|
||||
systemctl --user edit punktfunk-host
|
||||
```
|
||||
To serve stock Moonlight clients too, add one line to `~/.config/punktfunk/host.env` (the unit's
|
||||
`EnvironmentFile` — no drop-in or unit editing needed) and restart:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/punktfunk-host serve
|
||||
PUNKTFUNK_GAMESTREAM=1
|
||||
```
|
||||
|
||||
The empty `ExecStart=` is required — without it systemd adds a second command instead of replacing
|
||||
the first — and the path must match the one `systemctl --user cat` printed (the distro packages use
|
||||
`/usr/bin`). Save, then `systemctl --user restart punktfunk-host`.
|
||||
```sh
|
||||
systemctl --user restart punktfunk-host
|
||||
```
|
||||
|
||||
Windows is the other way round: an install from the setup `.exe` leaves GameStream **off** unless you
|
||||
tick it, and it is configured differently — see [Windows](#windows) below.
|
||||
Then also open the `punktfunk-gamestream` firewall service alongside `punktfunk-native` — the
|
||||
firewall step in your distro guide has the exact commands.
|
||||
|
||||
> **Upgrading?** Earlier releases baked `--gamestream` into the unit's `ExecStart`, so a packaged
|
||||
> host served Moonlight by default. The upgrade replaces that unit with the native-only one — if
|
||||
> you relied on Moonlight, add `PUNKTFUNK_GAMESTREAM=1` to `host.env` as above. (A hand-made
|
||||
> `systemctl --user edit` drop-in that sets its own `ExecStart` keeps winning either way —
|
||||
> `systemctl --user cat punktfunk-host` shows what is in effect.)
|
||||
|
||||
Windows works the same way: an install from the setup `.exe` leaves GameStream **off** unless you
|
||||
tick it, configured through its own mechanism — see [Windows](#windows) below.
|
||||
|
||||
## A. A desktop you log into
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ systemctl --user enable --now punktfunk-host
|
||||
journalctl --user -u punktfunk-host -f
|
||||
```
|
||||
|
||||
This unit runs `serve --gamestream`, so it serves stock [Moonlight](/docs/moonlight) clients as well
|
||||
as the native ones. For a native-only host, see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
|
||||
This unit runs the secure native-only host. To also serve stock [Moonlight](/docs/moonlight)
|
||||
clients, GameStream compat is opt-in (`PUNKTFUNK_GAMESTREAM=1` in `host.env`, trusted LANs only) —
|
||||
see [What the unit starts](/docs/running-as-a-service#what-the-unit-starts).
|
||||
|
||||
## Bring up the console and pair
|
||||
|
||||
|
||||
@@ -152,27 +152,27 @@ Ubuntu installs `ufw` but leaves it **inactive**, so out of the box
|
||||
there is nothing to open. If you did enable one — common on Ubuntu Server — the package ships the
|
||||
openers for both, because a package never edits your firewall itself.
|
||||
|
||||
The packaged unit runs `serve --gamestream` — the `.deb` installs it as it ships and only rewrites
|
||||
the binary path — so a host you enabled with `systemctl --user enable --now punktfunk-host` serves
|
||||
**both** the native `punktfunk/1` plane and stock [Moonlight](/docs/moonlight) clients, and needs
|
||||
**both** openers:
|
||||
The packaged unit runs the **secure native-only host** (`serve`, no GameStream), so a host you
|
||||
enabled with `systemctl --user enable --now punktfunk-host` needs only the native opener:
|
||||
|
||||
```sh
|
||||
# ufw:
|
||||
sudo ufw allow punktfunk-native
|
||||
sudo ufw allow punktfunk-gamestream
|
||||
|
||||
# firewalld:
|
||||
sudo firewall-cmd --reload # load the installed definitions
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-native
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
Switched the host to **native-only** — dropped `--gamestream` with a
|
||||
`systemctl --user edit punktfunk-host` drop-in, or you run `punktfunk-host serve` by hand? Then open
|
||||
`punktfunk-native` alone and leave `punktfunk-gamestream` closed. `systemctl --user cat
|
||||
punktfunk-host` shows which one yours is.
|
||||
Enabled **GameStream/Moonlight compat** (`PUNKTFUNK_GAMESTREAM=1` in `host.env` — see
|
||||
[What the unit starts](/docs/running-as-a-service#what-the-unit-starts)), or you pass
|
||||
`--gamestream` by hand? Then also open its service:
|
||||
|
||||
```sh
|
||||
sudo ufw allow punktfunk-gamestream # ufw
|
||||
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream && sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
`punktfunk-native` opens UDP 9777 (QUIC control), UDP 5353 (mDNS discovery) and TCP 47990 (the
|
||||
mgmt/library API — HTTPS + mTLS, read-only off loopback). `punktfunk-gamestream` opens the fixed
|
||||
|
||||
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
|
||||
# bump it with the marker so pacman sees a new version when only our patches moved.
|
||||
_gsver=3.16.25
|
||||
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
|
||||
pkgver="${_gsver}.pfhdr5"
|
||||
pkgver="${_gsver}.pfhdr6"
|
||||
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
|
||||
# `.pfhdrN` level deliberately stays put — see README.md.
|
||||
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
|
||||
@@ -33,6 +33,12 @@ pkgver="${_gsver}.pfhdr5"
|
||||
# a session on every capture renegotiation — i.e. on every client connect, since the host sets the
|
||||
# session to the client's mode. This one DOES move `.pfhdrN`, even though it adds no capability:
|
||||
# every deployed pfhdr4 binary crash-loops, so an operator has to be able to tell them apart.
|
||||
#
|
||||
# pfhdr6 / rel 1: patch 0008 honors GAMESCOPE_NO_FOCUS — a mapped-but-unpainted window carrying it
|
||||
# (Bazzite's hhd-ui crash-looping under a headless takeover) used to WIN focus selection and turn
|
||||
# the composite (and the stream) black while every health signal stayed green. No capability the
|
||||
# host probes for, but a field box's banner has to distinguish a build that can lose its composite
|
||||
# this way from one that cannot.
|
||||
pkgrel=1
|
||||
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
@@ -18,6 +18,7 @@ The patches here add the missing half, and nothing else. See
|
||||
| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream |
|
||||
| `0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch` | Give `g_device` and `g_output` storage that is never destroyed, so their destructors cannot call a Vulkan driver glibc has already unloaded at `exit()` | **Yes** — a plain static-destruction-order bug, not punktfunk-specific |
|
||||
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
|
||||
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
|
||||
|
||||
### Why the headless patch matters
|
||||
|
||||
@@ -84,14 +85,18 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
|
||||
| `+pfhdr2` | …and `--pipewire-composite-cursor` |
|
||||
| `+pfhdr3` | …and the headless connector advertises its mode + `--custom-refresh-rates` |
|
||||
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
|
||||
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
|
||||
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
|
||||
|
||||
Bump it whenever a patch adds or changes something the host must know about before it spawns.
|
||||
|
||||
A patch that only fixes a crash does **not** bump it: `0006` (the exit-time Vulkan teardown fix)
|
||||
changes nothing the host probes for, so the level stays `+pfhdr4` and the rebuild ships as a
|
||||
`pkgrel` bump instead — exactly the split the PKGBUILD's own comment describes. Bumping the level
|
||||
for a bugfix would be worse than useless: it would advertise a capability tier that does not exist
|
||||
and strand hosts that gate on it.
|
||||
A patch that only fixes a crash does **not** automatically bump it: `0006` (the exit-time Vulkan
|
||||
teardown fix) changes nothing the host probes for, so it shipped as a `pkgrel` bump at `+pfhdr4` —
|
||||
exactly the split the PKGBUILD's own comment describes. Since every host probe is `>=`, a bump for
|
||||
a bugfix is safe but must earn its place: `0007` and `0008` moved the level anyway because their
|
||||
absence is invisible until a stream fails (a crash-loop per connect; a composite lost to a
|
||||
NO_FOCUS window), so field triage has to be able to read the difference off a box's banner.
|
||||
Bumping without either reason would advertise a capability tier that does not exist.
|
||||
|
||||
⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the
|
||||
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: enricobuehler <enrico.buehler@unom.io>
|
||||
Date: Tue, 11 Aug 2026 21:48:29 +0200
|
||||
Subject: [PATCH] =?UTF-8?q?steamcompmgr:=20honor=20GAMESCOPE=5FNO=5FFOCUS?=
|
||||
=?UTF-8?q?=20=E2=80=94=20such=20windows=20are=20never=20focus=20candidate?=
|
||||
=?UTF-8?q?s?=
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
hhd (Handheld Daemon) sets GAMESCOPE_NO_FOCUS=1 on its hhd-ui overlay window once at init and
|
||||
never clears it (hhd src/hhd/plugins/overlay/x11.py, prepare_hhd); MangoHud sets the same atom.
|
||||
The show/hide protocol for these clients is STEAM_OVERLAY / STEAM_INPUT_FOCUS — the window is
|
||||
never meant to win focus selection on its own.
|
||||
|
||||
Nothing consumed the atom: neither this tree nor Bazzite's fork (checked ba148) interns it, 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 a perfectly ordinary focus candidate.
|
||||
steamcompmgr picked it over Big Picture, and the composite (and the stream fed from it) went
|
||||
black while every other health signal stayed green (observed on Bazzite .41, 2026-08-11:
|
||||
GAMESCOPE_FOCUSED_WINDOW = the hhd-ui window, GAMESCOPE_NO_FOCUS(CARDINAL)=1 on that window,
|
||||
client stats 60 fps at 0.1 Mb/s of black; killing hhd-ui flipped focus back to Steam and the
|
||||
picture returned instantly).
|
||||
|
||||
Wire the atom exactly like GAMESCOPE_EXTERNAL_OVERLAY — read at map, tracked on PropertyNotify
|
||||
(with MakeFocusDirty), skipped in both focus-candidate collectors (X11 and XDG). Unlike the
|
||||
overlay flags it does NOT zero appID and does not change compositing: the window still paints
|
||||
normally if something else (the baselayer protocol) brings it into view; it is only barred from
|
||||
being CHOSEN.
|
||||
|
||||
Banner: +pfhdr6 (no new capability — the bump exists so a field box's banner distinguishes a
|
||||
build that can lose its composite to a NO_FOCUS window from one that cannot).
|
||||
---
|
||||
src/meson.build | 3 ++-
|
||||
src/steamcompmgr.cpp | 24 ++++++++++++++++++++----
|
||||
src/steamcompmgr_shared.hpp | 4 ++++
|
||||
src/xwayland_ctx.hpp | 1 +
|
||||
4 files changed, 27 insertions(+), 5 deletions(-)
|
||||
|
||||
diff --git a/src/meson.build b/src/meson.build
|
||||
index 9a3a287..acfcaea 100644
|
||||
--- a/src/meson.build
|
||||
+++ b/src/meson.build
|
||||
@@ -185,7 +185,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
|
||||
# +pfhdr3 — …and the headless connector advertises its mode + `--custom-refresh-rates`
|
||||
# +pfhdr4 — …and `--pipewire-composite-external-overlay`
|
||||
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
|
||||
-version_tag = vcs_tag + '+pfhdr5' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||
+# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
|
||||
+version_tag = vcs_tag + '+pfhdr6' + ' (' + compiler_name + ' ' + compiler_version + ')'
|
||||
|
||||
gamescope_version_conf = configuration_data()
|
||||
gamescope_version_conf.set('VCS_TAG', version_tag)
|
||||
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
||||
index 64e1a8c..14596ae 100644
|
||||
--- a/src/steamcompmgr.cpp
|
||||
+++ b/src/steamcompmgr.cpp
|
||||
@@ -1109,6 +1109,7 @@ bool g_bPendingFade = false;
|
||||
#define STEAM_PROP "STEAM_BIGPICTURE"
|
||||
#define OVERLAY_PROP "STEAM_OVERLAY"
|
||||
#define EXTERNAL_OVERLAY_PROP "GAMESCOPE_EXTERNAL_OVERLAY"
|
||||
+#define NO_FOCUS_PROP "GAMESCOPE_NO_FOCUS"
|
||||
#define GAMES_RUNNING_PROP "STEAM_GAMES_RUNNING"
|
||||
#define SCREEN_SCALE_PROP "STEAM_SCREEN_SCALE"
|
||||
#define SCREEN_MAGNIFICATION_PROP "STEAM_SCREEN_MAGNIFICATION"
|
||||
@@ -3848,8 +3849,8 @@ found:;
|
||||
|
||||
for (steamcompmgr_win_t *w = this->list; w; w = w->xwayland().next)
|
||||
{
|
||||
- // Always skip system tray icons and overlays
|
||||
- if ( w->isSysTrayIcon || w->isOverlay || w->isExternalOverlay )
|
||||
+ // Always skip system tray icons, overlays, and windows that asked never to be focused
|
||||
+ if ( w->isSysTrayIcon || w->isOverlay || w->isExternalOverlay || w->isNoFocus )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -4197,8 +4198,8 @@ steamcompmgr_xdg_get_possible_focus_windows()
|
||||
std::vector< steamcompmgr_win_t* > windows;
|
||||
for ( auto &win : g_steamcompmgr_xdg_wins )
|
||||
{
|
||||
- // Always skip system tray icons and overlays
|
||||
- if ( win->isSysTrayIcon || win->isOverlay || win->isExternalOverlay )
|
||||
+ // Always skip system tray icons, overlays, and windows that asked never to be focused
|
||||
+ if ( win->isSysTrayIcon || win->isOverlay || win->isExternalOverlay || win->isNoFocus )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -4960,6 +4961,10 @@ map_win(xwayland_ctx_t* ctx, Window id, unsigned long sequence)
|
||||
if ( w->isExternalOverlay )
|
||||
w->appID = 0;
|
||||
|
||||
+ // Never a focus candidate; appID stays — the window may share the focused app's id (hhd-ui
|
||||
+ // stamps Steam's) and zeroing it here is not needed to keep it out of focus selection.
|
||||
+ w->isNoFocus = get_prop(ctx, w->xwayland().id, ctx->atoms.noFocusAtom, 0);
|
||||
+
|
||||
w->oulTargetVROverlay = get_u64_prop(ctx, w->xwayland().id, ctx->atoms.steamGamescopeVROverlayTarget);
|
||||
if ( w->oulTargetVROverlay )
|
||||
{
|
||||
@@ -5243,6 +5248,7 @@ add_win(xwayland_ctx_t *ctx, Window id, Window prev, unsigned long sequence)
|
||||
|
||||
new_win->isOverlay = false;
|
||||
new_win->isExternalOverlay = false;
|
||||
+ new_win->isNoFocus = false;
|
||||
new_win->isSteamLegacyBigPicture = false;
|
||||
new_win->isSteamStreamingClient = false;
|
||||
new_win->isSteamStreamingClientVideo = false;
|
||||
@@ -6193,6 +6199,15 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev)
|
||||
MakeFocusDirty();
|
||||
}
|
||||
}
|
||||
+ if (ev->atom == ctx->atoms.noFocusAtom)
|
||||
+ {
|
||||
+ steamcompmgr_win_t * w = find_win(ctx, ev->window);
|
||||
+ if (w)
|
||||
+ {
|
||||
+ w->isNoFocus = get_prop(ctx, w->xwayland().id, ctx->atoms.noFocusAtom, 0);
|
||||
+ MakeFocusDirty();
|
||||
+ }
|
||||
+ }
|
||||
if (ev->atom == ctx->atoms.winTypeAtom)
|
||||
{
|
||||
steamcompmgr_win_t * w = find_win(ctx, ev->window);
|
||||
@@ -7927,6 +7942,7 @@ void init_xwayland_ctx(uint32_t serverId, gamescope_xwayland_server_t *xwayland_
|
||||
ctx->atoms.gameAtom = XInternAtom(ctx->dpy, GAME_PROP, false);
|
||||
ctx->atoms.overlayAtom = XInternAtom(ctx->dpy, OVERLAY_PROP, false);
|
||||
ctx->atoms.externalOverlayAtom = XInternAtom(ctx->dpy, EXTERNAL_OVERLAY_PROP, false);
|
||||
+ ctx->atoms.noFocusAtom = XInternAtom(ctx->dpy, NO_FOCUS_PROP, false);
|
||||
ctx->atoms.opacityAtom = XInternAtom(ctx->dpy, OPACITY_PROP, false);
|
||||
ctx->atoms.gamesRunningAtom = XInternAtom(ctx->dpy, GAMES_RUNNING_PROP, false);
|
||||
ctx->atoms.screenScaleAtom = XInternAtom(ctx->dpy, SCREEN_SCALE_PROP, false);
|
||||
diff --git a/src/steamcompmgr_shared.hpp b/src/steamcompmgr_shared.hpp
|
||||
index 21ddc5f..924e0d2 100644
|
||||
--- a/src/steamcompmgr_shared.hpp
|
||||
+++ b/src/steamcompmgr_shared.hpp
|
||||
@@ -116,6 +116,10 @@ struct steamcompmgr_win_t {
|
||||
uint32_t appID = 0;
|
||||
bool isOverlay = false;
|
||||
bool isExternalOverlay = false;
|
||||
+ // GAMESCOPE_NO_FOCUS on the window: the client asks never to be a focus candidate (hhd-ui and
|
||||
+ // MangoHud set it once at init). Unlike an overlay it still composites normally if something
|
||||
+ // else focuses it into view; it is only excluded from focus selection.
|
||||
+ bool isNoFocus = false;
|
||||
|
||||
bool bIsSteamPid = false;
|
||||
bool bIsSteamWebHelperPid = false;
|
||||
diff --git a/src/xwayland_ctx.hpp b/src/xwayland_ctx.hpp
|
||||
index 978728a..d6ce90f 100644
|
||||
--- a/src/xwayland_ctx.hpp
|
||||
+++ b/src/xwayland_ctx.hpp
|
||||
@@ -105,6 +105,7 @@ struct xwayland_ctx_t final : public gamescope::IWaitable
|
||||
Atom gameAtom;
|
||||
Atom overlayAtom;
|
||||
Atom externalOverlayAtom;
|
||||
+ Atom noFocusAtom;
|
||||
Atom gamesRunningAtom;
|
||||
Atom screenZoomAtom;
|
||||
Atom screenScaleAtom;
|
||||
--
|
||||
2.50.1 (Apple Git-155)
|
||||
|
||||
@@ -15,6 +15,10 @@ WAYLAND_DISPLAY=wayland-kde
|
||||
XDG_CURRENT_DESKTOP=KDE
|
||||
PUNKTFUNK_COMPOSITOR=kwin
|
||||
PUNKTFUNK_VIDEO_SOURCE=virtual
|
||||
# Stock-Moonlight (GameStream) compat is OPT-IN (the host is native-only by default). Uncomment
|
||||
# on a TRUSTED LAN if this appliance should serve Moonlight clients too (plain-HTTP pairing +
|
||||
# legacy GCM caveats — security-review #5/#9 — and the punktfunk-gamestream firewall ports).
|
||||
#PUNKTFUNK_GAMESTREAM=1
|
||||
# GPU zero-copy (dmabuf → CUDA → NVENC) is ON by default; auto-falls back to CPU. Set =0 to force CPU.
|
||||
PUNKTFUNK_INPUT_BACKEND=libei
|
||||
# UDP Generic Segmentation Offload on the send path: coalesce a frame's equal-size packets into
|
||||
|
||||
@@ -82,6 +82,9 @@ let
|
||||
services.punktfunk.host = {
|
||||
enable = true;
|
||||
users = [ "alice" ];
|
||||
# Explicit: gamestream defaults to false now (opt-in on every route) — this fixture
|
||||
# is the one that proves opting IN still reaches the argv and the firewall.
|
||||
gamestream = true;
|
||||
gamescopeHdr = false;
|
||||
desktopSession = true;
|
||||
};
|
||||
@@ -187,6 +190,14 @@ let
|
||||
&& !(lib.elem 47984 nativeOnly.networking.firewall.allowedTCPPorts)
|
||||
&& lib.elem 47990 nativeOnly.networking.firewall.allowedTCPPorts;
|
||||
}
|
||||
{
|
||||
# The DEFAULT is the secure native-only host — gamestream unset must behave like
|
||||
# gamestream=false (opt-in on every package route; the appliance fixture leaves it unset).
|
||||
name = "gamestream default (unset) is native-only";
|
||||
ok =
|
||||
!(has appliance "punktfunk-host" "--gamestream")
|
||||
&& !(lib.elem 47984 appliance.networking.firewall.allowedTCPPorts);
|
||||
}
|
||||
{
|
||||
name = "openFirewall opens the console AND its plugin origin";
|
||||
ok =
|
||||
|
||||
@@ -89,11 +89,13 @@ in
|
||||
|
||||
gamestream = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
default = false;
|
||||
description = ''
|
||||
Advertise the GameStream/Moonlight-compatible planes (`serve --gamestream`) so a stock
|
||||
Moonlight client can pair. Set to `false` for a native-only, more secure host (no
|
||||
plain-HTTP pairing / legacy GCM path) and drop the GameStream firewall ports.
|
||||
Moonlight client can pair, and open their firewall ports. OPT-IN (default `false`,
|
||||
matching every other package route): the compat planes carry plain-HTTP pairing and the
|
||||
legacy GCM-nonce path (security-review #5/#9), so the default is the secure native-only
|
||||
host — Punktfunk clients only. Enable only on a trusted LAN.
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
# Mode mid-stream then kills the stream instead of being followed), and stale session vars
|
||||
# point detection at dead sockets. Those knobs are for CI and dedicated appliances (below).
|
||||
|
||||
# GameStream/Moonlight compatibility — OPT-IN (the shipped unit runs the secure native-only
|
||||
# host). Set =1 so stock Moonlight clients can pair and stream; TRUSTED LANs only — the compat
|
||||
# planes carry plain-HTTP pairing + the legacy GCM-nonce path (security-review #5/#9), and need
|
||||
# their extra firewall ports (TCP 47984/47989/48010, UDP 47998-48000/48002/48010; the packages
|
||||
# ship a `punktfunk-gamestream` firewalld service / ufw profile for exactly this).
|
||||
#PUNKTFUNK_GAMESTREAM=1
|
||||
|
||||
# Video source (GameStream/Moonlight sessions only): `virtual` creates a per-client virtual
|
||||
# output at the client's exact resolution+refresh (the flagship mode, and the default);
|
||||
# `portal` captures an existing monitor.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# punktfunk streaming host — systemd USER unit (`serve --gamestream` = native punktfunk/1 + the
|
||||
# GameStream/Moonlight-compat planes). For a SECURE native-only host (no plain-HTTP pairing / legacy
|
||||
# GCM nonce reuse — security-review #5/#9; native clients only), drop `--gamestream` from ExecStart.
|
||||
# punktfunk streaming host — systemd USER unit. Runs the SECURE native-only host by default
|
||||
# (punktfunk/1 clients + the management API; no plain-HTTP pairing / legacy GCM nonce reuse —
|
||||
# security-review #5/#9). Stock-Moonlight (GameStream) compat is OPT-IN: add
|
||||
# PUNKTFUNK_GAMESTREAM=1
|
||||
# to ~/.config/punktfunk/host.env (the EnvironmentFile below) and restart — no unit editing
|
||||
# needed. Only do that on a TRUSTED LAN; it opens the compat planes' extra ports too.
|
||||
#
|
||||
# Install (against an already-running compositor session — the host auto-detects and follows it,
|
||||
# so host.env needs no backend config):
|
||||
@@ -35,7 +38,7 @@
|
||||
# unit (when present) just needs to be up by the time a client streams (seconds later, user-driven).
|
||||
# A missing After= unit (e.g. gamescope backend, no kde-session installed) is simply ignored.
|
||||
[Unit]
|
||||
Description=punktfunk GameStream + punktfunk/1 host
|
||||
Description=punktfunk streaming host (punktfunk/1; +GameStream when PUNKTFUNK_GAMESTREAM=1)
|
||||
After=pipewire.service punktfunk-kde-session.service
|
||||
# PartOf the kwin session (when present): the host's libei input uses the RemoteDesktop portal,
|
||||
# whose D-Bus connection goes stale if the compositor session restarts the portal under it — the
|
||||
@@ -52,7 +55,7 @@ PartOf=punktfunk-kde-session.service
|
||||
# `punktfunk-host serve` by hand already does. Contrast punktfunk-web.service, which marks
|
||||
# mgmt-token mandatory ON PURPOSE so Restart waits for the host to write it — this is not that.
|
||||
EnvironmentFile=-%h/.config/punktfunk/host.env
|
||||
ExecStart=%h/punktfunk/target/release/punktfunk-host serve --gamestream
|
||||
ExecStart=%h/punktfunk/target/release/punktfunk-host serve
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
# Run it on the Deck (Desktop Mode "Konsole", or over ssh). Idempotent — safe to re-run to update
|
||||
# config or pick up new options. To rebuild after pulling new source, use update.sh.
|
||||
#
|
||||
# bash scripts/steamdeck/install.sh # PIN pairing required; Moonlight compat ON
|
||||
# bash scripts/steamdeck/install.sh --no-gamestream # SECURE native-only (no Moonlight/#5/#9 surface)
|
||||
# bash scripts/steamdeck/install.sh # PIN pairing required; SECURE native-only
|
||||
# bash scripts/steamdeck/install.sh --gamestream # ALSO serve Moonlight clients (opt-in; #5/#9 caveats)
|
||||
# bash scripts/steamdeck/install.sh --open # trusted LAN: accept unpaired clients (TOFU)
|
||||
# bash scripts/steamdeck/install.sh --no-web # skip the management web console
|
||||
# PUNKTFUNK_SRC=~/src/punktfunk bash scripts/steamdeck/install.sh # source elsewhere
|
||||
@@ -39,12 +39,13 @@ MGMT_PORT="${PUNKTFUNK_MGMT_PORT:-47990}"
|
||||
WEB_PORT="${PUNKTFUNK_WEB_PORT:-47992}"
|
||||
OPEN=0
|
||||
WITH_WEB=1
|
||||
GAMESTREAM=1 # Moonlight/GameStream compat on by default; --no-gamestream for a secure native-only host
|
||||
GAMESTREAM=0 # SECURE native-only by default (opt-in on every route); --gamestream adds Moonlight compat
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--open) OPEN=1 ;;
|
||||
--no-web) WITH_WEB=0 ;;
|
||||
--no-gamestream) GAMESTREAM=0 ;;
|
||||
--gamestream) GAMESTREAM=1 ;;
|
||||
--no-gamestream) GAMESTREAM=0 ;; # explicit-off kept for old command lines / re-runs
|
||||
--src=*) SRC="${arg#--src=}" ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
*) die "unknown option: $arg (try --help)" ;;
|
||||
@@ -426,15 +427,15 @@ fi
|
||||
log "Installing systemd user services"
|
||||
mkdir -p "$UNITS"
|
||||
# The native punktfunk/1 plane is always on; --gamestream additionally enables the Moonlight-compat
|
||||
# planes (the Deck commonly streams to Moonlight too). --no-gamestream → secure native-only (no #5/#9
|
||||
# surface; native clients only).
|
||||
# planes — OPT-IN, matching every other install route (#5/#9 surface; the default is the secure
|
||||
# native-only host, native clients only).
|
||||
SERVE_ARGS="serve --mgmt-bind 0.0.0.0:$MGMT_PORT"
|
||||
[ "$GAMESTREAM" = 1 ] && SERVE_ARGS="$SERVE_ARGS --gamestream"
|
||||
[ "$OPEN" = 1 ] && SERVE_ARGS="$SERVE_ARGS --open"
|
||||
cat > "$UNITS/punktfunk-host.service" <<EOF
|
||||
# Generated by scripts/steamdeck/install.sh — punktfunk Steam Deck host (native binary).
|
||||
[Unit]
|
||||
Description=punktfunk host (GameStream + punktfunk/1)
|
||||
Description=punktfunk host (punktfunk/1; +GameStream when installed with --gamestream)
|
||||
After=pipewire.service
|
||||
|
||||
[Service]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user