995cac5d03a6e656120deed7448ae4e60cfe9f61
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
995cac5d03 |
fix(vdisplay): the three hardest FFI calls were exempt from the crate's own unsafe-proof rule
`#![deny(clippy::undocumented_unsafe_blocks)]` cannot see an unsafe operation that sits directly in an `unsafe fn` body — in edition 2021 `unsafe_op_in_unsafe_fn` is allow-by-default, so a bare call inside such a body needs no block and therefore carries no proof. The file headers claim "every unsafe block carries a SAFETY proof", and that was true; it just did not cover the calls that needed it most. Turning the lint on named exactly three: `DeviceIoControl` and the `ioctl` wrapper in pf_vdisplay.rs — the whole host<->driver control channel — and `restore_displays_ccd` in manager.rs, the call the teardown path depends on to give the operator their physical panels back. Each now carries a real proof, and `ioctl` and `set_render_adapter` grew the `# Safety` heading their callers were owed. Also teaches scripts/wincheck.sh to cover pf-vdisplay, which is what let the above be compile-verified rather than reasoned: the crate's Windows half is ~3,400 lines that Linux CI never compiles. That needed a stub pf-encode (the real one drags ffmpeg-sys, and the admission gate calls exactly one predicate from it) and CompositorPref on the stub core. Verified non-vacuous with a planted type error. pf-win-display's half of the same lint is a separate change — it flags 62 further operations, and they want proofs written one at a time, not in bulk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fd648aa776 |
feat(gamescope): put the cursor in the node, so a session can be zero-copy
gamescope keeps the pointer out of its PipeWire node — it lives on a hardware plane for scanout, and `paint_pipewire()` composites a separate, reduced frame that never includes it. So punktfunk has always reconstructed it from XFixes and blended it in host-side. That blend is what has been blocking the zero-CSC encode path, and the cost is larger than it sounds: `VulkanVideoEncoder::open` refuses the RGB-direct (EFC) source for any session with `cursor_blend`, because that front end is fixed-function and has no blend stage. `cursor_blend_for` sets it unconditionally for gamescope. Net effect: a gamescope session paid a full-frame colour-conversion pass per frame, forever, for a pointer. So put the cursor where it belongs. A second carried gamescope patch adds `--pipewire-composite-cursor` (off by default — the node has never carried it, and a consumer that draws its own would get two), painting it with the same `MouseCursor::paint` call the scanout composite uses. It scales for free: paint_pipewire has already set `currentOutputWidth/Height` to the capture size, which is what that function scales against. The repaint test grows the cursor's state beside the commit ids — a pointer-only move produces no commit, so without it the composited cursor would freeze on a static screen while the real one moved, and a cursor that became hidden would never be erased. Host side, the `+pfhdr` marker becomes a monotonic PATCH LEVEL, so one probe answers every capability the session must know before it is planned (level 1 = HDR formats, level 2 = the cursor flag). `cursor_blend_for` and the `gamescope_cursor` resolver both consult it through one helper, because they have to agree: the reader without the blend is a wasted X11 connection, the blend without the reader is a stream with no pointer, and both together with a gamescope that paints its own would draw two. ⚠ The two indirect spawn modes carry the flag through `PF_HDR_ARGS`, so this shares a dependency with the HDR flags: a session that ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope gets neither. HDR fails loudly there (negotiation timeout + SDR latch); a missing cursor would be silent. Noted in packaging/gamescope/README.md as worth a post-spawn `/proc/<pid>/cmdline` check if it ever bites. |
||
|
|
689297c86e |
feat(hdr): a gamescope session streams true HDR10, decided before the Welcome
The Linux native plane has been 8-bit for a reason that stopped being true: `capturer_supports_hdr()` returned a flat `false` because Mutter's virtual monitors are SDR-only upstream. That is still right for Mutter, KWin and wlroots — and wrong for gamescope, whose node can now offer 10-bit BT.2020 PQ (packaging/gamescope). Open the three gates that held it off, together: * the capture-side gate becomes SOURCE-AWARE (`capturer_supports_hdr_for`): Windows keeps its platform answer, gamescope asks whether the resolved binary offers the formats, everything else stays false. It must be truthful rather than optimistic — the Welcome is irrevocable, and PQ frames on an 8-bit encoder are a deliberate hard error — so every term is a static fact resolved before anything is spawned, including "do we spawn gamescope at all" (an attach inherits someone else's flags and can promise nothing); * `want_hdr` reaches the capturer: `open_virtual_output` grew the parameter it never had, and the host arm stopped dropping `want.hdr` on the floor; * the spawn gains `--hdr-enabled --hdr-debug-force-support` on all three sub-modes (bare args, the `GAMESCOPE_BIN` wrapper, the SteamOS PATH shim), from one shared `hdr_args` — the force flag is what makes it work headless, and forgetting it looks exactly like a negotiation failure. Two things that would have gone wrong quietly: * the keep-alive reuse key gains `hdr`. gamescope cannot turn HDR on live, so a kept SDR display handed to an HDR session would give the game no HDR surfaces while the stream negotiated PQ over an SDR composite — wrong, and not obviously broken. Same for the managed session-plus/SteamOS reuse check. * the HDR-negotiation-failure latch becomes PER SOURCE. It was one process-wide flag, so a monitor that left HDR mode would have disabled gamescope HDR until the host restarted, and vice versa — two facts with nothing in common but the word HDR. GameStream follows the same shape: `host_hdr_capable` gains the gamescope arm, and the RTSP gate's live BT.2100 monitor probe is scoped to the portal source — a headless box has no monitor to be in HDR mode, so running it there would hard-refuse every gamescope HDR session. Off by default for one release (`PUNKTFUNK_GAMESCOPE_HDR=1`), and `punktfunk-host hdr-probe` now answers for both Linux HDR sources. |
||
|
|
0868b6a364 |
fix(vdisplay): compositor availability follows the live session, not the env
GNOME/Mutter reported "Unavailable" on a host sitting in a live Mutter session — and, on the same request, "Default", because the two columns had different sources. available() asked each backend, and those probes read the process env (XDG_CURRENT_DESKTOP for Mutter, WAYLAND_DISPLAY for KWin's registry handshake, SWAYSOCK for sway) — env a host started outside the session (systemd --user, a TTY, ssh) never inherited. It is only retargeted at the live session on the connect path, so the answer also flipped depending on whether anyone had connected yet. Both columns now come from the same /proc scan detect() already used: the live session's compositor is usable by definition, as is an explicit operator pin, and the per-backend probe stays as the fallback for backends that are not the live session (gamescope, which spawns its own). A live KWin without the zkde_screencast grant now surfaces as available and fails at create with that probe's precise message, which beats "no usable compositor" on a box visibly running KDE. Mutter's env sniff stays deliberately narrow — one var, not three. XDG_CURRENT_DESKTOP is the one apply_session_env owns end to end (written per connect, scrubbed when nothing is live); sniffing DESKTOP_SESSION alongside would resurrect the bug that scrub exists to prevent, where a stale value after a gnome-shell crash routes the next client into a dead session. Non-Linux hosts now report no compositors at all rather than five Linux backends flagged unavailable with no default — on Windows the pf-vdisplay driver is the only backend and vdisplay::open ignores the argument, so the old list read as broken detection instead of "not applicable here". The console says so explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit eb7ba3d6177552f5c3ed6a15439404084869c636) |
||
|
|
d461d889c3 |
feat: the streamed screen is a persisted setting, pickable from the console
P4 of design/per-monitor-portal-capture.md. The pin was an env var read once at startup, which a console picker can never write — so it becomes a field of the display policy (orthogonal to presets, like game_session), and `vdisplay::capture_monitor()` resolves env-over-policy: an appliance that pinned in its unit's host.env stays pinned there, and a console click cannot re-aim a machine whose operator already declared the answer. Read per `open` rather than cached, so a picker change takes effect on the next session instead of the next host restart. The host re-resolves the input anchor on every policy write — including clearing it when the pin is cleared, or a later virtual-display session inherits an anchor aimed at a monitor it is not showing. `with_manual_layout` carries the pin through like the other orthogonal axes: without that, saving a display ARRANGEMENT would silently swap the streamed screen back to virtual. Console: a "Streamed screen" card listing the host's real monitors beside "Virtual screen (default)", saving on selection. A disabled head is listed but not selectable (so "why isn't it here?" has an answer), and an env-pinned host renders read-only with the reason rather than offering controls that silently lose to the environment. Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes sessions to the mirror and anchors input; setting the env to a different connector overrides it, exactly as documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
358cfa4be4 |
feat(host): mirror-test — the on-glass gate for per-monitor capture
Opens the display backend exactly as a session would, attaches a capturer and reports the frames a named head actually produces, with no client involved. `--monitor` names the head explicitly (it cannot go through PUNKTFUNK_CAPTURE_MONITOR: pf_host_config parses the environment once and startup already read it, so a tool setting it for itself would still see the old snapshot — hence vdisplay::open_mirror); with no argument it exercises the production routing through the pin. A frame timeout is NOT treated as failure. Compositor capture is damage-driven — the host's own capture diag logs new_fps=0 for virtual outputs on an idle desktop for exactly this reason — so the first version of this tool ended its measurement on the first idle gap and made a working mirror look like a 2-frame stall. Verified on KWin 6.7.3 (home-nobara-1), mirroring HDMI-A-1: 346 frames in 20s at 1920x1080, arriving while input was injected and pausing when the desktop went quiet, on both the CPU (BGRx/MemFd) and zero-copy (NV12 dmabuf) paths, ownership=External. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
93c2765db7 |
feat(vdisplay): mirror a pinned physical monitor on KWin
P2 of design/per-monitor-portal-capture.md. zkde_screencast's stream_output records an output KWin already has — the connector name IS the selection, so there is no dialog, no portal and no chooser for a background service to answer. It arrives as a VirtualDisplay reporting DisplayOwnership::External, which already means "someone else's display, merely mirrored: no keep-alive, no topology, no reuse". So the rule that we must never disable, move or "restore" a monitor the user is sitting in front of holds by construction rather than by everyone remembering it — and create() ignores the client's requested mode for the same reason: a panel runs at the mode its owner set. Routing lives in vdisplay::open, the one place every session opens a display, so the pin cannot be honored on one plane and ignored on another. The host sets the libei anchor from the same pin (pf-vdisplay must not depend on pf-inject), which is what makes absolute input land on the mirrored head instead of a same-sized neighbour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
95e3314d9a |
feat(vdisplay): enumerate the host's physical monitors
P0 of per-monitor capture (design/per-monitor-portal-capture.md): nothing in the tree could answer "what heads does this host have?", which both a monitor pin and a console picker need first. One read per compositor, each beside the code that already speaks that dialect: KWin's kde_output_device_v2 (the topology session now also records geometry + scale), Mutter's DisplayConfig.GetCurrentState, swaymsg get_outputs, hyprctl monitors. Logical geometry throughout, because x/y is what identifies a head — two monitors can share a size but never an origin. PUNKTFUNK_CAPTURE_MONITOR parses here and is reported at startup, loudly including that it is not yet enforced: a knob that is read but inert has to say so, or "it didn't work" reads as a bug. GET /display/monitors always answers 200 with an explained empty list, so a compositor-less host renders as "nothing to pick", not as a broken console. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6be865f33f |
fix(host): a host that is stopped hands the box's session back first
Until now the host had no signal handling at all: SIGTERM killed it outright. That is fine for a host that owns nothing — but a managed gamescope takeover owns the box's session, and on a mask-fragile display manager (Nobara's plasmalogin) it has STOPPED that display manager for the length of the stream. Killed there, the host leaves a box with no graphical session and nothing left to restart it: the crash-restore state lives in $XDG_RUNTIME_DIR, which logind removes along with the user manager, so not even the next host start can heal it. `systemctl --user restart punktfunk-host` mid-stream — or a package update doing it for you — was enough to strand a box until someone reached a VT. So SIGTERM and SIGINT now restore first and exit after. The restore runs on a blocking thread (it shells out) under a 20s grace, well inside systemd's TimeoutStopSec, and a host that took nothing over exits immediately. `restore_takeover_now` is the synchronous sibling of the debounced disconnect restore, and deliberately ignores the keep-alive policy the latter honors: `keep_alive=forever` pins a session for the NEXT client, which means nothing once the host that would serve them is exiting. Both paths now share one `takeover_live()` predicate instead of repeating the four-way "is anything ours" test. Checked on Linux: clippy -D warnings on punktfunk-host + pf-vdisplay, tests, rustfmt. |
||
|
|
0323158a3e |
fix(vdisplay/gamescope): a fresh dedicated launch streams from the first second
audit / bun-audit (push) Failing after 20s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
ci / bench (push) Successful in 6m35s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 7m50s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
arch / build-publish (push) Canceled after 7m36s
deb / build-publish (push) Canceled after 4m26s
ci / web (push) Successful in 1m8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 40s
deb / build-publish-host (push) Canceled after 6m54s
deb / build-publish-client-arm64 (push) Canceled after 6m48s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 40s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
apple / swift (push) Successful in 5m32s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 13s
audit / cargo-audit (push) Successful in 2m54s
ci / docs-site (push) Successful in 1m25s
release / apple (push) Canceled after 1m57s
decky / build-publish (push) Successful in 45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 39s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 6m34s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
A freshly spawned headless gamescope never delivered a single PipeWire buffer when a game was launched: gamescope only composites (and only on a composite pushes a capture buffer) when a client paints, and a nested Steam bootstrap paints nothing until its UI's first frame — far longer than the 10 s first-frame budget. The native plane's retry ladder then killed the half-booted Steam on every attempt and started from zero; the GameStream plane died on its single wait. Reused live displays worked, which pointed away from the real cause (root-caused on .41 with a raw pw_stream probe: `sleep infinity` nested → 0 buffers ever, `vkcube` nested → 60/s immediately). Every bare spawn now backgrounds the host's own splash client (`gamescope-splash`, hidden subcommand) beside the nested app: a dark window with a subtle breathing bar painted at ~2.5 Hz — damage from the first second, so capture gets its first frame within the budget on both planes. In `--steam` mode gamescope composites only windows whose appid is in the root `GAMESCOPECTRL_BASELAYER_APPID` list (live-proven: even a painting vkcube gets zero composites without it), so the splash declares itself as the Steam UI (STEAM_GAME=769) and seeds the baselayer iff unset; Steam's own rewrite at game launch hands composite focus to the game with no action on our side. PUNKTFUNK_GAMESCOPE_SPLASH=0 is the escape hatch. The dedicated Steam launch is also shaped with `-gamepadui` instead of `-silent`: the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built around — so the boot shows the gamepad UI instead of the desktop client window flashing through the stream, and gamescope's focus rules (game outranks the Steam UI appid) cover what `-silent` was working around. On-glass on .41 (Bazzite, RTX 5070 Ti, gamescope c31743d): cold connect + Balatro launch delivers frames on the first attempt on both a cold and a torn-down-then-fresh spawn; Big Picture boot → game handover confirmed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
86d5d66660 |
fix(kwin): drive display topology over kde_output_management_v2, not kscreen-doctor
A KDE host on Nobara stopped disabling its physical screens and creating the virtual output the moment it updated: streaming still came up, but bring-up took ~26 s and the streamed output never became the desktop (`also_disabled=[]`). Root cause is not our topology logic — it's that every `kscreen-doctor` call on the reporter's session wedges. kscreen-doctor drives libkscreen, which (per setup) waits on the kscreen KDED module over D-Bus; when that layer is stuck it blocks in its own connect and never returns, so all five topology queries hit their 5 s budget and got killed (host log). Reproduced live on a Nobara / KWin 6.6.4 box: `kscreen-doctor -j` there times out at 8 s, every time. But the compositor's OWN Wayland is fully responsive on that same session — the host just created a virtual output over it via zkde_screencast. So drive the topology (resolve our output, take primary, disable the physical/bootstrap outputs, capture their modes, re-enable on teardown, position) directly over `kde_output_management_v2` + `kde_output_device_v2` instead of shelling out. On that same Nobara box the in-process path binds management (v19) and enumerates the outputs in 2.4 ms — ~3400x faster than the 8 s hang, and immune to whatever wedges the standalone tool. - vendor kde-output-management-v2 / kde-output-device-v2 (KWin advertises mgmt v19, device v20); generate client bindings inline (the interdependent-protocol module split from the wayland-protocols crate; needs the `bitflags` dep for the device protocol's bitfield enums). - new `kwin_output_mgmt`: bounded enumerate-then-apply over one Wayland connection; every wait is time-bounded so a genuinely wedged compositor degrades to `handled = false` and the old kscreen-doctor path still runs. - `kwin::create` topology + `apply_position` prefer the in-process path (address our output by its stable device UUID, supersede-robust) with kscreen-doctor as the fallback. The 60 Hz path now makes ZERO kscreen-doctor calls; only the >60 Hz custom-mode install still shells out (its in-process port is a follow-up). Verified on Linux: cargo test -p pf-vdisplay (73 pass), clippy --all-targets -D warnings clean, fmt clean, punktfunk-host checks clean. In-process enumeration proven live against KWin 6.6.4 (the reporter's env); the disable/apply path is not yet on-glass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ac3dc4323f |
fix(native/session): make the stop flag enforceable so a session can't outlive its client
Native sessions could survive long after the client was gone, in two
independent ways.
HOST: `stop` was only advisory. The one thing that ends a session is the
stream thread returning — every teardown (conn.close, the joins, and the
RAII drops of the session permit, admission entry and stream marker)
sits after that await, and nothing forced the issue. The encode loop
checks `stop` between iterations, so any unbounded call INSIDE one never
reaches the check, and one stuck syscall became a permanent zombie: it
held its semaphore slot (four of those and the host stops accepting
QUIC entirely), its admission entry (a later client gets "host busy"
forever), and not even the console's Stop button could clear it — that
button sets this same flag.
* Bound the wait: once the session has been told to stop, the thread
gets STREAM_STOP_GRACE (90 s, well past the 40 s capture-rebuild
budget) to return, then teardown runs anyway. The thread is detached,
not killed — Rust can't cancel a blocking thread — so it keeps its
capturer/encoder until the stuck call returns, but the session's slot
and admission entry come back and the host keeps serving. It logs at
ERROR as the host wedge it is.
* Bound the audio/input joins too — the last unbounded await in
teardown.
* Take the session permit AFTER the QUIC handshake instead of before
`accept()`, so a host at its concurrency cap still accepts and the
waiting client sees a live path instead of a silent dial timeout.
* Bound the compositor helpers that caused the wedge in the first
place: new pf-vdisplay `proc::{status_within, output_within}` kill a
child that outlives its budget. `kscreen-doctor` is a Wayland client
of the very compositor it configures, so against a wedged KWin it
never returned; same for systemctl/dbus against a stuck session bus.
CLIENTS: the connection was never closed, so the host was right to keep
the session — it still had a live, keep-alive-answering peer.
* Android: backgrounding did no teardown at all, and Android doesn't
suspend the process, so the worker kept answering keep-alives until
the OS reclaimed it (on a TV box, never). End the session on ON_STOP,
via the existing onDispose path; a plain close, not a quit, so the
host lingers the display for a fast return.
* Apple: the .background arm was iOS-only AND gated on an opt-in that
defaults off, so backgrounding did nothing — while the `audio`
background mode kept the app (and its connection) alive indefinitely.
Act unconditionally, and cover tvOS.
* Core: `conn.close()` only queues the frame, and run_pump is the body
of a block_on whose runtime is dropped the instant it returns, so the
driver could never put it on the wire — a deliberate quit reached the
host as silence (8 s idle timeout, no quit code, and the linger meant
for an unwanted disconnect). Carry the endpoint out of the handshake
and flush with wait_idle(), the same discipline the pairing and probe
paths already use.
Linux check/clippy/tests green: 262 host, 71 pf-vdisplay (incl. new
bounded-process tests), 231 core.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
d5ae8dcc3e |
feat(capture): gamescope cursor via XFixes shape + QueryPointer position (Phase C)
gamescope excludes its pointer from the PipeWire node it feeds us and can't embed one either (`set_hw_cursor` is inert), so every gamescope stream was cursorless. Read the pointer from gamescope's nested Xwayland instead — XFixesGetCursorImage for shape/hotspot/visibility, core QueryPointer for position — and publish a CursorOverlay into the capturer's existing `cursor_live` slot, so the encoder blend composites it into the video exactly like the SPA_META_Cursor path. - pf-capture/src/linux/xfixes_cursor.rs (new): the XFixes reader. Connects to EVERY nested Xwayland (Gaming Mode runs one per --xwayland-count) and follows the focused one each tick (the display whose pointer moves), reading that display's own cursor shape. Un-premultiplies ARGB -> straight RGBA. Drop stops the thread. - Capturer::attach_gamescope_cursor + the PortalCapturer override spawn it into the same `cursor_live` slot; pf_vdisplay::gamescope_xwayland_cursor_targets discovers the (DISPLAY, XAUTHORITY) pairs via the GAMESCOPE_WAYLAND_DISPLAY scan. - host: SessionPlan.gamescope_cursor (set from the compositor at both resolve sites AND on a mid-stream Desktop->Gaming switch); the blend gate now builds the encoder blend for gamescope; a sibling composite arm attaches capturer.cursor() per tick for capture-mode clients (no channel needed). native NV12 is disabled for these sessions — that encode path can't blend the cursor (it assumes gamescope embeds the pointer), so we capture RGB and route to the proven CUDA VkSlotBlend / compute-CSC blend. - the pipewire thread no longer clobbers `cursor_live` with None on a buffer that carries no SPA_META_Cursor (gamescope) — that raced the XFixes writer and strobed the composited pointer on/off. On-glass (home-bazzite-2, RTX 5070 Ti, Gaming Mode): cursor visible + steady in Big Picture and CS2 menus, follows focus between the two Xwaylands, hidden in-game. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2562663fc6 |
fix(host/linux): survive and self-heal dead gamescope sessions on headless SteamOS boxes
Three fixes for the "most connects simply fail" trap on a SteamOS host with no physical display (VM testbox, panel-less mini PC): - restore path: skip the restore-to-physical-panel when no DRM connector reports a connected display — removing the headless drop-in and restarting gamescope-session.target on such a box just crash-loops gamescope and strands every later connect on "no usable compositor". Keep the headless session (and the takeover state) instead. - connect path: when no live graphical session exists but the MANAGED gamescope infra is present (SteamOS gamescope-session / Bazzite session-plus), route to the gamescope takeover — which rebuilds the session at the client's mode — instead of failing the connect. - error delivery: a session-setup failure used to just drop the QUIC connection, reaching the client as "control stream finished mid-frame" (indistinguishable from transport trouble). Close with a typed setup-failed code (0x68, RejectedSetupFailed = -29) carrying the error text, and give the client handshake/pairing error paths a short grace for the CONNECTION_CLOSE to beat the stream error it caused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
333c8979e9 |
fix(vdisplay/gamescope): capture-loss rebuild probes must not steal the box session
Observed live on a Deck host: the user switched Game Mode → Desktop, KDE came up in 0.8 s — and the capture-loss rebuild's FIRST detection ran inside that gap, still said Gaming, and its gamescope re-acquire restarted gamescope-session.target. On SteamOS that steals the seat: the user was yanked out of the KDE session they had just chosen, the two session managers fought (job canceled / dependency failed), no gamescope node appeared within 30 s, and the stream died at the 40 s rebuild budget. A rebuild probe acting on possibly-stale detection must never stop, relaunch, or take over box sessions. New pf_vdisplay::rebuild_probe_scope (RAII, counted): while held, the gamescope managed/takeover create paths only attach to a live node and fail fast otherwise — no stop_autologin_sessions, no session-plus relaunch, no gamescope-session.target restart. The capture-loss loop holds it for the first 4 s after a loss (the detection-ambiguity window); after that, destructive rebuilds are allowed again, so a genuine switch INTO Game Mode still gets its headless takeover. Probe failures are instant, so the loop now paces itself at ~2 Hz instead of spinning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b89dbfa979 |
feat(gamescope): end dedicated stream on Steam game exit + auto --steam
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m12s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
ci / bench (push) Successful in 6m57s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m51s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
apple / screenshots (push) Successful in 6m13s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
deb / build-publish (push) Successful in 9m37s
docker / deploy-docs (push) Successful in 29s
deb / build-publish-host (push) Successful in 9m29s
windows-host / package (push) Successful in 16m24s
arch / build-publish (push) Successful in 17m40s
android / android (push) Successful in 18m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m52s
ci / rust (push) Successful in 26m16s
Dedicated single-game sessions now end cleanly when the launched game quits, and Steam launches get gamescope's Steam integration without an operator env knob. End-on-exit: the existing APP_EXITED close only fired when gamescope's PipeWire node vanished, which never happens for Steam — the nested `steam` is the resident singleton client and stays up after a game quits, so gamescope (and its node) never die and the stream sat on a hidden Steam session forever. Add a Steam AppId reaper watcher (pf-vdisplay .../gamescope/discovery.rs): steam_appid_from_launch() parses steam://rungameid/<id>; wait_for_steam_game_exit() waits for the game to start (<=300s grace) then exit (3s confirm); steam_game_running() scans same-uid /proc for Steam's launch reaper matching both the `SteamLaunch` and `AppId=<id>` argv tokens (exact-match; reaper lifetime == game lifetime, so shader precompile can't false-trigger). The host spawns a pf1-gamewatch thread for nested Steam launches that closes the connection with APP_EXITED (launcher clients return to their library) and sets quit/stop; cancelled via stop if the session ends first. Non-Steam nested launches keep the node-death path (gamescope's child IS the game). Flags: auto-enable `--steam` whenever the launch is a Steam launch (was only the global PUNKTFUNK_GAMESCOPE_STEAM knob, default-off) — in-game overlay / Steam+X / gamepad-UI nav for Steam titles with no operator config. New opt-in PUNKTFUNK_GAMESCOPE_GRAB_CURSOR adds `--force-grab-cursor` for a real game launch (FPS mouselook); default OFF because it forces relative mode, which breaks absolute-pointer games/menus. Verified: fmt clean; clippy -D warnings clean on the three crates; pf-vdisplay 64/0 (incl. new steam-appid parse test); punktfunk-host builds + 186/0; reaper /proc detection smoke-tested (detect, exact non-match, gone-after-exit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
27a5d8daac |
refactor(host/W6.2): extract virtual-display orchestration into the pf-vdisplay crate
vdisplay.rs + vdisplay/* (the per-compositor Linux backends — KWin zkde-screencast,
wlroots swaymsg, Mutter RemoteDesktop, Hyprland — and the Windows IddCx/pf-vdisplay
driver backend, behind one VirtualDisplay trait; the mode-conflict admission
registry, the display policy/identity/custom-preset state, and the session-env /
gamescope routing) move into crates/pf-vdisplay (plan §W6). The DDC/CI panel-power
control (used only here) and the KWin zkde protocol XML move with it. This
completes the host-crate decomposition: capture, encode, inject, and vdisplay are
now four subsystem crates over the shared leaves, and punktfunk-host is the
orchestrator (serve/supervisor + native + gamestream + mgmt).
Coupling breaks (all down-only, cargo-tree acyclic):
- capture::dxgi identity -> pf_frame::dxgi; win_display/monitor_devnode/
console_session_mismatch -> pf-win-display leaf; can_open_another_session ->
pf-encode (the NVENC session-budget admission gate — acyclic peer edge).
- The registry's DisplayCreated/DisplayReleased emits into the host SSE event bus
invert to a leaf hook: pf-vdisplay emits a neutral DisplayEvent to a
host-registered DISPLAY_EVENT_SINK, so it never reaches the orchestrator's
events module.
- The IddCx driver module is renamed pf_vdisplay -> driver (its old name collided
with the crate name through the host's `mod vdisplay` shim glob).
The host keeps `mod vdisplay { pub use pf_vdisplay::* }` so every crate::vdisplay::*
path (serve/mgmt/native/the capture FrameChannelSender seam) is unchanged; the
heavy deps (wayland/ashpd/tokio + the zkde protocol) moved with the crate.
Co-authored: a fail-closed IOCTL-reply-length security fix (reject short/zeroed
pf-vdisplay driver replies before trusting protocol_version/target_id/wudf_pid/luid,
security-review 2026-07-17) rides this commit in the moved driver module.
Verified: Linux clippy -D warnings (pf-vdisplay + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-vdisplay 63/63 + host 167/167 tests; Windows clippy -D warnings
(pf-vdisplay --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|