Compare commits

..
Author SHA1 Message Date
enricobuehler 96f75f4e52 style: rustfmt the abandoned-devnode sweep
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / bun-nix (pull_request) Successful in 20s
ci / docs-drift (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m3s
ci / web (pull_request) Successful in 3m33s
android / android (pull_request) Successful in 10m5s
ci / rust (pull_request) Successful in 9m45s
2026-08-23 09:49:49 +02:00
enricobuehler 4c5b97cfe4 fix(host,audio): the registry stamp route reached for the Render hive even for capture endpoints
ci / rust-arm64 (pull_request) Successful in 1m21s
ci / rust (pull_request) Failing after 2m50s
ci / web (pull_request) Successful in 1m6s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m7s
ci / docs-drift (pull_request) Successful in 23s
android / android (pull_request) Canceled after 7m19s
write_stamps falls back to a raw-registry write when the property store
denies it. That fallback built its path from MMDEV_RENDER_PATH unconditionally,
so stamping the minted microphone's CAPTURE endpoint reached for
...\MMDevices\Audio\Render\{capture-guid}\Properties - a key that cannot
exist. RegOpenKeyExW then failed, write_stamps returned the error, and
stamp_identity degraded to 'keeps the driver's default name'.

Invisible to the pad program, whose endpoints are render-only, and invisible on
any box where the property store route succeeds (both field logs show
registry=[] on every stamp line, so neither reporter ever took this path). It
only bites where the property store is denied - exactly the boxes the ACL
repair exists for.

The hive now follows the direction the endpoint id encodes, with render as the
default for anything unrecognised. Unit-tested.
2026-08-23 09:40:01 +02:00
enricobuehler 4beee17953 fix(host,audio): merge the abandoned-devnode tests into the existing module
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
ci / docs-drift (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Failing after 1m44s
ci / rust-arm64 (pull_request) Successful in 2m30s
android / android (pull_request) Successful in 5m48s
2026-08-23 09:08:57 +02:00
enricobuehler 4ad0055416 fix(host,audio): a host that died mid-mint left an orphan devnode, and the next start minted a duplicate
Minting an audio devnode is two PnP steps: SetupDiRegisterDeviceInfo makes it
real and bindable, then the owner marker goes into Device Parameters. A host
that dies between them - the 0.30.0 TLS-destructor abort did exactly this,
five times on one field box - leaves a registered, driver-bound, endpoint-
serving devnode carrying no marker.

Nothing resolved it afterwards. find_role_devnode matches on the marker, so
the next pass minted a SECOND devnode and the orphan stayed: a duplicate
'Punktfunk Speakers'/'Punktfunk Microphone' in the Sound zoo that no uninstall
removed, because devnode_cleanup is marker-matched too. A field box showed
exactly this shape - 'Punktfunk Speakers (3- Punktfunk)' beside an unstamped
'Punktfunk Speakers (4- Steam Streaming Speakers)' - in every wiring plan it
logged. Reproduced on .173 against the shipping 0.31.2 binary by clearing the
marker: ROOT\MEDIA\0005 was minted and 0004 was abandoned, still active and
still serving two live Punktfunk Microphone endpoints.

* minted.rs adopts before it mints. An unmarked ROOT\MEDIA\NNNN devnode
  carrying the role's Steam hardware id is re-marked and reused, so the
  endpoint GUID survives and no device-change broadcast is paid.
* devnode_cleanup sweeps the same shape, so orphans already on a box go at
  uninstall instead of outliving the product.

The instance prefix is what keeps both off Valve's own devices: Steam's
devnodes carry these hardware ids and are ROOT-enumerated too, but live under
ROOT\SteamStreamingSpeakers\* / ROOT\SteamStreamingMicrophone\*. Only
ROOT\MEDIA\* can come from our SetupDiCreateDeviceInfoW(DICD_GENERATE_ID).
is_abandoned_mint carries that rule with unit tests.
2026-08-23 09:03:52 +02:00
enricobuehler db9cd40079 The hand-back never checked that the panel came back, and a crashed host left game mode asleep (#375)
ci / bun-nix (push) Successful in 37s
ci / rust-arm64 (push) Successful in 1m31s
ci / web (push) Successful in 1m46s
ci / docs-site (push) Successful in 1m43s
ci / docs-drift (push) Successful in 1m48s
deb / build-publish-gamescope (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 2m2s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m7s
docker / builders-arm64cross (push) Successful in 23s
deb / build-publish-host (push) Successful in 5m35s
ci / rust (push) Successful in 7m21s
docker / deploy-docs (push) Successful in 51s
android / android (push) Successful in 10m16s
arch / build-publish (push) Successful in 10m38s
deb / build-publish (push) Successful in 5m18s
deb / smoke-install (push) Successful in 4m30s
windows-host / package (push) Successful in 16m42s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m22s
Reproduced on both Bazzite 44.20260818 and Nobara f44: a host killed mid-takeover
left the box's Game Mode running `/usr/bin/sleep infinity` with the panel lit and
blank, permanently. Fixed and re-verified on both boxes against canary
0.32.0-0.ci15147.gc63e8cee, with no regression to the ordinary disconnect.

The hand-back also measures its own outcome now instead of trusting a systemd job
status, so any other route to a dark panel is caught and escalated rather than
logged as success.
2026-08-22 23:38:23 +00:00
enricobuehler c63e8cee39 fix(gamescope): the hand-back never checked that the panel came back, and a crashed host left game mode asleep
ci / bun-nix (pull_request) Successful in 28s
ci / docs-drift (pull_request) Successful in 1m5s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m50s
ci / rust-arm64 (pull_request) Successful in 2m7s
ci / rust (pull_request) Successful in 7m5s
android / android (pull_request) Successful in 7m35s
Field reports on 0.31.x, Bazzite and Nobara: after disconnecting, the box's own
physical screen stays black.

I could not reproduce it (PR #375 has the full negative write-up: five scenarios
across both distro families on the real VMs, all recovering cleanly, and the
mechanism I first proposed disproved on glass). So this does not guess at the
trigger. It closes the gap that lets ANY trigger end as a dark panel, and fixes
the one black-screen path I could prove.

## The restore never checked its own work

`do_restore_tv_session` issues a lifecycle verb and logs what systemd said about
the JOB. "The job succeeded" and "the box shows a picture" are different
questions, and nothing in this file has ever asked the second one — the restore
walks away the moment the verb returns, so every way the box can end up dark
looks identical to success in the log.

So measure it. After the hand-back a detached watcher polls
`detect_active_session()`, whose `None` means no compositor of our uid is running
at all — exactly the symptom. If the box is still dark 25 s later it climbs a
ladder of remedies, each measured on both images (Bazzite 44.20260818, Nobara
f44, 2026-08-22):

1. STOP the autologin unit. Its login session's script is parked on
   `systemctl --user --wait start <unit>` on both images, so a stop releases that
   wait, the session exits, and `Relogin=true` logs back in — starting the unit
   inside a session with a seat. `stop`, not `restart`: a restart does NOT
   release the parked waiter (measured), which is why it cannot rescue a box the
   ordinary restart already failed to bring back.
2. Restart the display manager — what the pre-0.31.0 takeover did on every
   disconnect, and proven on the Bazzite VM to return the box to game mode.
3. `PUNKTFUNK_RECOVER_SESSION_CMD`, then an ERROR naming the command a human has
   to run.

Detached, and that is load-bearing: the restore holds `RESTORE_FLIGHT`, which a
reconnecting client must take before it can re-take the box, so watching for up
to a minute while holding it would put that wait in front of every reconnect.
The watcher also stands down the instant `takeover_live()` says a new takeover
armed — the box belongs to that stream now, and a remedy fired into it would be
a fresh bug. It runs after `clear_takeover()` so that check means "a client
reconnected" and not "our own takeover has not been filed yet".

Skipped on the shutdown path: `restore_takeover_now` runs inside `native.rs`'s
20 s `SHUTDOWN_RESTORE_GRACE`, and spending that grace watching would cost the
hand-back rather than check it. What covers a shutdown that left the box dark is
the next host start — which this commit also makes true.

## A crashed host left the box's game mode asleep, provably

`restore_takeover_on_startup` sweeps a leftover idle drop-in off the box and logs
that the box's "own Game Mode session would have started and then done nothing".
Removing the FILE does not touch the unit RUNNING under it: its `ExecStart` is
still the sleep, so it sits `active` drawing nothing. Nothing below that sweep
restarts it either — the takeover file may be absent, unparseable, or fail
`takeover_state_is_live`, and all three exits leave the box on a dark panel with
its game mode "running". Any host killed mid-takeover (SIGKILL, OOM, a yanked
update) lands exactly there, and it survives until someone reboots.

`hand_back_idled_units_after_crash` restarts those units, gated on the box
actually being dark so a user already in game mode or on a desktop is never
bounced, and only for ACTIVE instances — under a just-removed idle drop-in,
active means "running the sleep".

## Not changed

The `restart` verb on the ordinary restore path. It works on both distros
(measured), and 0.31.0 chose it deliberately for the idled unit. The `stop` idea
survives only as escalation rung 1, where it runs after the proven path has
already failed.

`listed_autologin_units` is factored out of `stop_autologin_sessions` so both
callers share it, and its column parsing — which decides whether a live gaming
session can be told from a dead leftover — finally has a test against real
`--plain` output from both images.
2026-08-23 01:02:27 +02:00
enricobuehler b670b5d844 Merge pull request 'A TV negotiated the refresh its menu pinned, not the one it outputs' (#378) from worktree-tv-refresh-mismatch into main
ci / web (push) Successful in 2m11s
ci / docs-site (push) Successful in 2m1s
ci / rust-arm64 (push) Successful in 2m45s
ci / docs-drift (push) Successful in 1m27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 31s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 29s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 39s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
ci / bun-nix (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders-arm64cross (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m49s
docker / deploy-docs (push) Successful in 36s
ci / rust (push) Successful in 5m41s
android / android (push) Successful in 9m51s
2026-08-22 22:47:19 +00:00
enricobuehler 064ea3de7d fix(android): a TV negotiated the refresh its MENU pinned, not the one it outputs
ci / bun-nix (pull_request) Successful in 29s
ci / docs-drift (pull_request) Successful in 39s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m17s
ci / rust (pull_request) Successful in 5m7s
android / android (pull_request) Successful in 6m22s
Field report: on Android TV / Fire Stick, latency explodes whenever the client's
refresh differs from the host's, and setting the refresh by hand is the only
workaround.

The client was manufacturing that mismatch itself, in three steps:

  1. `MainActivity.onCreate` pins the panel to its highest-refresh mode for the
     console UI (`setConsoleHighRefreshRate(true)`) — unconditionally, TVs
     included. That pin exists for phone refresh governors (Nothing OS's LTPO
     logic among them) which cap third-party apps at 60 Hz. No TV has one.
  2. At connect, `nativeDisplayMode` resolves "Native" refresh from
     `display.mode` — which now reports the mode the MENU pinned, not the TV's
     real HDMI output. So the session negotiates (say) 120.
  3. `StreamScreen` releases the pin again on TV, by design: there the decoder's
     own `setFrameRate(CHANGE_FRAME_RATE_ALWAYS)` governs the HDMI mode. The
     panel falls back to 60 while the host is already serving 120.

A 120 fps stream on a 60 Hz output, by construction, on exactly the two form
factors in the report. Picking a refresh explicitly is precisely what bypasses
step 2, which is why that is the workaround people found. The mode comparator
sorts refresh before area, so the same pin could also drop a 4K TV to 1080p120
and negotiate the stream at that.

Fixed at the choke point: `resolveHighRefreshMode` returns early on a TV, leaving
`highRefreshModeId` at 0, which `setConsoleHighRefreshRate` already treats as a
no-op — so all three of its callers are covered by the one guard. A TV that
genuinely wants 120 still gets it by choosing it, driven by the native mode
switch, exactly as the TV path documents.

Also in the same chain: `nativeDisplayMode` TRUNCATED the panel rate, so a TV
reporting the fractional NTSC rates over HDMI (59.94, 29.97, 23.976) asked the
host for 59 / 29 / 23 — rates no display mode has, which the host serves by
clamping down to the highest it advertises at or below. Rounded now, which also
makes it agree with `MainActivity.streamPanelFps`; the two describe the same
panel and must not disagree.
2026-08-23 00:21:50 +02:00
enricobuehler ec278c0478 Merge pull request 'Floor the forced-keyframe coalesce window so a 120 fps session can't IDR-storm' (#377) from worktree-hevc-idr-storm-coalesce into main
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m17s
ci / bun-nix (push) Successful in 29s
ci / docs-drift (push) Successful in 31s
ci / rust-arm64 (push) Successful in 2m3s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 29s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 40s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-gamescope (push) Successful in 1m5s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 16s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
deb / build-publish-client-arm64 (push) Successful in 1m43s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
ci / rust (push) Successful in 7m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 17s
docker / deploy-docs (push) Successful in 38s
docker / builders-arm64cross (push) Successful in 18s
deb / build-publish (push) Successful in 5m17s
android / android (push) Successful in 9m45s
deb / build-publish-host (push) Successful in 5m48s
arch / build-publish (push) Successful in 9m20s
deb / smoke-install (push) Successful in 2m36s
windows-host / package (push) Successful in 13m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m41s
2026-08-22 22:00:55 +00:00
enricobuehler 5d91176500 fix(host): floor the forced-keyframe coalesce window so a 120 fps session can't IDR-storm
ci / rust-arm64 (pull_request) Successful in 2m24s
ci / web (pull_request) Successful in 2m2s
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 7m33s
ci / docs-site (pull_request) Successful in 1m57s
android / android (pull_request) Successful in 8m14s
The window was `frame_interval * 2`, which is 16.7 ms at 120 fps. A Moonlight
client that has lost decode sync re-asks for an IDR roughly every 30 ms, so the
gate never closed between requests and effectively every request became a full
keyframe.

Field log (AMD RX 7800 XT, Bazzite 44, 1080p120 HEVC over the GameStream plane):
1118 IDR requests in one 91 s session, 1115 honoured, only 3 coalesced — about
one full IDR every tenth frame at a 100 Mbps target. IDRs that size saturate the
send path, which causes the loss that prompts the next request, so the storm
sustains itself. It reads as stutter at a flat latency, because frames are being
lost rather than queued. The same session's H.264 leg (libav VAAPI, same
bitrate) took 2 IDR requests and was clean.

The window is a round-trip bound — how long until the client can receive and
decode the IDR it already asked for — so it needs an absolute floor rather than
a frame count. 100 ms matches the encoder-reset backoff in the same loop.

Simulated against the logged 30 ms request cadence, this cuts honoured IDRs over
a 91 s session from every request to roughly a quarter, while still recovering
promptly from a genuine loss event.
2026-08-22 23:49:33 +02:00
enricobuehler 551d0c3294 Merge pull request 'The encoder follows a game-driven display mode change' (#373) from worktree-encoder-follow-mode-change into main
ci / bun-nix (push) Successful in 17s
ci / web (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 1m27s
ci / docs-site (push) Successful in 1m21s
ci / docs-drift (push) Successful in 30s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 21s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
deb / build-publish (push) Successful in 5m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
deb / build-publish-host (push) Successful in 5m50s
docker / deploy-docs (push) Successful in 40s
docker / builders-arm64cross (push) Successful in 12s
ci / rust (push) Successful in 7m5s
arch / build-publish (push) Successful in 10m22s
android / android (push) Successful in 11m5s
deb / build-publish-gamescope (push) Failing after 11m3s
windows-host / package (push) Successful in 13m44s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 34s
deb / smoke-install (push) Successful in 5m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 22m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 22m23s
Reviewed-on: #373
2026-08-22 21:33:42 +00:00
enricobuehler 7f77fa68af Merge pull request 'Stop the flatpak build updating runtimes it already has' (#370) from worktree-flatpak-deps-no-update into main
ci / bun-nix (push) Successful in 30s
ci / docs-drift (push) Successful in 31s
ci / docs-site (push) Successful in 1m5s
ci / web (push) Successful in 1m9s
ci / rust-arm64 (push) Successful in 1m28s
apple / swift (push) Successful in 2m10s
deb / build-publish-gamescope (push) Successful in 1m2s
deb / build-publish-client-arm64 (push) Successful in 1m43s
decky / build-publish (push) Successful in 1m7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 20s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 19s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
deb / build-publish (push) Successful in 4m23s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
deb / build-publish-host (push) Successful in 6m31s
docker / deploy-docs (push) Successful in 45s
android / android (push) Successful in 9m2s
docker / builders-arm64cross (push) Successful in 15s
flatpak / build-publish (push) Successful in 5m15s
arch / build-publish (push) Successful in 10m36s
deb / smoke-install (push) Successful in 5m41s
apple / distribute (push) Successful in 13m29s
ci / rust (push) Canceled after 16m51s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 11m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 11m57s
apple / screenshots (push) Successful in 10m36s
Reviewed-on: #370
2026-08-22 21:16:05 +00:00
enricobuehler ece8b16a78 fix(gamestream): the encoder follows a game-driven display mode change
ci / docs-drift (pull_request) Successful in 27s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m12s
ci / web (pull_request) Successful in 1m58s
ci / rust-arm64 (pull_request) Successful in 2m13s
android / android (pull_request) Successful in 5m16s
ci / rust (pull_request) Successful in 6m20s
The GameStream twin of the native fix. A fullscreen game can mode-set the
virtual display mid-stream; the IDD-push capturer re-opens its ring at the
new mode, and `try_latest` then hands this loop a frame the encoder cannot
accept. Every submit fails, the submit ladder rebuilds the encoder IN
PLACE at the same configured size — which cannot converge on a size the
source has already left — and after five resets the stream ends, costing
the Moonlight client a full disconnect/reconnect.

Reopen at the delivered size instead, with the same bookkeeping the
capture-loss rebuild in this loop already does (ring depth, RFI caps,
forced IDR, in-flight numbering restart). A failed reopen spends the
shared `encoder_resets` budget at the existing exponential pace rather
than ending the stream on the first try — a mode-set leaves the driver
settling, which is what that backoff exists for.

`gs_bit_depth(frame.format)` is derived per open, so an HDR flip that
recreates the ring at P010 now re-opens at the right depth too.

The client is NOT told: GameStream has no mid-stream mode-change message,
so Moonlight decodes a bitstream that disagrees with the resolution it
configured its decoder from. That is the same bargain the first open in
this function already takes whenever the captured size differs from the
negotiated one (the monitor-mirror case, §7.3) — tolerant decoders re-init
off the SPS and scale; a strict one (Media Foundation on Xbox) may stall
and drop the session. Taking it here too is strictly better than the
alternative, which is ending every stream the moment a game changes mode.
The guard carries that note.
2026-08-22 22:25:09 +02:00
enricobuehler 2b91339cb8 Merge pull request 'A paired Moonlight device can be given a name, and the pad-silence theory is measured and dropped' (#374) from worktree-gamestream-pad-heartbeat into main
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 44s
ci / web (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 1m35s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
deb / build-publish-host (push) Successful in 4m44s
deb / build-publish-gamescope (push) Successful in 52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
arch / build-publish (push) Successful in 9m0s
android / android (push) Failing after 9m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 43s
deb / build-publish (push) Successful in 4m52s
deb / smoke-install (push) Successful in 2m52s
windows-host / package (push) Successful in 18m0s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 18m30s
windows-host / canary-manifest (push) Successful in 35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m45s
2026-08-22 19:22:01 +00:00
enricobuehler ffa4577793 style(host): keep the IDR-anchor comment off the trailing position
ci / rust-arm64 (pull_request) Successful in 2m46s
ci / web (pull_request) Successful in 1m35s
ci / bun-nix (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m21s
ci / docs-drift (pull_request) Successful in 39s
android / android (pull_request) Successful in 6m46s
ci / rust (pull_request) Successful in 20m2s
A trailing comment that long makes rustfmt treat the two comment lines
that follow it as a continuation of the same block and reflow them into a
hanging indent past column 60, which fails `cargo fmt --all --check`.
Put it on its own line above the statement instead.
2026-08-22 20:58:00 +02:00
enricobuehler 9bb8d84f12 fix(host): retry the mode-follow encoder reopen instead of ending the session
ci / rust-arm64 (pull_request) Successful in 2m26s
ci / docs-site (pull_request) Successful in 1m17s
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 26s
ci / web (pull_request) Successful in 3m12s
android / android (pull_request) Successful in 9m29s
ci / rust (pull_request) Canceled after 4m46s
The reopen added in the previous commit bailed the session on the FIRST
failed `open_video`. That is worse than what it replaced: the mode-set
that triggers the reopen is exactly the kind of event that leaves the
driver settling, which is the transient the submit path's backoff already
exists for ("NVENC session open failing after a codec switch", 2026-07 —
no 8 ms retry could outlive it).

Spend the shared `encoder_resets` budget on it at the same exponential
pace (100 ms → 1.6 s), re-entering the follow-the-source guard each round.
The old encoder stays installed and mismatched meanwhile, so it simply
keeps failing submit until an open succeeds or the budget runs out — the
same ~3 s ceiling as before, but now every round is a real attempt at the
new mode instead of an in-place re-init that cannot converge.

Also tag the exhausted path accurately: it is an encoder REOPEN failure,
not a submit failure, and the session-end log prints that context.
2026-08-22 20:47:49 +02:00
enricobuehler f42aca690f fix(host): the encoder follows a game-driven display mode change
ci / web (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-drift (pull_request) Successful in 24s
android / android (pull_request) Successful in 6m57s
ci / rust (pull_request) Canceled after 5m5s
A fullscreen game can mode-set the virtual display mid-session with no
client Reconfigure. The IDD-push capturer already handles that — it
re-opens its ring at the new mode on a confirmed descriptor change — but
nothing re-opened the ENCODER, which is the one component that cannot
follow a resolution change in place.

Every submit then failed with "captured frame 1920x1080 != encoder
3840x2160", and the submit-error path only rebuilds the encoder IN PLACE
(Terminate + re-Init at the SAME configured size), which cannot fix a
size the source has already left. All five resets burned on it and the
video session ended ~3 s later, with audio still running — the client
sees a frozen picture and has to reconnect.

Field report 2026-08-22 (host 0.31.2, RX 6800 XT, AMF/HEVC 4K60):

  IDD push: display descriptor changed — recreating the ring at the new
    mode target_id=259 from=3840x2160 hdr=true to=1920x1080 hdr=true
  encoder submit failed — encoder rebuilt in place, forcing an IDR
    error=captured frame 1920x1080 != encoder 3840x2160 reset=1 max=5
  ... reset=5 max=5
  encoder did not recover after repeated in-place rebuilds — ending the
    video session ... resets=6

Track what the encoder was opened against and, when the source delivers
something else, re-open at the delivered size through the same
`open_video` path the client-initiated resize uses — then publish the new
mode to the client exactly as an accepted resize does, so its mode slot,
stats and aspect follow. PyroWave's Automatic rate is re-resolved for the
new mode (it is a per-mode bpp pin); H.26x rates stay with ABR.

Also covers a mid-session frame-format change (an HDR flip re-creates the
ring at a new format), which failed the same way.

The GameStream/Moonlight loop has the identical gap, left alone here: that
protocol has no mid-stream mode-change message, so following the source
there needs its own decision.
2026-08-22 20:35:25 +02:00
enricobuehler 34a02fdac5 Merge pull request 'Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch' (#372) from worktree-steam-prelaunch-latch into main
ci / bun-nix (push) Successful in 51s
ci / web (push) Successful in 1m28s
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 1m59s
deb / build-publish-gamescope (push) Successful in 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
deb / build-publish (push) Successful in 5m40s
deb / build-publish-host (push) Successful in 5m51s
ci / rust (push) Successful in 8m36s
arch / build-publish (push) Successful in 9m41s
android / android (push) Successful in 10m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 42s
deb / smoke-install (push) Successful in 7m27s
windows-host / package (push) Successful in 17m57s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m11s
2026-08-22 18:32:30 +00:00
enricobuehler 8670b412c7 fix(host): Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 6m6s
ci / rust (pull_request) Successful in 7m12s
A player had to launch Rocket League twice: the first launch streamed the
"Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host
did that to itself.

`reaper SteamLaunch AppId=<appid>` is the *appid's* wrapper, not the game's.
Steam wraps its pre-launch work for a title in one too, so a launch is a chain
of appid-tagged trees and only the last is the game. The lease matched the
first tree two seconds in, and that single sighting latched it out of the start
phase (START_GRACE, five minutes, ending nothing) into the exit watch
(EXIT_CONFIRM, three seconds, ending the session). When the tree exited with
the game still starting, the watch called it the game exiting and closed the
connection with APP_EXITED.

Linux has nothing to catch that: `procscan::running_hint` is Windows-only, and
no provider reports runstate for Steam, so an appid scan with three seconds of
slack is the whole signal. (Steam's registry.vdf is not an option — RunningAppID
is no longer set on modern Steam Linux, and the per-app Running key is
unreliable.)

Two layers, because only one of them can be certain:

* The matcher now rejects a `SteamLaunch AppId=` reaper whose payload is
  `fossilize_replay` — Steam's shader replayer, never a game.
* A scan match must be seen continuously for SHIM_WINDOW before it latches.
  This is the rule already applied to a spawned child ("a launcher about to
  hand off looks exactly like the game for its first few seconds"); the scan
  side never had it. It bounds the pre-launch trees nobody has named yet, at
  the cost of a few seconds of GameRunning latency. Exit detection is
  untouched, and a provider report still latches immediately — that is the
  launcher's own statement, not an inference from a lookalike.

The log said `procs=1` and never which process, which is what made this
unclosable from a log alone; `procscan::names` puts that on the line.
2026-08-22 20:12:36 +02:00
enricobuehler 430499bdab ci(flatpak): trigger on the deps-check script too
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m17s
ci / docs-drift (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 2m15s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 2m6s
ci / rust-arm64 (pull_request) Successful in 2m59s
android / android (pull_request) Successful in 5m18s
ci / rust (pull_request) Successful in 7m49s
flatpak-deps-present.sh decides whether the job talks to Flathub at all, and
a push-paths filter that ignores it means a change to that decision ships
untested until the next unrelated client commit happens to rebuild. Same
reason .gitea/workflows/flatpak.yml is already listed.
2026-08-22 03:35:26 +02:00
enricobuehler 92578803c2 fix(ci): stop the flatpak build updating runtimes it already has
The flatpak job died on every attempt with

    Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
    Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While
      pulling ... .filez: Server returned HTTP 404

dl.flathub.org was serving a 404 for one object of the then-current
rust-stable//25.08 commit. retry.sh burned all 10 attempts (~9 min) on the
same object, and flatpak-builder segfaulted on its own error path (rc=139),
so the wrapper could not tell a dead end from a load blip either.

Root cause is ours, not Flathub's: `--install-deps-only` does not install
what is missing, it UPDATES what is present. builder_manifest_install_dep()
branches on `flatpak info --show-commit <ref>` succeeding and runs
`flatpak update` for every already-installed dep, with no fallback to a
plain install when that update fails. ci/flatpak-ci.Dockerfile bakes the
entire runtime set, so that update was a pure no-op on a healthy run while
making every build depend on Flathub's health at that minute. Nothing wanted
the newer commit — the manifest pins a runtime VERSION, not a commit.

So ask first, and reach for Flathub only on a real miss. The check is
scripts/ci/flatpak-deps-present.sh (runtime + SDK at the manifest's exact
runtime-version, sdk-extensions by presence, since their version comes from
the SDK's metadata and any bump that moves them moves runtime-version too).
It fails OPEN: anything it cannot parse takes the full install path. Its
--self-test stubs `flatpak` and covers baked / cold / each dep missing /
wrong version / unreadable manifest.

Also drop --install-deps-from=flathub from the build step. Its comment
called it "a no-op safety net"; builder-main.c calls
builder_manifest_install_deps() whenever that flag is set, and
--install-deps-only only decides whether it exits afterwards, so the step
billed as offline was re-running the same update — and could only ever fire
if the prefetch step had already failed the job.

packaging/flatpak/build-flatpak.sh keeps --install-deps-from: a dev box
genuinely wants deps installed, and it has no baked image.
2026-08-22 03:34:51 +02:00
15 changed files with 1125 additions and 65 deletions
+53 -9
View File
@@ -50,7 +50,10 @@ on:
- 'crates/pf-vaadec/**'
- 'packaging/flatpak/**'
- 'Cargo.lock'
# Both halves of this job's correctness, not of the bundle's content: a change to either
# can only be proven by a real run, and there is no other trigger that would give it one.
- '.gitea/workflows/flatpak.yml'
- 'scripts/ci/flatpak-deps-present.sh'
tags: ['v*']
workflow_dispatch:
@@ -270,12 +273,13 @@ jobs:
- name: Prefetch deps + sources (retried — the network phase, split off the build)
run: |
set -euo pipefail
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
# extensions. (No codec extension: the client links no FFmpeg — see the
# manifest header.)
# 1) the Flathub deps the manifest declares — the GNOME 50 runtime/SDK + the
# rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions — but ONLY the ones
# genuinely MISSING; see the block below. (No codec extension: the client links no
# FFmpeg — see the manifest header.)
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
@@ -288,9 +292,40 @@ jobs:
# for the mechanism.
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
# load-sensitive runner, same flathub.org resolution path.
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
#
# WHY THIS IS NOT AN UNCONDITIONAL `--install-deps-only` ANY MORE (2026-08-22):
# that flag does not install what is missing, it UPDATES what is present.
# builder_manifest_install_dep() branches on `flatpak info --show-commit <ref>` succeeding
# and runs `flatpak update` for every dep already installed — with no fallback to a
# plain install when that update fails — and ci/flatpak-ci.Dockerfile bakes
# the entire runtime set, so on a healthy run it was a pure no-op that nonetheless made
# every build depend on Flathub being healthy at that minute. It bit on 2026-08-22:
# Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
# Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While pulling …
# .filez: Server returned HTTP 404
# dl.flathub.org served a 404 for one object of the then-current rust-stable//25.08
# commit, deterministically — all 10 retry.sh attempts died on the SAME object over
# ~9 min — and flatpak-builder SEGFAULTED on its own error path (rc=139), so retry.sh
# saw a crash rather than a clean "this will never work" either. The build never wanted
# that newer commit: the manifest pins a runtime VERSION, not a commit, and the baked
# one satisfies it. Updating bought nothing and imported an upstream outage.
#
# So: assert what the image already has, and reach for Flathub only on a real miss —
# the same "guard, don't install on top of a stale image" doctrine as the Tooling step.
# The check lives in scripts/ci/flatpak-deps-present.sh (run its --self-test after
# touching it): a bug in it that reports "satisfied" when it is not would build against
# whatever runtime happened to be lying around, which is worth more than an inline
# if-statement. It deliberately fails OPEN — anything it cannot parse takes the slow
# install path below.
if bash scripts/ci/flatpak-deps-present.sh "$MANIFEST"; then
echo "deps satisfied by the baked image — not touching Flathub"
flatpak list --user --columns=ref
else
echo "::warning::$MANIFEST declares deps punktfunk-flatpak-ci does not have — pulling from Flathub (~1.5 GB). Bump GNOME_VERSION/FREEDESKTOP_VERSION in ci/flatpak-ci.Dockerfile so this stays off the hot path."
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
fi
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only --disable-updates \
"$PWD/build-dir" "$MANIFEST"
@@ -298,7 +333,17 @@ jobs:
- name: Build the flatpak (offline — deps + sources prefetched above)
run: |
# Everything is already local (state dir warmed by the prefetch step), so this long
# step needs no network; --install-deps-from stays as a no-op safety net.
# step needs no network.
#
# --install-deps-from=flathub USED to sit here, commented as "a no-op safety net". It
# was neither. builder-main.c calls builder_manifest_install_deps() whenever that flag
# is set — --install-deps-only only decides whether it EXITS afterwards — so this step
# re-ran the same `flatpak update` of the runtimes that killed the prefetch step on
# 2026-08-22 (Flathub HTTP 404 on a rust-stable//25.08 object; see there). A live pull
# of multi-GB runtimes is a strange thing to call a safety net in the step whose whole
# design is to be offline, and it could only ever fire if the prefetch step above had
# already failed the job. Dropped: the prefetch step is the one place that talks to
# Flathub, and it is the one place with retries.
#
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
@@ -326,7 +371,6 @@ jobs:
flatpak-builder --user --force-clean --disable-rofiles-fuse \
--default-branch="$FLATPAK_BRANCH" \
--disable-updates \
--install-deps-from=flathub \
--repo="$PWD/repo" \
"$PWD/build-dir" "$MANIFEST"
@@ -515,8 +515,21 @@ class MainActivity : ComponentActivity() {
else -> KeyEvent.KEYCODE_DPAD_RIGHT
}
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
/**
* Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate].
*
* NEVER on a TV, which leaves the id at `0` and makes every [setConsoleHighRefreshRate] call a
* no-op. The pin exists for phone refresh governors that cap third-party apps at 60 Hz; a TV has
* no such governor, and there it does active harm. `display.mode` is what [nativeDisplayMode]
* reads to resolve "Native" refresh at connect, so a menu-time pin makes the session negotiate
* the PINNED rate rather than the TV's real HDMI output — and [StreamScreen] then releases the
* pin on TV (the decoder's own mode switch governs there), dropping the panel back to 60 while
* the host is already serving 120. Every frame then waits out that mismatch, which is the
* "latency explodes unless I set the refresh by hand" field report: picking a refresh explicitly
* is precisely what bypasses the corrupted `nativeDisplayMode` answer.
*/
private fun resolveHighRefreshMode() {
if (isTvDevice(this)) return
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
@@ -478,7 +478,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
val mode = display.mode
val w = mode.physicalWidth
val h = mode.physicalHeight
val hz = mode.refreshRate.toInt().coerceAtLeast(1)
// ROUNDED, not truncated: TVs report the fractional NTSC rates over HDMI (59.94, 29.97,
// 23.976), and `toInt()` turns 59.94 into 59 — a rate no display mode anywhere has, which the
// host then serves by clamping DOWN to the highest mode it advertises at or below it. Rounding
// also keeps this agreeing with `MainActivity.streamPanelFps`, which already rounds; the two
// describe the same panel and must not disagree.
val hz = kotlin.math.round(mode.refreshRate).toInt().coerceAtLeast(1)
return Triple(maxOf(w, h), minOf(w, h), hz)
}
@@ -367,6 +367,38 @@ fn takeover_state_is_live(state: &TakeoverState) -> bool {
|| state.forced_screen_env
}
/// Restart the box's own autologin gaming session(s) after a leftover idle drop-in was swept off
/// a host that died holding one ([`restore_takeover_on_startup`]).
///
/// Gated on the box actually being dark ([`box_session_live`]): if the user is already in game mode
/// or on a desktop, the drop-in we removed was inert and bouncing their session would be the bug.
/// Only an ACTIVE instance is restarted — under a just-removed idle drop-in, active means "running
/// the sleep"; an inactive one is a leftover the display manager will handle on its own.
fn hand_back_idled_units_after_crash() {
if box_session_live() {
return; // something is already drawing — the drop-in was inert
}
let units: Vec<String> = listed_autologin_units()
.into_iter()
.filter(|(_, active)| active == "active")
.map(|(unit, _)| unit)
.collect();
if units.is_empty() {
return;
}
tracing::warn!(
?units,
"gamescope: the box's Game Mode is running the dead host's idle placeholder and its panel \
is dark restarting it"
);
for unit in &units {
if let RestoreVerb::Failed(why) = issue_restore_verb(&["restart", unit]) {
tracing::error!(unit, status = %why, "gamescope: could not restart it");
}
}
ensure_box_session_or_escalate(&units);
}
/// On host startup, restore the TV's gaming session if a previous host instance took it over and
/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted
/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a
@@ -399,6 +431,13 @@ pub fn restore_takeover_on_startup() {
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
own Game Mode session would have started and then done nothing"
);
// Removing the FILE does not touch the unit RUNNING under it. That unit's `ExecStart` was
// replaced with a sleep, so it is `active` and drawing nothing, and nothing below will
// restart it: the takeover file may be absent, unparseable, or not `takeover_state_is_live`
// — and all three of those exits used to leave the box sitting on a dark panel with its
// Game Mode "running". A host killed mid-stream (SIGKILL, OOM, a yanked update) lands
// exactly there, and on glass it is indistinguishable from broken hardware. Hand it back.
hand_back_idled_units_after_crash();
}
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
return; // no takeover file — clean start
@@ -2984,6 +3023,48 @@ fn replay_switch_under_restored_dm(dm: &str) {
}
}
/// The box's autologin gaming instances and their ACTIVE state, as `(unit, active)` pairs — the
/// `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION, so the state is the third.
///
/// An unanswered query reads as "none listed", which is the safe direction for both callers: the
/// takeover then frees nothing rather than killing a session it could not see properly, and the
/// crash hand-back restarts nothing rather than bouncing one.
fn listed_autologin_units() -> Vec<(String, String)> {
let Ok(out) = crate::proc::output_within(
Command::new("systemctl").args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
]),
UNIT_QUERY_BUDGET,
) else {
return Vec::new();
};
parse_listed_units(&String::from_utf8_lossy(&out.stdout))
}
/// [`listed_autologin_units`]'s parser (the unit-testable core). Which column the ACTIVE state is
/// in decides whether the takeover can tell a live gaming session from a dead leftover, and
/// getting that wrong is silent in both directions — a live session read as dead leaves Steam
/// holding the instance our own launch then collides with, and a dead one read as live idles a
/// session nobody was in.
fn parse_listed_units(stdout: &str) -> Vec<(String, String)> {
stdout
.lines()
.filter_map(|l| {
let mut cols = l.split_whitespace();
let unit = cols.next()?;
let active = cols.nth(1).unwrap_or("");
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
.then(|| (unit.to_string(), active.to_string()))
})
.collect()
}
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// single-instance Steam is free for our own host-managed session. Records the units so
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
@@ -3011,33 +3092,9 @@ fn replay_switch_under_restored_dm(dm: &str) {
/// The ORDER is therefore load-bearing and not a style choice: stop the DM, bail if it did not
/// land, and only then mask. A mask laid before a stop that never arrives is the storm.
fn stop_autologin_sessions() -> Result<()> {
let Ok(out) = crate::proc::output_within(
Command::new("systemctl").args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--plain",
"gamescope-session-plus@*.service",
]),
UNIT_QUERY_BUDGET,
) else {
return Ok(());
};
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let mut cols = l.split_whitespace();
let unit = cols.next()?;
let active = cols.nth(1).unwrap_or("");
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
.then(|| (unit.to_string(), active.to_string()))
})
.collect();
let listed = listed_autologin_units();
if listed.is_empty() {
return Ok(()); // nothing autologged in — Steam is already free
return Ok(()); // nothing autologged in (or the query failed) — Steam is already free
}
let dm = display_manager_unit();
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
@@ -3439,7 +3496,13 @@ pub fn restore_takeover_now() {
}
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
do_restore_tv_session();
// `verify: false` — the escalation ladder waits up to a minute, and this runs inside
// `native.rs`'s 20 s `SHUTDOWN_RESTORE_GRACE`, after which `exit(0)` runs no destructors.
// Spending that grace watching instead of restoring would COST the hand-back, not check it.
// The next host start is what covers a shutdown that left the box dark
// ([`restore_takeover_on_startup`], which now hands the box back rather than only sweeping the
// drop-in off it).
do_restore_tv_session(false);
}
/// What a bounded `systemctl --user` lifecycle verb on the RESTORE path actually did. Three states,
@@ -3503,11 +3566,168 @@ fn connected_connector_under(base: &std::path::Path) -> bool {
})
}
/// How long a hand-back waits for the box to show something on its own panel before it starts
/// escalating. Generous on purpose: the unit's `ExecStart` is a whole gamescope + Steam start, and
/// on a cold box that is not quick — while a false escalation costs the user a session bounce.
const HANDBACK_GRACE: Duration = Duration::from_secs(25);
/// How long each escalation rung gets. Shorter than [`HANDBACK_GRACE`]: by the time a rung runs,
/// the ordinary start has already had its full grace and not delivered.
const HANDBACK_RUNG_GRACE: Duration = Duration::from_secs(15);
/// Poll slice for the two waits above.
const HANDBACK_POLL: Duration = Duration::from_millis(500);
/// Is ANYTHING driving the box's own panel right now — its game mode, or a desktop it switched to?
///
/// [`super::detect_active_session`] answers precisely the question the symptom asks: it reports the
/// running compositor of our uid, and [`super::ActiveKind::None`] means nothing is drawing
/// anywhere. Only sound AFTER `stop_session(SESSION_UNIT)` has killed our own managed session —
/// that kill is a synchronous SIGKILL ([`kill_unit`]), so by the restore's escalation point our
/// gamescope cannot still be answering for the box.
fn box_session_live() -> bool {
super::detect_active_session().kind != super::ActiveKind::None
}
/// Poll [`box_session_live`] until it is true or `grace` runs out. [`HandbackWait::Superseded`]
/// means a client reconnected and took the box over again — the hand-back we were checking is moot,
/// and every remedy below would now be fighting a live stream for the box's session.
enum HandbackWait {
Live,
Superseded,
TimedOut,
}
fn wait_for_box_session(grace: Duration) -> HandbackWait {
let deadline = Instant::now() + grace;
loop {
if takeover_live() {
return HandbackWait::Superseded;
}
if box_session_live() {
return HandbackWait::Live;
}
if Instant::now() >= deadline {
return HandbackWait::TimedOut;
}
std::thread::sleep(HANDBACK_POLL);
}
}
/// **The hand-back's last line of defence for a dark panel**, and the only part of this file that
/// checks whether the restore it just performed actually WORKED.
///
/// Everything above issues a lifecycle verb and reports what systemd said about the JOB. That is
/// not the same question as "does the box show a picture again", and the gap between the two is
/// where every "my screen stays black after disconnecting" report lives — including ones whose
/// trigger nobody has reproduced. So stop inferring the outcome and measure it: if nothing is
/// driving the panel a full [`HANDBACK_GRACE`] after the hand-back, climb a ladder of remedies,
/// each of which is a mechanism measured on both distro families (Bazzite `44.20260818`, Nobara
/// f44, 2026-08-22), and say loudly at every rung what is happening.
///
/// 1. **`stop` the autologin unit.** Its login session's script is parked on
/// `systemctl --user --wait start <unit>` (verified on both images), so stopping the unit
/// releases that wait, the session exits, and `Relogin=true` logs straight back in — starting
/// the unit inside a fresh login session with a seat. `stop`, never `restart`: a restart does
/// NOT release the parked waiter (measured), which is exactly why it cannot rescue a box the
/// ordinary restart already failed to bring back.
/// 2. **Restart the display manager.** What the pre-0.31.0 takeover did on every disconnect, and
/// proven to return the box to game mode. Needs privilege, so it can honestly fail.
/// 3. **`PUNKTFUNK_RECOVER_SESSION_CMD`**, then an ERROR naming the command a human must run.
///
/// **Detached**, and that is not incidental. The restore runs under [`RESTORE_FLIGHT`], which a
/// reconnecting client must take before it can re-take the box; watching for up to a minute while
/// holding it would put that whole wait in front of every reconnect. So the caller fires this and
/// returns, and the watcher stands down by itself the moment [`takeover_live`] says a new takeover
/// armed — the box belongs to that stream now, and a remedy fired into it would be the bug.
/// Call it AFTER `clear_takeover()`, or the very first poll reads our own finished takeover as a
/// new one and stands down immediately.
///
/// A box that was already fine costs one [`box_session_live`] call and the thread exits.
fn ensure_box_session_or_escalate(units: &[String]) {
let units: Vec<String> = units.to_vec();
std::thread::spawn(move || handback_watch(&units));
}
fn handback_watch(units: &[String]) {
match wait_for_box_session(HANDBACK_GRACE) {
HandbackWait::Live => {
tracing::info!(
"gamescope: the box is driving its own panel again — hand-back complete"
);
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
}
tracing::warn!(
secs = HANDBACK_GRACE.as_secs(),
units = ?units,
"gamescope: NOTHING is driving the box's panel {}s after the hand-back — its screen is \
dark. Escalating: stopping the autologin unit so the display manager relogins into a \
session with a seat",
HANDBACK_GRACE.as_secs()
);
// Rung 1 — release the login session's parked `--wait start` and let the DM relogin.
for unit in units {
if let RestoreVerb::Failed(why) = issue_restore_verb(&["stop", unit]) {
tracing::warn!(unit, status = %why, "gamescope: could not stop the autologin unit");
}
}
match wait_for_box_session(HANDBACK_RUNG_GRACE) {
HandbackWait::Live => {
tracing::info!(
"gamescope: the display manager relogged the box into its own session — panel back"
);
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
}
// Rung 2 — put the display manager itself through a restart.
if let Some(dm) = display_manager_unit() {
tracing::warn!(
%dm,
"gamescope: the box is still dark — restarting its display manager"
);
match restore_display_manager(&dm) {
Ok(()) => match wait_for_box_session(HANDBACK_RUNG_GRACE) {
HandbackWait::Live => {
tracing::info!(%dm, "gamescope: the display manager brought the box back");
return;
}
HandbackWait::Superseded => return,
HandbackWait::TimedOut => {}
},
Err(why) => tracing::warn!(
%dm,
shape = why.shape(),
reason = %why,
"gamescope: could not restart the display manager"
),
}
}
// Rung 3 — the operator's own escape hatch, then say what is left to do by hand.
if crate::try_recover_session() {
tracing::warn!(
"gamescope: fired PUNKTFUNK_RECOVER_SESSION_CMD to bring the box's session back"
);
return;
}
tracing::error!(
units = ?units,
"gamescope: the box has NO session driving its panel and every automatic remedy failed — \
its screen stays dark until someone runs `systemctl --user restart <unit>` for one of \
these, or `sudo systemctl restart display-manager.service`. Set \
PUNKTFUNK_RECOVER_SESSION_CMD to let the host do this itself"
);
}
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
/// cancelled+reconnected window keeps the list for a later real restore.
fn do_restore_tv_session() {
fn do_restore_tv_session(verify: bool) {
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
@@ -3574,6 +3794,9 @@ fn do_restore_tv_session() {
),
}
clear_takeover(); // A3: consumed — after the restart, not before it
if verify {
ensure_box_session_or_escalate(&[STEAMOS_SESSION_TARGET.to_string()]);
}
return;
}
}
@@ -3699,14 +3922,14 @@ fn do_restore_tv_session() {
}
// (The idle drop-in is already gone — removed above every early return, so the restarts
// below bring the box's real session back rather than another idle one.)
for unit in units {
for unit in &units {
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
// that logged an unconditional success over a thrown-away exit status. A `--user start`
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
// and the DM branch thirty lines up already shows the shape — say what happened.
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
// active unit is a no-op that would report success over a session still running nothing.
match issue_restore_verb(&["restart", &unit]) {
match issue_restore_verb(&["restart", unit]) {
RestoreVerb::Done => tracing::info!(
unit,
"restored the TV's autologin gaming session (debounce elapsed, no client)"
@@ -3731,6 +3954,12 @@ fn do_restore_tv_session() {
}
}
clear_takeover(); // A3: consumed — and only now, with the restarts actually issued
// …and CHECK that the restart above actually put a picture back on the box's panel, rather
// than trusting the job status to mean that. AFTER `clear_takeover`, which is what makes a
// later `takeover_live()` mean "a client reconnected" — see [`ensure_box_session_or_escalate`].
if verify {
ensure_box_session_or_escalate(&units);
}
}
/// Host-lifetime worker that fires a pending [`schedule_restore_tv_session`] once its debounce
@@ -3767,7 +3996,10 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
}
};
if still_due {
do_restore_tv_session();
// The disconnect restore: verified. This is the path the field reports
// are about, it is on a worker thread with no deadline over it, and a box
// left dark here stays dark until someone walks up to it.
do_restore_tv_session(true);
}
}
}
@@ -5334,12 +5566,12 @@ mod tests {
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
nested_wrapper_script, our_wsi_layer_dir, parse_listed_units, plan_bind,
release_autologin_mask, remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced,
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE,
RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
};
use std::time::{Duration, Instant};
@@ -5538,6 +5770,39 @@ mod tests {
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
/// The `--plain` column the ACTIVE state lives in, pinned against real `systemctl --user
/// list-units` output from both distro families. Read the wrong column and a live gaming
/// session looks dead (Steam stays held, and our launch collides with it) or a dead leftover
/// looks live (the takeover idles a session nobody was in) — both silent on glass.
#[test]
fn listed_units_take_the_active_column_not_the_load_column() {
// Bazzite 44.20260818 and Nobara f44, verbatim (unit / LOAD / ACTIVE / SUB / description).
let out = "gamescope-session-plus@ogui-steam.service loaded active running Gamescope Session Plus\n\
gamescope-session-plus@steam.service loaded inactive dead Gamescope Session Plus\n";
assert_eq!(
parse_listed_units(out),
vec![
(
"gamescope-session-plus@ogui-steam.service".to_string(),
"active".to_string()
),
(
"gamescope-session-plus@steam.service".to_string(),
"inactive".to_string()
),
]
);
// `loaded` is the LOAD column and must never be mistaken for the state — that is the
// off-by-one this pins.
assert!(parse_listed_units(out).iter().all(|(_, a)| a != "loaded"));
// Anything that is not one of our template's instances is not ours to touch.
assert!(
parse_listed_units("plasma-plasmashell.service loaded active running Shell\n")
.is_empty()
);
assert!(parse_listed_units("").is_empty());
}
#[test]
fn idle_dropin_replaces_exec_start_rather_than_appending() {
let body = idle_dropin_body("/usr/bin/sleep");
@@ -26,17 +26,26 @@
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
use anyhow::Result;
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
};
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
/// sweep needs.
const OWNER_MARKERS: [&str; 3] = [
pub(crate) const OWNER_MARKERS: [&str; 3] = [
pe::PAD_INDEX_VALUE,
minted::ROLE_MARKER,
audio_probe::PROBE_MARKER,
];
/// The Steam streaming hardware ids every audio devnode this product mints is created with —
/// the second half of the ABANDONED-devnode test in [`owned_devnodes`].
const MINTED_HWIDS: [&str; 2] = [
"ROOT\\SteamStreamingSpeakers",
"ROOT\\SteamStreamingMicrophone",
];
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
/// registry half is best-effort by design — see [`delete_endpoint_record`].
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
@@ -117,11 +126,91 @@ fn owned_devnodes() -> Result<Vec<String>> {
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
out.push(inst);
continue;
}
// ABANDONED: `ROOT\MEDIA\NNNN` carrying one of our minting hardware ids but no marker at
// all — a devnode registered by a host that died before the marker write landed. It is
// still bound and still serving endpoints, so leaving it behind is the "uninstalling
// punktfunk left Sound settings full of Punktfunk devices forever" report all over again.
//
// The instance prefix is what makes this safe, and it is NOT redundant with
// [`is_removable_instance`]: Steam's own devnodes carry these very hardware ids and are
// ROOT-enumerated too, but live under `ROOT\SteamStreamingSpeakers\*` /
// `ROOT\SteamStreamingMicrophone\*`. Only `ROOT\MEDIA\*` can have come from our
// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)`.
if is_abandoned_mint(
&inst,
&pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID),
) {
out.push(inst);
}
}
Ok(out)
}
/// The ABANDONED-devnode test, split out from the PnP enumeration so the rule that keeps this
/// sweep off VALVE'S OWN devices is checkable without a live devinfo set. See [`owned_devnodes`].
fn is_abandoned_mint(instance_id: &str, hwids: &[String]) -> bool {
instance_id
.to_ascii_uppercase()
.starts_with("ROOT\\MEDIA\\")
&& MINTED_HWIDS
.iter()
.any(|want| hwids.iter().any(|h| h.eq_ignore_ascii_case(want)))
}
#[cfg(test)]
mod abandoned_tests {
use super::is_abandoned_mint;
fn hw(s: &str) -> Vec<String> {
vec![s.to_string()]
}
#[test]
fn adopts_our_own_unmarked_devnodes() {
// What a host that died mid-mint leaves behind, either role.
assert!(is_abandoned_mint(
r"ROOT\MEDIA\0004",
&hw(r"ROOT\SteamStreamingMicrophone")
));
assert!(is_abandoned_mint(
r"ROOT\MEDIA\0002",
&hw(r"ROOT\SteamStreamingSpeakers")
));
// PnP casing is not guaranteed on either half.
assert!(is_abandoned_mint(
r"root\media\0009",
&hw(r"root\steamstreamingspeakers")
));
}
#[test]
fn never_matches_valves_own_devices() {
// THE safety rule: Steam's devnodes carry the very same hardware ids and are ROOT-
// enumerated too — only the instance prefix separates them from ours.
assert!(!is_abandoned_mint(
r"ROOT\STEAMSTREAMINGMICROPHONE\0000",
&hw(r"ROOT\SteamStreamingMicrophone")
));
assert!(!is_abandoned_mint(
r"ROOT\STEAMSTREAMINGSPEAKERS\0000",
&hw(r"ROOT\SteamStreamingSpeakers")
));
}
#[test]
fn never_matches_other_vendors_or_real_hardware() {
// VB-Cable mints ROOT\MEDIA devnodes too — a different hardware id is all that saves it.
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0000", &hw("VBAudioVACWDM")));
assert!(!is_abandoned_mint(
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0897",
&hw(r"ROOT\SteamStreamingSpeakers")
));
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0001", &[]));
}
}
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
///
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
@@ -275,13 +275,22 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?;
let devnode = match find_role_devnode(role)? {
Some(inst) => inst,
None => {
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
})?;
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
inst
}
// Before minting a SECOND devnode, reclaim an abandoned one. Minting is two PnP steps
// (register, then mark), and a host that dies between them — the 0.30.0 teardown abort
// did exactly this, five times on one box — leaves a registered, driver-bound, endpoint-
// serving devnode that carries no marker. Nothing then resolves it: the next pass mints
// a fresh one and the orphan lingers as a duplicate "Punktfunk Speakers"/"Punktfunk
// Microphone" in the Sound zoo, invisible to the marker-matched uninstall sweep.
None => match adopt_orphan_devnode(role, &hwid)? {
Some(inst) => inst,
None => {
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
})?;
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
inst
}
},
};
pe::bind_driver(&hwid, &inf)?;
@@ -531,6 +540,61 @@ fn find_role_devnode(role: Role) -> Result<Option<String>> {
Ok(None)
}
/// Reclaim an ABANDONED punktfunk devnode for `role`, re-marking it so it resolves normally from
/// here on; `None` when there is nothing to adopt (the ordinary first-mint path).
///
/// The shape adopted is `ROOT\MEDIA\NNNN` + the role's Steam hardware id + NO owner marker.
/// That triple can only be ours: `ROOT\MEDIA\NNNN` is what
/// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA class yields, and STEAM'S OWN
/// devnodes are enumerated under `ROOT\SteamStreamingSpeakers\*` /
/// `ROOT\SteamStreamingMicrophone\*` — they carry the same hardware id but never that instance
/// prefix, which is precisely what keeps this from adopting (and later sweeping) Steam's devices.
/// A marker of ANY family is left alone: it is a live devnode, ours but spoken for.
///
/// Which family the orphan came from does not matter. Every one is a plain instance of the same
/// Valve driver; roles are ours to assign, and re-marking it here is what makes the assignment
/// stick across restarts.
fn adopt_orphan_devnode(role: Role, hwid: &str) -> Result<Option<String>> {
use windows::Win32::Devices::DeviceAndDriverInstallation::{
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
};
let set = pe::media_class_devs()?;
for i in 0.. {
let mut did = pe::devinfo_data();
// SAFETY: live set; `did` is a live out-param with cbSize set.
if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() {
break; // ERROR_NO_MORE_ITEMS
}
let Some(inst) = pe::instance_id(&set, &did) else {
continue;
};
if !inst.to_ascii_uppercase().starts_with("ROOT\\MEDIA\\") {
continue;
}
if !pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID)
.iter()
.any(|h| h.eq_ignore_ascii_case(hwid))
{
continue;
}
if super::devnode_cleanup::OWNER_MARKERS
.iter()
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
{
continue;
}
pe::write_devparam_dword(&set, &mut did, ROLE_MARKER, role.value())?;
tracing::warn!(
role = role.label(),
devnode = %inst,
"adopted an abandoned audio devnode — one of ours whose owner marker never landed \
(a host that died mid-mint). Re-marked and reused instead of minting a duplicate"
);
return Ok(Some(inst));
}
Ok(None)
}
/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any
/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver
/// Windows already trusts), else fall back to Steam's driver directory. Shared with the
@@ -1192,6 +1192,17 @@ fn grant_system_full_control(subkey_path: &str) -> Result<()> {
result
}
/// The MMDevices hive an endpoint's record lives in, chosen by the direction its id encodes
/// (`{0.0.1.…}` = capture, anything else = render). Render is the safe default: it is what every
/// non-capture id resolves to, and the pad program only ever has render endpoints.
fn mmdev_path_for(endpoint_id: &str) -> &'static str {
if endpoint_id.starts_with(CAPTURE_ENDPOINT_ID_PREFIX) {
MMDEV_CAPTURE_PATH
} else {
MMDEV_RENDER_PATH
}
}
/// The raw-registry stamp route: repair the Properties key ACL, then write the serialized
/// values (see [`reg_registry_value`]). Values written here are STORED but possibly not
/// SERVED until an AudioEndpointBuilder restart — the caller's read-back decides.
@@ -1199,7 +1210,14 @@ fn registry_stamp(endpoint_id: &str, stamps: &[&Stamp]) -> Result<()> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
let guid = endpoint_guid_part(endpoint_id)?;
let path = format!(r"{MMDEV_RENDER_PATH}\{guid}\Properties");
// The hive follows the endpoint's DIRECTION. This was hardcoded to Render, which is
// invisible for the pad program (its endpoints are render-only) but wrong for the minted
// provider, which stamps the virtual microphone's CAPTURE endpoint through the same
// writer: the fallback then reached for `…\Render\{capture-guid}\Properties`, a key that
// cannot exist, so every registry-route stamp of a capture endpoint failed on a box where
// the property store was denied — silently, since the caller degrades to "keeps the
// driver's default name".
let path = format!(r"{}\{guid}\Properties", mmdev_path_for(endpoint_id));
grant_system_full_control(&path)
.with_context(|| format!("make {path} writable (registry stamp route)"))?;
let key = RegKey::predef(HKEY_LOCAL_MACHINE)
@@ -2109,6 +2127,23 @@ fn pad_capture_thread(
mod tests {
use super::*;
/// The registry stamp route must reach for the hive matching the endpoint's DIRECTION —
/// it was hardcoded to Render, so a capture endpoint's fallback stamp could never land.
#[test]
fn registry_stamp_hive_follows_the_endpoint_direction() {
assert_eq!(
mmdev_path_for("{0.0.1.00000000}.{2753f927-2093-4ab4-aa90-9d880e959128}"),
MMDEV_CAPTURE_PATH,
"the minted microphone's capture endpoint records under Capture"
);
assert_eq!(
mmdev_path_for("{0.0.0.00000000}.{5da9b5c9-8a10-4b54-8cf6-ce02b8354f16}"),
MMDEV_RENDER_PATH,
);
// Anything unrecognised keeps the old behaviour rather than inventing a hive.
assert_eq!(mmdev_path_for("nonsense"), MMDEV_RENDER_PATH);
}
/// The serialized container blob for pad 0 must be byte-for-byte the on-glass-measured
/// value, and byte 23 must be the pad index.
#[test]
+106 -2
View File
@@ -590,6 +590,9 @@ fn watch(
// ---- Phase 1: wait for the game to show up. ----
let start_deadline = spawned_at + START_GRACE;
// How long the scan has *continuously* seen something for this title — the scan-side twin of
// [`SHIM_WINDOW`]. See `scan_settled` below for what it is protecting against.
let mut seen_since: Option<Instant> = None;
loop {
if cancelled() {
return;
@@ -713,12 +716,39 @@ fn watch(
&& (child.is_some() || spawned.is_some())
&& spawned_at.elapsed() >= SHIM_WINDOW;
let live = scanner.find(&shared.spec, shared.launch_stamp);
// The same rule for what the *scan* finds, and for the same reason. A store's launch is a
// chain of process trees, and the ones that run before the game carry the signals the game
// carries: Steam wraps its shader pre-caching and its Proton prefix work in the very
// `reaper SteamLaunch AppId=<appid>` the game gets, so the first poll of a launch can match
// a tree that was never the game.
//
// Latching on one poll is what costs, because the two phases are patient in opposite ways.
// This one waits [`START_GRACE`] — five minutes — and ending it never ends the session.
// Phase 2 waits [`EXIT_CONFIRM`] — three seconds — and ending it *does*. A single sighting
// flips the lease from the first to the second, permanently; when that tree then exits with
// the real game not yet started, the stream drops mid-launch. On Linux that ended a Rocket
// League session 10 s after launch, while Steam was still compiling its shaders, and the
// player had to launch a second time to get one that stayed up (field report 2026-08-22).
//
// Requiring the sighting to persist buys that back for a few seconds of `GameRunning`
// latency and nothing else — exit detection is untouched. ⚠ It is a window, not a proof: a
// pre-launch tree that outlives the window still latches. Signals sharp enough to tell one
// from the other belong in [`crate::procscan`] (where Steam's shader job is already excluded
// by name); this bounds what no signal caught.
let scan_settled = if live.is_empty() {
seen_since = None;
false
} else {
seen_since.get_or_insert_with(Instant::now).elapsed() >= SHIM_WINDOW
};
// A provider saying so is as good as seeing it — better, for a title there is nothing to
// see: it is the launcher that started the game telling us it did. This is the only way a
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
// gets there sooner than the scan would.
// gets there sooner than the scan would. Not gated by the window above: a report is the
// launcher's own statement about the game, not an inference from a process that resembles
// it, so there is nothing to wait out.
let said_running = reported().is_some_and(|l| l.running);
if !live.is_empty() || child_alive || said_running {
if scan_settled || child_alive || said_running {
known = live.clone();
publish(&live);
shared.was_running.store(true, Ordering::Relaxed);
@@ -731,6 +761,8 @@ fn watch(
title = %shared.game.title,
kind = kind.as_str(),
procs = live.len(),
// Which processes, not just how many: see [`crate::procscan::names`].
names = ?crate::procscan::names(&live),
"the launched game is running"
);
break;
@@ -2019,6 +2051,78 @@ mod tests {
);
}
/// 🛑 The 2026-08-22 field report: a **pre-launch** process tree must not be mistaken for the
/// game.
///
/// Steam wraps its shader pre-caching in the same `SteamLaunch AppId=` reaper the game itself
/// gets, so the first poll of a launch matches a tree that was never the game. What shipped
/// latched on that single sighting: the lease left the start phase immediately, and when the
/// compile finished and that tree exited — with Rocket League still starting — the exit watch
/// called it the game exiting and closed the session with `APP_EXITED`, 10 s after launch. On
/// the player's screen the stream dropped mid-"Processing Vulkan shaders"; their workaround was
/// to launch the game twice.
///
/// The scanner now knows Steam's replayer by name ([`crate::procscan`]). This pins the bound
/// behind that: a matched process that does not outlive [`SHIM_WINDOW`] never arms the exit
/// watch, whatever it was — which is what covers the pre-launch trees nobody has named yet.
///
/// Ignored by default: it outlives the shim window and then waits out [`EXIT_CONFIRM`], ~11 s.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "drives a real process for ~11s (shim window + exit confirmation)"]
fn a_pre_launch_tree_that_exits_never_ends_the_session() {
use std::sync::atomic::AtomicUsize;
// The stand-in has to keep the name `sleep`: coreutils is a multi-call binary that
// dispatches on `argv[0]`, and under any other name it exits instantly — which would pass
// this test for entirely the wrong reason. (Same trap as the live matcher test in
// [`crate::procscan`].)
let td = tempfile::tempdir().expect("tempdir");
let stand_in = td.path().join("sleep");
std::fs::copy("/bin/sleep", &stand_in).expect("copy a stand-in pre-launch binary");
let launch_stamp = launch_clock();
// Alive for less than the shim window — Steam's shader job, in miniature.
let mut child = std::process::Command::new(&stand_in)
.arg("3")
.spawn()
.expect("spawn the fake pre-launch tree");
// Reaped on its own thread: a zombie keeps its `/proc` entry with an unchanged start time,
// so the scan would call it alive forever and the exit under test never happen.
std::thread::spawn(move || {
let _ = child.wait();
});
static PRE_EXITS: AtomicUsize = AtomicUsize::new(0);
PRE_EXITS.store(0, Ordering::SeqCst);
let lease = open(
LeaseRequest {
launch_stamp,
// No child and no pid: the scan is the only signal, which is the field-report shape
// (`steam steam://rungameid/…` had already handed off and exited).
..req("steam:pre-launch", DetectSpec::dir(td.path()), false)
},
Box::new(|| {
PRE_EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
let shared = lease.shared();
assert!(matches!(shared.kind(), LeaseKind::Matched));
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(3));
assert_eq!(
PRE_EXITS.load(Ordering::SeqCst),
0,
"a tree that ran before the game must not end the session when it exits — this is the \
field report"
);
assert_ne!(
shared.state(),
GameState::Exited,
"the game never started, so nothing of it can have exited"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
+121 -3
View File
@@ -1111,6 +1111,21 @@ fn spawn_sender(
use crate::send_pacing::percentile;
/// How long to ignore further keyframe requests after emitting one.
///
/// The window bounds IDR emission in TIME, so it needs an absolute floor rather than a frame
/// count: it has to outlast the round trip in which the client receives and decodes the IDR it
/// already asked for. The original `frame_interval * 2` closes long before that at high refresh —
/// 16.7 ms at 120 fps, while a Moonlight client under loss re-asks every ~30 ms — so every request
/// passed the gate and the stream became ~32 full IDRs/s, whose bulk causes the very loss that
/// prompts the next request. That storm sustains itself and reads as stutter at a flat latency
/// (field log, AMD RX 7800 XT / Bazzite 44 HEVC, 2026-08-22: 1118 requests, 1115 honoured, 3
/// coalesced). 100 ms matches the encoder-reset backoff below and is about one IDR's service time
/// on a saturated link.
fn keyframe_coalesce_window(frame_interval: Duration) -> Duration {
(frame_interval * 2).max(Duration::from_millis(100))
}
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
#[allow(clippy::too_many_arguments)]
@@ -1194,6 +1209,11 @@ fn stream_body(
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
// and a deeper ring is free pipelining the fallback would forfeit.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
// What `enc` was opened against. The capture source can change size/format UNDER this loop with
// nothing negotiating it (see the follow-the-source guard below); tracked so the loop can notice.
// Both sites that swap `enc` re-bind `frame` with it, so this is always
// `(frame.format, frame.width, frame.height)` right after one.
let mut enc_src = (frame.format, frame.width, frame.height);
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
.ok()
@@ -1273,9 +1293,9 @@ fn stream_body(
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
let keyframe_coalesce = frame_interval * 2;
// we ignore further keyframe requests for the in-flight window below. NVENC ref-invalidation
// (cheap, no IDR spike) is never rate-limited — only full keyframes are.
let keyframe_coalesce = keyframe_coalesce_window(frame_interval);
let mut last_keyframe: Option<Instant> = None;
// A frame dropped at the pipeline head (below) breaks the reference chain for the following
// P-frames: the client never receives it, but the encoder advanced its references past it, and —
@@ -1362,6 +1382,7 @@ fn stream_body(
.context("reopen encoder after rebuild")?;
// A rebuilt encoder starts unconfigured — same reason as the first open above.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
enc_src = (frame.format, frame.width, frame.height);
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
@@ -1375,6 +1396,82 @@ fn stream_body(
}
}
let t_cap = tick.elapsed();
// Follow an AUTONOMOUS source mode change — one nothing negotiated. The IDD-push capturer
// re-opens its ring on a confirmed display-descriptor change (a fullscreen game mode-setting
// the virtual display, or an HDR flip changing the format), and the encoder is the one
// component that cannot follow a resolution change in place. Every `submit` below then
// refuses the frame ("captured WxH != encoder AxB"), and the submit ladder only rebuilds the
// encoder IN PLACE — at the SAME configured size — which cannot fix a size the source has
// already left, so all five resets burn on it and the stream ends (native/stream.rs carried
// the identical gap; a 2026-08-22 field report hit it there at 4K→1080p).
//
// GameStream has no mid-stream mode-change message, so the client is NOT told: Moonlight
// decodes a bitstream that disagrees with the resolution it configured its decoder from.
// That is the same bargain the first open above already takes whenever the captured size
// differs from the negotiated one (the monitor-mirror case) — tolerant decoders re-init off
// the SPS and scale, a strict one (Media Foundation on Xbox) may stall and drop the session.
// Taking it here too is strictly better than the alternative, which is ending every stream
// the moment a game changes mode.
if enc_src != (frame.format, frame.width, frame.height) {
match encode::open_video(
cfg.codec,
frame.format,
frame.width,
frame.height,
cfg.fps,
cfg.bitrate_kbps as u64 * 1000,
frame.is_cuda(),
// Derived from the delivered format, so an HDR flip re-opens at the right depth.
gs_bit_depth(frame.format),
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 — see the first open
cursor_blend, // same capture cursor mode — see the first open
cfg.slices, // client slicing ceiling — see the first open
) {
Ok(e) => {
tracing::info!(
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
negotiated = ?(cfg.width, cfg.height),
"gamestream: the capture source changed mode mid-stream — reopened the \
encoder at the delivered size (the client is not told; a strict decoder \
may not follow see the note at this guard)"
);
enc = e;
enc_src = (frame.format, frame.width, frame.height);
// A rebuilt encoder starts unconfigured — same reasons as the first open.
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
// The old encoder died with its in-flight submissions — their AUs will never
// arrive, so the numbering prediction restarts at `au_seq` (same reasoning as
// the capture rebuild above). Restart the stall clock for the fresh encoder and
// give it the full reset budget.
enc_inflight = 0;
encoder_resets = 0;
last_au_at = Instant::now();
}
Err(e) => {
// Don't spend the stream on the FIRST failed open: the mode-set that triggered
// this is exactly the kind of event that leaves the driver settling, which is
// what the submit ladder's backoff exists for. Spend the shared reset budget at
// the same exponential pace, re-entering this guard each round — the old encoder
// stays installed and mismatched meanwhile, so it simply keeps failing submit.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS {
return Err(e).context("reopen encoder at the source's new mode");
}
let backoff = frame_interval
.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"gamestream: reopening the encoder at the source's new mode failed — retrying");
next_frame = Instant::now() + backoff;
std::thread::sleep(backoff);
continue;
}
}
}
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
@@ -1716,6 +1813,27 @@ mod tests {
assert_eq!(t.game.title, "/opt/game/run");
}
/// The coalesce window must bound forced IDRs in time, not in frames. A frame-scaled window
/// vanishes exactly where it matters most — at high refresh, where a client's recovery spam
/// arrives far slower than two frame intervals and so passes the gate every time.
#[test]
fn keyframe_coalesce_window_outlasts_a_clients_request_cadence() {
// The observed storm: a 120 fps session against a client re-asking every ~30 ms. The
// pre-floor window was 16.7 ms, so every request became a full IDR.
let at_120 = keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 120.0));
assert!(
at_120 >= Duration::from_millis(100),
"120 fps window {at_120:?} does not outlast a ~30 ms request cadence"
);
// 60 fps was under the floor too (33.3 ms), which is why this is not a 120-only fix.
assert!(keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 60.0)) >= at_120);
// A slow stream keeps the frame-scaled window — the floor only ever raises it.
assert_eq!(
keyframe_coalesce_window(Duration::from_millis(200)),
Duration::from_millis(400)
);
}
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
/// byte-identical, at a peer socket via the paced sendmmsg path.
#[test]
+8 -1
View File
@@ -55,7 +55,14 @@ pub struct DetectSpec {
/// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper
/// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the
/// sharpest signal available: Steam wraps every launch — native or Proton — in
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's.
/// `reaper SteamLaunch AppId=<appid>`.
///
/// ⚠ That reaper is the *appid's*, not the game's. Steam wraps its **pre-launch** work for a
/// title in one too — shader pre-caching most visibly — so a launch is a chain of reaper trees
/// and only the last of them is the game. Reading the first as the game is what dropped a
/// stream 10 s into a Rocket League launch, mid-shader-compile (field report 2026-08-22); the
/// shader job is excluded by name in [`crate::procscan`], and [`crate::gamelease`] waits out a
/// window before believing any of them.
pub steam_appid: Option<u32>,
/// A launcher-stamped environment marker.
pub env_marker: Option<EnvMarker>,
+111
View File
@@ -1875,6 +1875,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
mut cur_display_gen,
built_bitrate,
) = pipe;
// What `enc` was opened against. The capture source can change format/size UNDER this loop with
// no client `Reconfigure` at all — the IDD-push capturer re-opens its ring on a confirmed
// display-descriptor change (a fullscreen game mode-setting the virtual display, an HDR flip) —
// and every backend's `submit` then refuses the frame. Tracked so the loop can FOLLOW the
// source (see the guard in the submit path) instead of dying against an error no in-place
// encoder reset can fix. Every site below that swaps `enc` re-binds `frame` with it, so this is
// always `(frame.format, frame.width, frame.height)` immediately after one.
let mut enc_src = (frame.format, frame.width, frame.height);
// The display exists now, so the portal has answered: settle the cursor plan against what it
// actually negotiated rather than what this session asked for (see `settle_portal_cursor`).
// `mut`: every capture-loss rebuild re-runs `create`, hence re-negotiates.
@@ -2613,6 +2621,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
);
cur_mode = new_mode;
next = std::time::Instant::now();
enc_src = (frame.format, frame.width, frame.height);
// H2/H3: the backend may have honored a different mode than requested — KWin caps
// a virtual output's refresh, or Windows pf-vdisplay rejects a resolution its
// running monitor doesn't advertise and the host falls back to the actual display
@@ -2695,6 +2704,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
trace.as_ref(),
true,
) {
enc_src = (frame.format, frame.width, frame.height);
// The owed AUs died with the old encoder — same bookkeeping as a resize.
inflight.clear();
last_au_at = std::time::Instant::now();
@@ -3388,6 +3398,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
interval = new_interval;
cur_node_id = new_node_id;
cur_display_gen = new_display_gen;
enc_src = (frame.format, frame.width, frame.height);
// The rebuild re-ran `create`, so the portal answered again — possibly a different
// backend's portal (the retarget above), possibly with a different verdict. Settle
// the cursor plan against THIS display, exactly as bring-up did: the retarget arm
@@ -3650,6 +3661,106 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// exactly that volume, so host apps already tone-mapped the content into it and the honest
// mastering description IS the client's panel. (The IDD capturer only knows the generic
// baseline; if the driver ever forwards per-content IDDCX_HDR10_METADATA, prefer that here.)
// Follow an AUTONOMOUS source change — one no client `Reconfigure` announced. The IDD-push
// capturer re-opens its ring on a confirmed display-descriptor change: a fullscreen game
// mode-setting the virtual display (2026-08-22 field report: a 4K60 HEVC session, the game
// switched the display to 1080p mid-play), or an HDR flip changing the frame format. The
// encoder is the one component that cannot follow that in place (same note as
// `try_inplace_resize`), so every `submit` below refuses the frame — and the submit-error
// path only rebuilds the encoder IN PLACE, at the SAME configured size, which cannot fix a
// size the source has already left. All five resets burn on it and the session ends while
// audio keeps running. Reopen at what the source actually delivers instead; the client
// learns the new mode from the `Reconfigured` below and its decoder from the opening IDR.
if enc_src != (frame.format, frame.width, frame.height) {
let actual = delivered_mode(frame.width, frame.height, interval);
// Same per-mode pin the client-initiated resize re-resolves: PyroWave's Automatic rate
// IS a function of the mode, so carrying the old one across a source-driven mode change
// hands it the wrong operating point. H.26x rates are mode-independent (ABR owns them),
// and an explicit client rate is never second-guessed.
let src_kbps = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
resolve_bitrate_kbps_for(plan.codec, 0, &actual, plan.chroma, plan.bit_depth)
} else {
bitrate_kbps
};
let opened = crate::encode::open_video(
plan.codec,
frame.format,
frame.width,
frame.height,
actual.refresh_hz,
src_kbps as u64 * 1000,
frame.is_cuda(),
bit_depth,
plan.chroma,
plan.cursor_blend,
plan.max_slices,
)
.with_context(|| {
format!(
"the capture source changed to {}x{} {:?} mid-session and the encoder could not \
be reopened at it",
frame.width, frame.height, frame.format
)
});
let mut new_enc = match opened {
Ok(e) => e,
Err(e) => {
// Don't spend the session on the FIRST failed open. The mode-set that triggered
// this is exactly the kind of event that leaves the driver settling — the same
// transient the submit path's backoff exists for ("NVENC session open failing
// after a codec switch", 2026-07) — so spend the shared reset budget on it at
// the same exponential pace, re-entering this guard each round. The old encoder
// is still installed and still mismatched; it simply keeps failing submit until
// an open succeeds or the budget runs out.
encoder_resets += 1;
if encoder_resets > MAX_ENCODER_RESETS {
return Err(e).context("encoder reopen at the source's new mode");
}
let backoff = std::cmp::max(
interval,
std::time::Duration::from_millis(100u64 << (encoder_resets - 1).min(4)),
);
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
max = MAX_ENCODER_RESETS,
"reopening the encoder at the source's new mode failed — retrying");
next = std::time::Instant::now() + backoff;
std::thread::sleep(backoff);
continue;
}
};
if let Some(c) = plan.wire_chunk {
new_enc.set_wire_chunking(c);
}
// A rebuilt encoder starts with the ring bound unset — re-report it, as every other
// rebuild site does, or an in-place backend can encode a texture the capturer has
// already rotated and overwritten.
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
tracing::info!(
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
"the capture source changed mode mid-session with no client reconfigure — reopened \
the encoder at the delivered size"
);
enc = new_enc;
enc_src = (frame.format, frame.width, frame.height);
adopt_built_bitrate(&mut bitrate_kbps, src_kbps, &live_bitrate, &retarget_tx);
// The owed AUs died with the old encoder — same bookkeeping as a resize.
inflight.clear();
last_au_at = std::time::Instant::now();
encoder_resets = 0;
// A fresh encoder opens on an IDR — anchor the cooldown.
last_forced_idr = Some(std::time::Instant::now());
// The client's mode slot still says the old size, and its stats/aspect follow it.
// Publish what it is really decoding now, exactly as an accepted resize does.
live_mode.store(
pack_mode(actual.width, actual.height, actual.refresh_hz),
Ordering::Relaxed,
);
let _ = reconfig_result_tx.send(Reconfigured {
accepted: true,
mode: actual,
});
}
let hdr_meta = capturer.hdr_meta().map(|m| client_hdr.unwrap_or(m));
enc.set_hdr_meta(hdr_meta);
let mut resend_meta = hdr_meta != last_hdr_meta;
+20
View File
@@ -87,6 +87,26 @@ pub fn resolve(pid: u32) -> Option<ProcRef> {
}
}
/// Short names for the processes a lease adopted, in `procs` order.
///
/// Diagnostics only — nothing decides anything on these, and they are deliberately not part of
/// [`ProcRef`], which is compared for equality. They exist because a launch that adopted the game
/// and a launch that adopted a *pre-launch* tree logged identically (`procs=1`), which is what left
/// the 2026-08-22 field report unclosable from its log: the one question worth asking of that line
/// is which process the lease latched onto.
pub fn names(procs: &[ProcRef]) -> Vec<String> {
#[cfg(any(target_os = "linux", windows))]
{
let scanner = Scanner::system();
procs.iter().map(|p| scanner.name_of(*p)).collect()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
let _ = procs;
Vec::new()
}
}
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
///
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
+67 -1
View File
@@ -126,6 +126,14 @@ impl Scanner {
Some(ProcRef { pid, start })
}
/// This process's `comm` — its short name, as `ps` shows it. Diagnostics only (see
/// [`super::names`]); `?` for a process that has already gone, which is routine.
pub fn name_of(&self, p: ProcRef) -> String {
std::fs::read_to_string(self.root.join(p.pid.to_string()).join("comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "?".into())
}
/// Which of `procs` are still the same live processes — pid present **and** start time unchanged,
/// so a recycled pid is never reported alive (rule 2).
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
@@ -180,16 +188,29 @@ impl Scanner {
if let Some(tok) = steam_tok {
// Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and
// Steam's own (non-reaper) helper steps aren't mistaken for the game.
//
// …with one exception, because the reaper is *not* only the game's: Steam wraps its
// shader pre-caching for a title in the same `SteamLaunch AppId=<appid>` reaper it
// wraps the game in, so that job satisfies this recipe exactly while the game has
// not started yet. Adopting it points the lease at a tree that exits when the
// compile finishes, which reads as the game exiting — on Linux that dropped a
// Rocket League stream 10 s into a launch, mid-"Processing Vulkan shaders", and the
// player had to launch a second time to get a session that stayed up (field report
// 2026-08-22). The payload names itself: `fossilize_replay` is Steam's replayer and
// is never a game.
let mut launch = false;
let mut appid = false;
let mut shader = false;
for arg in cmdline.split(|&b| b == 0) {
if arg == b"SteamLaunch" {
launch = true;
} else if arg == tok.as_bytes() {
appid = true;
} else if program_name(arg) == b"fossilize_replay" {
shader = true;
}
}
if launch && appid {
if launch && appid && !shader {
return true;
}
}
@@ -247,6 +268,15 @@ impl Scanner {
}
}
/// The last `/`-separated component of an argv entry — the program's own name, when the entry is a
/// path to one. Bytes rather than `str` because an argv entry is not required to be UTF-8.
fn program_name(arg: &[u8]) -> &[u8] {
match arg.iter().rposition(|&b| b == b'/') {
Some(i) => &arg[i + 1..],
None => arg,
}
}
/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished
/// or the file is unreadable — both routine during a scan.
fn read_capped(path: &Path) -> Option<Vec<u8>> {
@@ -472,6 +502,42 @@ mod tests {
assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]);
}
/// The 2026-08-22 field report: Steam's **shader pre-caching** runs under the game's own
/// `SteamLaunch AppId=` reaper, so it satisfies the appid recipe while the game has not started.
///
/// Adopting it is what dropped a Rocket League stream 10 s into a launch — the lease called that
/// tree the game, and its exit (the compile finishing) the game exiting. The reaper's payload is
/// the whole tell, and it is only ever Steam's replayer.
#[test]
fn steam_shader_pre_caching_is_not_the_game() {
let td = fake_proc_root(
1000.0,
&[
// The shader job for this very appid — the game is still being brought up.
FakeProc::new(35, 50_000).cmdline(&[
"/home/p/.steam/ubuntu12_32/reaper",
"SteamLaunch",
"AppId=252950",
"--",
"/home/p/.steam/steamapps/common/SteamLinuxRuntime/fossilize_replay",
"/home/p/.steam/steamapps/shadercache/252950/fozpipelinesv6/steamapprun_pipeline_cache.foz",
]),
// The game itself, same appid, same reaper. This one IS the game.
FakeProc::new(36, 50_000).cmdline(&[
"/home/p/.steam/ubuntu12_32/reaper",
"SteamLaunch",
"AppId=252950",
"--",
"/home/p/.steam/steamapps/common/Proton/proton",
"waitforexitandrun",
"/home/p/.steam/steamapps/common/rocketleague/RocketLeague.exe",
]),
],
);
let s = scanner(td.path());
assert_eq!(pids(s.find(&DetectSpec::steam(252_950), None)), vec![36]);
}
#[test]
fn matches_env_marker_by_exact_value_or_presence() {
let td = fake_proc_root(
@@ -120,6 +120,14 @@ impl Scanner {
Some(ProcRef { pid, start })
}
/// This process's image file name. Diagnostics only (see [`super::names`]); `?` for a process
/// that has already gone or cannot be opened, which is routine.
pub fn name_of(&self, p: ProcRef) -> String {
process_start_and_image(p.pid)
.and_then(|(_, image)| image.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "?".into())
}
/// Which of `procs` are still the same live processes — pid present **and** creation time
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
/// this check is what makes signalling a remembered pid safe at all.
+111
View File
@@ -0,0 +1,111 @@
# shellcheck shell=bash
# Does this box already have every Flathub dep a flatpak manifest declares?
# bash scripts/ci/flatpak-deps-present.sh <manifest.yml> -> exit 0 = yes, 1 = no
# bash scripts/ci/flatpak-deps-present.sh --self-test -> run the asserts below
#
# WHY THIS EXISTS: flatpak.yml used to prefetch deps with `flatpak-builder --install-deps-only`,
# which does NOT mean "install what is missing". builder_manifest_install_dep() branches on
# `flatpak info --show-commit <ref>` succeeding and runs `flatpak update` for every dep that IS
# installed (a failed update is fatal there — it never falls back to install) — and
# ci/flatpak-ci.Dockerfile bakes the whole runtime set, so on a healthy run that flag did nothing
# except make the build depend on Flathub being up at that minute. On 2026-08-22 it took the job
# down: dl.flathub.org returned HTTP 404 for one .filez object of the then-current
# rust-stable//25.08 commit, identically on all 10 retry.sh attempts (~9 min), and flatpak-builder
# segfaulted on its own error path (rc=139) so the retry wrapper could not tell a dead end from a
# blip. Nothing about the build wanted that newer commit: the manifest pins a runtime VERSION, not
# a commit, and the baked one satisfies it.
#
# So the workflow asks this first and only reaches for Flathub on a real miss.
#
# FAILS OPEN, deliberately: an unreadable/unexpected manifest reports "not present" (1), so the
# caller does the full install. Silently skipping the install on a manifest we stopped
# understanding is how you build against the wrong runtime.
set -uo pipefail
deps_present() {
local manifest="$1" runtime rt_ver sdk exts e
runtime=$(sed -n 's/^runtime: *//p' "$manifest" | head -1)
rt_ver=$(sed -n 's/^runtime-version: *//p' "$manifest" | tr -d "\"'" | head -1)
sdk=$(sed -n 's/^sdk: *//p' "$manifest" | head -1)
exts=$(sed -n '/^sdk-extensions:/,/^[^ #-]/p' "$manifest" | sed -n 's/^ *- *//p')
[ -n "$runtime" ] && [ -n "$rt_ver" ] && [ -n "$sdk" ] && [ -n "$exts" ] || return 1
flatpak info --user "$runtime//$rt_ver" >/dev/null 2>&1 || return 1
flatpak info --user "$sdk//$rt_ver" >/dev/null 2>&1 || return 1
# Extensions are checked for PRESENCE, not version: flatpak-builder resolves their version from
# the SDK's own metadata (it prints "Dependency Extension: … 25.08"), never from the manifest.
# Any bump that moves them moves runtime-version too, which the two checks above already catch.
for e in $exts; do
flatpak info --user "$e" >/dev/null 2>&1 || return 1
done
}
self_test() {
local rc fails=0 full
# NOT `local`: the EXIT trap fires after this function has returned.
SELFTEST_TMP=$(mktemp -d) || return 1
trap 'rm -rf "$SELFTEST_TMP"' EXIT
local tmp="$SELFTEST_TMP"
cat > "$tmp/ok.yml" <<'YML'
runtime: org.gnome.Platform
runtime-version: '50'
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
- org.freedesktop.Sdk.Extension.llvm20
command: punktfunk-client
YML
# A manifest this script cannot read (the fail-open case).
printf 'app-id: io.unom.Punktfunk\n' > "$tmp/unparseable.yml"
# Stub `flatpak`: $INSTALLED is the newline-separated set of refs it admits to having.
mkdir -p "$tmp/bin"
cat > "$tmp/bin/flatpak" <<'STUB'
#!/usr/bin/env bash
# only `flatpak info --user <ref>` is exercised here
# args are: info --user <ref>
[ "$1" = info ] || exit 0
printf '%s\n' "$INSTALLED" | grep -qxF "$3"
STUB
chmod +x "$tmp/bin/flatpak"
PATH="$tmp/bin:$PATH"
check() { # <expected rc> <label> <installed set> <manifest>
INSTALLED="$3" deps_present "$4"; rc=$?
if [ "$rc" != "$1" ]; then
echo "FAIL: $2 (expected rc=$1, got $rc)" >&2; fails=$((fails + 1))
else
echo "ok: $2"
fi
}
full='org.gnome.Platform//50
org.gnome.Sdk//50
org.freedesktop.Sdk.Extension.rust-stable
org.freedesktop.Sdk.Extension.llvm20'
check 0 "everything baked -> skip Flathub" "$full" "$tmp/ok.yml"
check 1 "cold box -> install" "" "$tmp/ok.yml"
check 1 "runtime missing -> install" "${full/org.gnome.Platform\/\/50/x}" "$tmp/ok.yml"
check 1 "sdk missing -> install" "${full/org.gnome.Sdk\/\/50/x}" "$tmp/ok.yml"
# The regression that started all this: llvm20 fine, rust-stable not.
check 1 "one sdk-extension missing -> install" "${full/*.rust-stable/x}" "$tmp/ok.yml"
# A runtime installed at ANOTHER version must not pass just because the name matches.
check 1 "runtime at the wrong version" 'org.gnome.Platform//51
org.gnome.Sdk//51
org.freedesktop.Sdk.Extension.rust-stable
org.freedesktop.Sdk.Extension.llvm20' "$tmp/ok.yml"
check 1 "unreadable manifest -> fail open" "$full" "$tmp/unparseable.yml"
[ "$fails" = 0 ] || { echo "$fails check(s) failed" >&2; return 1; }
echo "all checks passed"
}
case "${1:---help}" in
--self-test) self_test ;;
--help|-h) sed -n '2,4p' "$0"; exit 2 ;;
*) deps_present "$1" ;;
esac