Compare commits

..
25 Commits
Author SHA1 Message Date
enricobuehlerandClaude Sonnet 5 3721b6816d chore(release): bump workspace version to 0.20.1
audit / bun-audit (push) Successful in 25s
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m32s
audit / cargo-audit (push) Successful in 2m13s
apple / swift (push) Successful in 5m18s
ci / bench (push) Successful in 5m33s
ci / rust-arm64 (push) Successful in 13m8s
android-screenshots / screenshots (push) Successful in 3m13s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m52s
deb / build-publish-host (push) Successful in 10m14s
ci / rust (push) Successful in 34m31s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
decky / build-publish (push) Successful in 28s
arch / build-publish (push) Successful in 12m48s
deb / build-publish (push) Successful in 13m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m48s
android / android (push) Successful in 18m46s
deb / build-publish-client-arm64 (push) Successful in 7m55s
linux-client-screenshots / screenshots (push) Successful in 8m3s
web-screenshots / screenshots (push) Successful in 3m3s
docker / build-push-arm64cross (push) Successful in 5m33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13m15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
docker / deploy-docs (push) Successful in 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m38s
release / apple (push) Successful in 31m20s
flatpak / build-publish (push) Failing after 8m6s
apple / screenshots (push) Successful in 23m22s
windows-host / package (push) Successful in 12m1s
windows-host / winget-source (push) Failing after 12s
24 commits since v0.20.0. Patch, not minor: entirely fixes and
hardening on top of last release's session⇄game work, nothing new to
opt into. Headline fixes: three Windows install blockers reported
within hours of 0.20.0 (a false conflicting-host detection, a broken
winget log-path switch, missing package-source instructions),
GameStream/Moonlight compat flipped back to opt-in on fresh installs,
three gamescope Game Mode takeover faults that could black out a
Linux machine or strand it on a host restart, a deactivated laptop
panel misdiagnosed as a blind spot instead of a named stall cause,
and the PyroWave high-bitrate latency-creep bundle (clock re-sync
starvation, wire bitrate overshoot, backlog buildup, ABR probe
false-congestion).

Wire protocol stays at 2, the embeddable C ABI at 13 and the Windows
virtual-display driver protocol at 6, unchanged from 0.20.0 — no
embedder rebuild required, 0.18/0.19/0.20 hosts and clients mix
freely.

Notes authored ahead of the tag per docs/releases/README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 19:25:47 +02:00
enricobuehler 0e0d5b8b4d chore(api): regenerate openapi.json — the stats description drifted
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m12s
ci / bench (push) Successful in 7m31s
deb / build-publish (push) Successful in 10m0s
decky / build-publish (push) Successful in 27s
deb / build-publish-host (push) Successful in 10m21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
android / android (push) Successful in 12m31s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
windows-host / package (push) Successful in 11m40s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 49s
windows-host / winget-source (push) Skipped
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
ci / rust-arm64 (push) Successful in 13m26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
docker / deploy-docs (push) Successful in 32s
deb / build-publish-client-arm64 (push) Successful in 10m7s
arch / build-publish (push) Successful in 18m11s
docker / build-push-arm64cross (push) Successful in 4m26s
apple / swift (push) Successful in 6m2s
ci / rust (push) Successful in 28m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m4s
apple / screenshots (push) Successful in 24m37s
`2c69cbda` rewrote StatsSample::mbps's doc comment (goodput → attempted
sealed wire bytes) without regenerating the snapshot, so
`openapi_document_is_complete_and_checked_in` fails on main today. Pure
regeneration, no API surface change: the description and the version stamp
are the only differences.
2026-07-27 19:04:20 +02:00
enricobuehler 6be865f33f fix(host): a host that is stopped hands the box's session back first
Until now the host had no signal handling at all: SIGTERM killed it
outright. That is fine for a host that owns nothing — but a managed
gamescope takeover owns the box's session, and on a mask-fragile display
manager (Nobara's plasmalogin) it has STOPPED that display manager for the
length of the stream. Killed there, the host leaves a box with no graphical
session and nothing left to restart it: the crash-restore state lives in
$XDG_RUNTIME_DIR, which logind removes along with the user manager, so not
even the next host start can heal it. `systemctl --user restart
punktfunk-host` mid-stream — or a package update doing it for you — was
enough to strand a box until someone reached a VT.

So SIGTERM and SIGINT now restore first and exit after. The restore runs on
a blocking thread (it shells out) under a 20s grace, well inside systemd's
TimeoutStopSec, and a host that took nothing over exits immediately.

`restore_takeover_now` is the synchronous sibling of the debounced
disconnect restore, and deliberately ignores the keep-alive policy the
latter honors: `keep_alive=forever` pins a session for the NEXT client,
which means nothing once the host that would serve them is exiting. Both
paths now share one `takeover_live()` predicate instead of repeating the
four-way "is anything ours" test.

Checked on Linux: clippy -D warnings on punktfunk-host + pf-vdisplay, tests,
rustfmt.
2026-07-27 19:04:20 +02:00
enricobuehler f3615f83a5 fix(gamescope): the takeover no longer kills the host it is running in
Field report, Nobara + 0.20.0: switching into Game Mode mid-stream
disconnects the client and the box never lights up again. His journal has
the whole mechanism, ten seconds apart:

  12:34:18.9  freed Steam: stopped the display manager for this stream
  12:34:29.0  systemd[1685]: Stopping punktfunk-host.service

Stopping the display manager ends the user's last login session. The
packaged host is a `systemd --user` unit, so logind then stops
user@1000.service once UserStopDelaySec elapses — 10s by default on Nobara
— and takes the host with it. The stream dies, the scheduled restore never
runs, and the display manager stays down with nothing left to restart it:
a black box that needs a VT to recover. Exactly the two symptoms reported,
and the intermittency is just whether an active Gaming Mode session was
there to trigger the takeover at all.

It never showed on the repro VM because lingering was enabled there for the
earlier sessionless tests (`Linger=yes` — confirmed on the VM today), which
is precisely the thing that breaks the dependency: logind keeps the user
manager alive with no session. Our KDE/GNOME/Arch setup guides already ask
for it; his box, following the KDE route, did not have it.

So the takeover now ensures lingering BEFORE it touches the display
manager: `loginctl enable-linger` directly (allow_active in logind's own
policy — verified working unprivileged on Nobara), else the packaged helper
gains a `linger` verb that enables it for PKEXEC_UID alone, never a
caller-named user. If neither works the takeover is refused with an
actionable error and managed degrades to attach — mirroring the box's own
session is strictly better than a screen that needs a VT. Hosts that cannot
be reached this way (root, a system unit) skip the check: the cgroup test
is a pure `cgroup_under_user_manager` with a unit test, matched against the
real shapes on a Nobara box (`user@1000.service/app.slice/...` vs a
`session-N.scope`).

Also documents the requirement in the gamescope guide's Nobara section.

Checked on Linux: clippy -D warnings, 78 tests, rustfmt; helper verified
with `sh -n`, and the linger enable/disable round-trip run live on the
Nobara VM.
2026-07-27 19:04:20 +02:00
enricobuehler eae1837a3a fix(gamescope): a managed takeover never waits on a dialog nobody can see, nor acts on a months-old sentinel
Two ways the Nobara DM-stop takeover could end a session and leave the box
dark, both found while triaging a 0.20.0 field report (managed round-trip:
"my client disconnects and my monitor doesn't turn back on", intermittent).
0.20.0 is the first release where that box runs the takeover for real —
before it, no package shipped the polkit privilege, so managed always
degraded to attach and never touched the display manager.

Privileged verbs are non-interactive now. `try_stop_display_manager` and
`restore_display_manager` try a plain system-bus `systemctl` first, and
without `--no-ask-password` that asks polkit for INTERACTIVE authorization.
On a box where the host runs inside the user's graphical session, polkit
hands that to the session's agent: a password dialog on the box's own
screen, which during a takeover is off or mid-switch. Nobody sees it,
nobody answers it, and the call blocks on the stream's own thread — the
capture-loss rebuild, or the restore worker — while the rebuild budget
burns. The takeover then lands after the session it was for already ended.
With the flag it fails immediately ("interactive authentication required"),
which is what both callers are already written for: the packaged pkexec
helper (allow_any, no agent needed) takes it from there.

A sentinel with no baseline is not a request. `session_select_requested`
read "no baseline recorded" as "the sentinel appeared during the session" —
but `~/.config/steamos-session-select` is a permanent file, so every box
whose user has ever switched sessions has one. The baseline was recorded
only after a SUCCESSFUL launch, leaving a real window: takeover succeeds,
launch fails, the client retries inside the 5s restore debounce (which the
retry cancels), and the honor gate fires on a months-old write — pushing
the box to the desktop the user never asked for, plus a 120s grace refusing
to relaunch game mode. Now the baseline is taken at takeover too (setting
STOPPED_DM is what arms the gate), and "never baselined" reads as no
request. `SESSION_SELECT_BASELINE` carries both states explicitly, and the
decision is a pure `sentinel_advanced` with a unit test for all six cases.

Checked on Linux: cargo check --all-targets, clippy -D warnings, 77 tests,
rustfmt.
2026-07-27 19:04:20 +02:00
enricobuehlerandClaude Fable 5 ba89b9fcd0 feat(tools): display-disturb learns the bench moves the field sessions needed
ci / rust-arm64 (push) Successful in 9m0s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m3s
android / android (push) Successful in 18m29s
ci / bench (push) Successful in 5m27s
decky / build-publish (push) Successful in 28s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 19s
arch / build-publish (push) Successful in 19m47s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 14s
ci / rust (push) Failing after 20m23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 28s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
deb / build-publish (push) Successful in 9m55s
deb / build-publish-client-arm64 (push) Successful in 8m33s
deb / build-publish-host (push) Successful in 12m56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m54s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m10s
apple / swift (push) Successful in 5m28s
apple / screenshots (push) Successful in 23m31s
Three upgrades from the on-glass rounds on .173/.221:

- `ddc-open` timing: the handle-ACQUISITION cost per monitor is reported even
  when it yields no physical handles — the poller's first contact with a
  virtual display, exactly what the DDC-fail-fast driver work changes; the
  old code silently skipped handle-less monitors, which on a streaming host
  in exclusive topology is every monitor there is.
- Monitors are labeled by GDI device name (\\.\DISPLAYn) so a virtual
  display and a panel are tellable apart in the correlation log.
- `extend` mode: one SDC_TOPOLOGY_EXTEND poke — re-activates every
  attachable display, i.e. the exact 'non-managed display re-activated'
  event the exclusive watchdog evicts. One shot, not a loop: trigger a
  reassert round, observe the recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:52:31 +02:00
enricobuehlerandClaude Fable 5 622817954a fix(capture/win-display): a deactivated laptop panel is a named stall suspect, not a blind spot
audit / bun-audit (push) Successful in 14s
ci / web (push) Successful in 52s
windows-drivers / probe-and-proto (push) Successful in 44s
ci / docs-site (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 2m18s
windows-drivers / driver-build (push) Successful in 2m21s
apple / swift (push) Successful in 4m54s
ci / bench (push) Successful in 6m41s
ci / rust-arm64 (push) Successful in 10m4s
decky / build-publish (push) Successful in 42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 26s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
deb / build-publish-host (push) Successful in 10m31s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
ci / rust (push) Failing after 13m9s
deb / build-publish (push) Successful in 12m14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m9s
android / android (push) Successful in 16m5s
arch / build-publish (push) Successful in 17m22s
deb / build-publish-client-arm64 (push) Successful in 9m46s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
flatpak / build-publish (push) Successful in 7m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m42s
release / apple (push) Successful in 27m50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m34s
docker / deploy-docs (push) Successful in 54s
docker / build-push-arm64cross (push) Successful in 9m53s
apple / screenshots (push) Successful in 23m52s
Field A/B (reporter, Legion 5 Pro hybrid, 2026-07-27): the ~2 s capture-stall
metronome IS the exclusive isolate's own doing — deactivating the laptop panel
leaves a dark-but-connected eDP head whose driver-level servicing disturbs the
capture path (~1.7-2.8 s period); `topology: primary` (panel stays active)
stopped it outright (16.3 stalls/min → 0). Our own .221 logged the identical
signature the day before. The detector then steered AWAY from the cause:
connected_inactive filtered to EXTERNAL physicals only, so the one display
that mattered — on a hybrid laptop, the default config — reported as `none`.

- TargetInventory: new `internal_panel` class bit (eDP/LVDS/embedded).
- connected_inactive_externals → connected_inactive_physicals: internal
  panels join the suspect list (virtual/indirect stay excluded); a nameless
  panel renders as "laptop panel".
- The below-OS METRONOMIC warn names the dark-panel case and its actual
  cures (`topology: primary`, `pnp_disable_monitors`) — the old text's
  "unplug it" prescriptions are impossible for an internal panel.
- ddc_power_off: a zero-ack sweep now says at INFO that the axis did nothing
  (internal panels have no DDC/CI) instead of silently no-op'ing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 17:04:08 +02:00
enricobuehlerandClaude Fable 5 ab58fd2f0e feat(vdisplay/driver): DDC/CI against the virtual monitor fails fast
In exclusive topology the virtual display is the ONLY monitor on the desktop,
so monitor-control software (the Twinkle Tray / PowerToys PowerDisplay /
Monitorian class) aims its entire DDC traffic — brightness polls,
capabilities-string requests — at OUR monitor. There is no bus and no sink;
the only wrong answer is a slow one (a timeout-shaped failure occupies
win32k's physical-monitor path, serialized per monitor, for its full
duration). Register EvtIddCxMonitorI2CTransmit/Receive and answer every probe
with an immediate STATUS_NOT_SUPPORTED, making the virtual monitor the
cheapest thing on the "bus" to poll. (The receive DDI has no out-arg at the
callback — refusing synchronously refuses the whole transaction.) EDID needs
no equivalent: the OS serves descriptor queries from the blob supplied at
monitor creation without calling the driver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:37:06 +02:00
enricobuehlerandClaude Fable 5 1945052e66 fix(capture/vdisplay): descriptor-following defers while a topology reassert is in flight
The exclusive-topology reassert's forced re-commit transiently bounces the
virtual display's mode; the descriptor poller can sample that EVICTION state
and recreate the ring at a mode the recovery chain is about to undo (field
log 2026-07-27 10:30:44Z: hdr=true → false → recreate → recovery restores
true → second recreate). New pf-win-display::topology_churn latch (deadline
semantics — self-expiring, so a lost release can never wedge descriptor-
following off): the watchdog holds it for interval+3s each fighting round and
releases on "stable again"; poll_display_hdr consumes samples but acts on
nothing while held — which also keeps the negotiated-depth pin-back from
issuing a CCD write mid-eviction, where it would fight the reassert itself.
The deliberate recreate_ring_in_place recovery path is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:37:05 +02:00
enricobuehlerandClaude Fable 5 9e1a686795 feat(tools): display-disturb — deterministic display-stack disturbance generator
The stall-immunity bench needs both stall classes on demand, without a standby
TV or a monitor-tool storm: `ddc` replays the Twinkle-Tray/PowerDisplay-class
DDC/CI traffic (VCP reads, optional capabilities-string requests) through the
win32k I2C path; `modeset` re-commits the current mode with CDS_RESET — the
Level-Two modeset entry that idles the whole adapter with nothing Win32-visible
changing. Every op prints an epoch-stamped took_ms line so host.log stall
reports correlate line-for-line, and the op duration itself measures the
driver's service time per disturbance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:24:20 +02:00
enricobuehlerandClaude Fable 5 19d6f79d2d feat(vdisplay/driver): the frame pump survives MMCSS refusal and outranks GPU contention
Two stall-immunity hardenings for the swap-chain drain thread (branch-2 of the
disturbance-immunity program — failures in OUR delivery leg, as opposed to
adapter-wide display servicing, which no priority survives):

- MMCSS registration fails under the restricted WUDFHost token on some boxes;
  the drain thread then ran UNPRIORITIZED — the whole display's frame pump at
  normal priority, starvable by DDC/HPD-servicing DPC pressure into
  multi-hundred-ms delivery holes. Fall back to TIME_CRITICAL.

- IddCxSetRealtimeGPUPriority (IddCx 1.9) raises the processing device above
  every regular application's GPU priority. Slot availability comes from
  raising the exported IddMinimumVersionRequired 4 → 10, which was overdue
  independently: the drain loop already calls ReleaseAndAcquireBuffer2 (1.10)
  unconditionally, so binding a pre-1.10 framework would dispatch past the
  populated IddFunctions table — with 10 an old framework fails the bind
  cleanly instead. The product floor is already Win11 22H2 (installer
  MinVersion=10.0.22621, whose framework is 1.10), so no supported system
  changes behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:24:10 +02:00
enricobuehlerandClaude Fable 5 94f0207cbd feat(windows/client): logs persist — %LOCALAPPDATA%\punktfunk\logs\client.log
apple / swift (push) Successful in 4m54s
ci / web (push) Successful in 58s
decky / build-publish (push) Successful in 34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 23s
ci / bench (push) Successful in 7m9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 22s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 40s
deb / build-publish-client-arm64 (push) Successful in 7m35s
deb / build-publish (push) Successful in 10m19s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m25s
ci / rust-arm64 (push) Successful in 13m57s
deb / build-publish-host (push) Successful in 14m30s
ci / docs-site (push) Successful in 1m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m41s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m32s
android / android (push) Successful in 15m23s
arch / build-publish (push) Successful in 14m1s
flatpak / build-publish (push) Failing after 8m16s
docker / deploy-docs (push) Successful in 11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m25s
release / apple (push) Successful in 29m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 7m52s
ci / rust (push) Failing after 16m15s
docker / build-push-arm64cross (push) Failing after 4m25s
windows-host / package (push) Successful in 11m35s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 1m59s
apple / screenshots (push) Successful in 23m44s
The shell is a windows_subsystem binary and spawns punktfunk-session with
CREATE_NO_WINDOW + inherited stderr: a normal GUI/MSIX launch has no
console, so every log line — the shell's and the session's whole
receive/decode/present forensic trail — evaporated exactly when a user
hit something worth reporting. The 2026-07 PyroWave latency-sawtooth
report had to be triaged from host logs alone; the deciding evidence
(clock re-sync vs backlog-flush lines) lives client-side.

- New logfile module: %LOCALAPPDATA%\punktfunk\logs\client.log,
  the host's rotation convention (10 MB cap -> .old at next start, one
  generation), best-effort (no dir -> plain stderr, never a startup
  failure).
- The tracing subscriber tees to stderr AND the file (ANSI off — the
  file is what users send); startup logs the path.
- The spawned session's stderr is piped through the same tee,
  line-buffered — dev-terminal runs keep their interleaved output,
  GUI runs finally keep anything at all.
- The --console path and the punktfunk-console.exe MSIX shim forward
  the same way (a couch launch has no console either).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 2c69cbdab9 docs(host/stats): say what the stats actually measure — three field-triage traps
Three lines that each sent the 2026-07 PyroWave field triage down a wrong
path, made honest:

- The 'client accepts streamed AUs … will stream per-slice' log fires
  before the encoder exists and regardless of backend support (only Linux
  direct-NVENC implements chunked polling) — it now states the client
  capability only.
- StatsSample::mbps was documented as 'transmit goodput'; it is attempted
  sealed wire bytes at seal time — AU bytes + shard framing + FEC parity
  (+ PyroWave window padding), not reduced by socket send drops.
- The per-stage split's 'encode' stage is the poll() duration, which is
  ~0 by construction for synchronous backends (PyroWave encodes inside
  submit → its time shows as 'submit') — documented at the split's
  definition so 'encode 0.00' stops reading as an instrumentation hole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 d8d8c6c43d fix(core/abr): the capacity probe's own overload no longer reads as session congestion
The startup speed-test probe deliberately overloads the link (2 Gb/s for
800 ms) to measure capacity. The report tick is suppressed while it runs
and the window anchors are rebased when it ends — but two residues leak
past the rebase: probe frames still pending in the reassembler age out as
frames_dropped for another ~120 ms (the loss-window fuse), and the burst
can latch flush_in_window (a jump-to-live it caused itself). Either one
reads as a SEVERE window: the controller backs off ×0.7 and — worse —
slow start ends at the first congestion signal, permanently.

That is precisely the 2026-07 field report's Automatic session: 20→14
Mb/s exactly one report tick after the probe, then a purely additive
+6 %/4.5 s crawl that took the whole 4.5-minute match to reach 308 Mb/s
on a link the probe had just measured at ~1.2 Gb/s. Slow start (doubling
per clean window to the probed ceiling, 'seconds instead of minutes')
never got to run.

The first post-probe report window is now discarded outright: no
LossReport (probe residue would also spike the host's adaptive FEC), the
standing-latency detector closes it as not-loss-free (its clean-run just
resets), and the ABR is fed nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 b8b8ac336c feat(core/client): all-intra streams drain the frame channel to the newest AU
The pre-decode FrameChannel is strictly FIFO because H.26x reference
chains forbid mid-stream drops — falling behind is only recoverable via
the coarse jump-to-live thresholds (depth ≥6 sustained, or 400 ms behind
the capture clock) at the cost of a keyframe round-trip.

PyroWave has no reference chains: every AU decodes independently, so a
slow consumer can skip straight to the newest queued AU with zero
recovery cost. The pump now flags the channel all-intra for PyroWave
sessions and pop() drains to the newest, which caps any standing
pre-decode queue at ~1 frame structurally — the 2026-07 field report's
780M client could otherwise ratchet a real multi-frame backlog (and with
it the OSD latency) between those coarse thresholds.

Skips are counted separately from losses (they were delivered, just
superseded) and surfaced at debug on the report tick — the OSD 'lost'
line keeps meaning wire loss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 6d1baa0add fix(pf-encode/pyrowave): the bitrate pin holds on the WIRE, not the raw bitstream
A datagram-aligned PyroWave session inflates the codec bitstream ×1.2–1.3
on its way to the wire — greedy packing of few-hundred-byte atomic block
packets into 1408 B windows zero-pads most window tails, plus the 4-byte
prefixes and FRAG chains. The 2026-07 field report's 1440p60 10-bit
"Automatic" pin of 407 Mb/s put a measured 550 Mb/s on a 1 GbE link;
nothing enforced the pin past the rate controller.

New shared WireBudget (pyrowave_wire.rs, both backends): tracks the real
per-frame AU/bitstream ratio as a ×1024 fixed-point EMA (prior ×1.25,
weight 1/8, clamped ×1.0–×2.0) and deflates the budget handed to
pyrowave's rate control by it, so the windowed AU lands on the configured
rate. Sealed-datagram framing (+4.5%) and FEC parity stay uncompensated —
H.26x sessions carry those on top of the configured bitrate too, and the
pin must mean the same thing for every codec.

Dense (non-chunked) sessions are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehlerandClaude Fable 5 53640b8754 fix(core/clock): re-sync survives loaded links — floor baseline, spaced rounds, bounded staleness
The mid-stream clock re-sync starved on high-bitrate LAN sessions (2026-07
PyroWave-sawtooth field report, RX 9070 XT -> 780M @ 550 Mb/s): every batch
was judged against the CONNECT-TIME RTT, measured before the video data
plane existed, with a 2 ms floor — mid-stream control RTTs on a loaded GbE
link sit above that almost permanently, so batches were rejected for
minutes while the wall clocks drifted apart and the OSD e2e figure ramped
19->150 ms before snapping back on a lucky batch.

Three changes:
- Rounds are spaced 7 ms apart (stamped at send time, so the spacing never
  lands in the RTT). An 8-round batch used to complete inside ONE ~6 ms
  video burst — all rounds sampled the same congestion state; spacing walks
  them across the frame cycle so the min-RTT round finds a quiet gap.
- ResyncGuard replaces the static baseline: the guard band follows the best
  RTT the session has evidenced (connect RTT, then min over every completed
  batch — rejected ones included).
- Rejection streaks are bounded: after 3 consecutive rejections the best
  (min-RTT) batch of the streak is applied anyway. Its queueing bias is at
  most ~half its RTT; unbounded wall-clock drift costs more per minute.

Rejections now log at warn with the streak and floor — the starvation
signature is grep-able in exactly the logs users send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 12:51:11 +02:00
enricobuehler 94b7a834cb fix(installer/windows): GameStream is opt-in, not on by default
apple / swift (push) Successful in 3m19s
decky / build-publish (push) Successful in 35s
windows-host / package (push) Successful in 18m44s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m15s
apple / screenshots (push) Successful in 23m24s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 1m27s
ci / bench (push) Successful in 7m15s
ci / rust-arm64 (push) Successful in 11m47s
deb / build-publish-client-arm64 (push) Successful in 10m21s
android / android (push) Failing after 11m52s
deb / build-publish-host (push) Successful in 11m34s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m42s
deb / build-publish (push) Successful in 17m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8m30s
arch / build-publish (push) Successful in 18m29s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13m59s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14m36s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13m1s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Successful in 30m15s
docker / build-push-arm64cross (push) Successful in 9m8s
A user found this in their log and had never been offered a choice:

  WARN GameStream/Moonlight compat ENABLED (--gamestream): its pairing runs over
  plain HTTP and its legacy control encryption can reuse GCM nonces
  (security-review #5/#9) — an on-path LAN attacker could MITM pairing or
  recover input. Enable only on a TRUSTED network.

They were right. The `gamestream` task carried no `Flags: unchecked`, so it was
pre-ticked; and since a silent install takes the wizard's own task defaults
(1839d756), a winget install turned it on with no checkbox ever shown. The host
warns that this plane is a security downgrade on every single start, so having
the installer enable it by default cannot be squared with our own advice — least
of all on the path where nobody sees a wizard.

Now unchecked, matching `allowpublicfw`, the other task with a security
consequence. A default install therefore lands `PUNKTFUNK_HOST_CMD=serve`, the
native-only host; Punktfunk's own clients are unaffected, since the native
punktfunk/1 plane is always on. Unattended opt-in is `/MERGETASKS=gamestream`.

Scope, deliberately: this changes FRESH installs only. `GamestreamParam` already
omits the flag entirely unless `FreshHostInstall`, so no existing host has its
choice touched by an upgrade, and anyone relying on Moonlight today keeps it.
`DEFAULT_HOST_CMD` (the service's fallback when host.env carries no
PUNKTFUNK_HOST_CMD line at all) is deliberately NOT flipped here: the installer
always writes the line explicitly, so it does not affect a normal install, and
inverting it would silently disable Moonlight for anyone whose host.env lost that
line — a runtime regression that belongs in its own change, if at all.

Docs corrected everywhere they stated the old default, since three of them now
told users the opposite of what ships: the winget Agreement shown BEFORE install
(it said "enabled by default" and gave the opt-OUT override), the Windows
packaging README, and host.env.example. The `--override` example in the installer
manifest is inverted with it (`/MERGETASKS=gamestream` to add, `!` to remove).

Verified: [Tasks] + [Code] compile with ISCC 6.7.1 on the windows-amd64 runner
(all optional features defined), and the winget catalogue rebuilds and passes
29/29 with the edited manifests.
2026-07-27 12:26:47 +02:00
enricobuehler 4114dfeff7 fix(winget): the Log switch used |LOGPATH|, which winget never substitutes
Second field report on 0.20.0's winget path, and independent of the conflict
abort in 37781a61: an INTERACTIVE install through UniGetUI dies before the
wizard with Inno's "Error creating log file: The filename, directory name, or
volume label syntax is incorrect."

`<LOGPATH>` is winget's own token and the only spelling it replaces — the
schema this manifest declares says so verbatim ("<LOGPATH> token can be included
in the switch value so that winget will replace the token with user provided
path"). We shipped `|LOGPATH|`, which is not a token, so winget passed the
literal string to Inno; `|` is not legal in a Windows filename, and Inno refuses
before doing anything else.

Worse than the conflict abort in two ways: it is not confined to silent installs
— it fires in EVERY mode for any caller that requests a log — and UniGetUI
requests one by default, so a GUI user cannot install at all. Confirmed against
the live source, which is serving the broken switch to everyone right now:
  curl -s https://winget.punktfunk.unom.io/packageManifests/unom.PunktfunkHost
  → "Log": "/LOG=\"|LOGPATH|\""

Guarded by the suite that exists for exactly this class of defect (a wrong
response shape does not fail loudly — winget just says "no package found"). The
new check accepts an absent Log switch, requires <LOGPATH> when present, and
rejects any |TOKEN| spelling. Mutation-verified: restoring |LOGPATH| fails the
check naming the offending value, and the corrected manifest passes 29/29.

⚠ Fixing the template does NOT fix the live catalogue: it is derived from the
manifest trio attached to each release, so the corrected switch only reaches
users when a release carries it.
2026-07-27 10:28:59 +02:00
enricobuehler 37781a610f fix(installer/windows): only a host that will actually RUN is a conflict
docker / deploy-docs (push) Successful in 41s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m50s
apple / swift (push) Successful in 5m41s
ci / bench (push) Successful in 7m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 2m3s
ci / rust-arm64 (push) Successful in 9m51s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
deb / build-publish-host (push) Successful in 11m32s
deb / build-publish-client-arm64 (push) Successful in 10m59s
decky / build-publish (push) Successful in 41s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6m19s
android / android (push) Successful in 17m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m35s
windows-host / package (push) Successful in 11m21s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 19m9s
docker / build-push-arm64cross (push) Successful in 5m7s
ci / rust (push) Successful in 22m25s
deb / build-publish (push) Successful in 10m49s
apple / screenshots (push) Successful in 23m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m34s
Field report within hours of 0.20.0 (HitFrostbite): `winget install
unom.PunktfunkHost` fails with "Installer failed with exit code: 1",
0x8A150006, after the hash verifies and the install starts.

That is Setup aborting on purpose. `StreamHostPresent` counted a conflicting
host as present if its SCM service key existed at ANY start type, OR if a bare
Program Files\<Name> directory existed — so a disabled service, or a folder left
behind by an uninstall, read as a live conflict. 3d893013 then made the warning
a SuppressibleMsgBox defaulting to IDNO so unattended installs could not hang on
it; under winget's /VERYSILENT /SUPPRESSMSGBOXES that default aborts
InitializeSetup, and Inno exits "failed to initialize" = code 1. Net effect:
anyone with Sunshine on disk — the exact audience most likely to migrate — could
not install via winget at all, with no visible reason.

The other candidate for exit 1 is excluded rather than assumed: the manifest
declares MinimumOSVersion 10.0.22621.0 matching the script's MinVersion, so
winget refuses an unsupported OS BEFORE downloading, and the report shows the
hash verified and the install starting. The conflict check is also the only
`Result := False` in InitializeSetup.

Detection now keys on the service's `Start` value (0 boot / 1 system /
2 automatic / 3 manual / 4 disabled), counting only 0-2 — a host that comes up
on its own is the only kind that can take the GameStream ports or load a second
virtual-display driver. Directories no longer count at all: they were the
noisiest signal and the one that survives an uninstall. This makes the installer
agree with the tray, which dropped its always-on warning over a merely-INSTALLED
Sunshine in this same release (3e782852) for exactly the same reason. The
message now says "installed and set to start automatically" and offers disabling
the service as a remedy, both of which are what is actually detected.

A conflicting host running as a plain user process is deliberately not detected:
pure Pascal cannot enumerate processes, it is a runtime rather than install-time
condition, and the host already reports it via the `punktfunk::detect` startup
warning, `detect-conflicts`, and /api/v1/local/summary.

Verified by compiling the [Code] section with ISCC 6.7.1 on the windows-amd64
runner (WithWeb defined, as CI does). That caught two defects in my own first
draft that review would not have: a continuation line STARTING with #13#10, which
ISPP reads as a preprocessor directive ("Unknown preprocessor directive", compile
aborted), and `{commonpf}` written inside a `{ }` comment, which closes it early
since Inno comments do not nest. Both are noted at the sites so they are not
reintroduced.
2026-07-27 09:00:34 +02:00
enricobuehler ec9aa415f6 chore(web): override brace-expansion to 5.0.8 — clears GHSA-mh99-v99m-4gvg
audit / bun-audit (push) Successful in 19s
ci / docs-site (push) Successful in 2m18s
audit / cargo-audit (push) Successful in 3m22s
ci / web (push) Successful in 1m6s
apple / swift (push) Successful in 5m10s
ci / bench (push) Successful in 5m49s
ci / rust-arm64 (push) Successful in 9m52s
arch / build-publish (push) Successful in 13m17s
decky / build-publish (push) Successful in 54s
deb / build-publish-client-arm64 (push) Successful in 12m15s
android / android (push) Successful in 18m58s
deb / build-publish-host (push) Successful in 16m33s
windows-host / package (push) Successful in 19m55s
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 11m50s
apple / screenshots (push) Successful in 23m33s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m25s
ci / rust (push) Successful in 33m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m45s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 37s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 36s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 28s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 34s
docker / build-push-arm64cross (push) Successful in 5m16s
docker / deploy-docs (push) Successful in 38s
bun-audit has been red since the advisory published, so it was already failing
before v0.20.0 was cut (same failure on 0323158a). High-severity DoS: unbounded
expansion length causing an OOM crash. All three paths are dev tooling —
orval→typedoc→minimatch, @storybook/react-vite→glob→minimatch, and
nitro-v2-vite-plugin→nitropack→archiver→archiver-utils→glob→minimatch — so
nothing shipped to a user was exposed, but the leg gates every lockfile change.

Done as an `overrides` entry, joining the eight already there for exactly this
purpose. `bun update brace-expansion` is NOT the fix and was reverted: for a
package that is only transitive it adds a spurious DIRECT dependency, and it
does not even resolve the advisory — it hoists 5.0.8 for the new direct dep and
leaves `minimatch/brace-expansion` pinned at the vulnerable 5.0.7.

The advisory's affected range is `<= 5.0.7` across ALL majors with 5.0.8 the
only patched version, so the two nested 2.1.2 copies had to move too — a major
bump for consumers declaring ^2.0.1/^2.0.2. Checked rather than assumed: 5.0.8
is dual ESM/CJS with a real `require` branch in its exports map (2.1.2 was
plain CJS), so those consumers still resolve. Proven by building: the nitro
build is itself one of the forced consumers.

Verified: `bun audit` reports no vulnerabilities, both nested entries are gone
from the lock, and the full CI web sequence is green —
`bun install --frozen-lockfile --ignore-scripts`, `bun run build`, `bun run lint`.
2026-07-27 07:32:08 +02:00
enricobuehler 24d2f97eae fix(ci/winget): forward GITHUB_REF_NAME into the remote shell
The catalogue-verify step has failed on every stable tag since it landed,
v0.20.0 included: `bash: line 6: GITHUB_REF_NAME: unbound variable`, twice,
then exit 1.

`env:` populates the RUNNER's environment. appleboy/ssh-action executes its
`script` on the REMOTE host over SSH, which inherits none of it — the action
has a separate `envs:` input naming the variables to forward. Without it the
`set -u` at the top of the script turns the first expansion into a fatal, and
the two `curl`s either side report only the collateral: `Failed writing body`
is curl losing its pipe when the `grep` it was feeding died.

The failure is maximally misleading, because everything it guards ALREADY
WORKED: the catalogue is built, tested and shipped in the preceding steps, and
only the proof-of-serving fails. Confirmed by hand against the live source
during the v0.20.0 release — it serves unom.PunktfunkHost 0.20.0 correctly.

The pattern was already right everywhere else: both "Pull and start docs" steps
(deploy-services.yml, docker.yml) pair `env:` with `envs:`. A sweep of the
other three ssh-action steps found no further instance — the remaining two pass
no variables at all.
2026-07-27 07:31:55 +02:00
enricobuehler c9b8f666cd docs(release): winget needs its source added first — say so
Reported within an hour of the announcement: `winget install unom.PunktfunkHost`
answers "no package found". That is correct behaviour, not a broken package —
Punktfunk is served from its own REST source, and winget only searches sources
it has been told about. The notes said "after adding Punktfunk's package source
once" without ever giving the command, so there was no way to act on it.

Both commands are now spelled out, the `winget source add` one flagged as
needing an Administrator terminal (it does), and the failure mode is stated
explicitly so someone who hits it recognises it rather than filing it as a bug.

The lead-in is untouched, so the Discord embed for v0.20.0 stays accurate and
this needs no re-announcement — the release body is re-synced from this file.
2026-07-27 07:31:37 +02:00
enricobuehler 35b6c835fd docs: on a gamescope session the display outranks the end-game setting
ci / web (push) Successful in 1m23s
ci / docs-site (push) Successful in 1m19s
decky / build-publish (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 57s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 26s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
ci / bench (push) Successful in 5m32s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 9m2s
windows-host / package (push) Successful in 20m22s
windows-host / winget-source (push) Skipped
arch / build-publish (push) Successful in 14m46s
docker / build-push-arm64cross (push) Successful in 30s
docker / deploy-docs (push) Successful in 52s
android / android (push) Successful in 16m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m36s
ci / rust (push) Successful in 27m3s
apple / swift (push) Successful in 6m5s
apple / screenshots (push) Successful in 25m23s
deb / build-publish-host (push) Successful in 16m6s
deb / build-publish-client-arm64 (push) Successful in 11m21s
deb / build-publish (push) Successful in 11m28s
On-glass on .41: with `game_on_session_end: keep` — "leave it running, nothing
is ever closed" — a deliberate stop ended the game, and a drop ended it ~14 s
later. Both times the LEASE did the right thing (no `ending the launched game`
line); the game died because the virtual display was torn down, and a nested
launch runs inside that gamescope.

So on a dedicated gamescope session the game's lifetime IS the display's, and
keep-alive decides it — not this setting:

  * a deliberate stop skips the linger by design, so the game goes at once,
  * a drop lingers the keep-alive window, then takes the game with it,
  * only keep-alive Forever actually keeps it — and `always` still ends it at
    the reconnect window, which is the documented precedence and was verified
    (a `forever`-pinned display released at grace expiry, 60.006 s).

That makes the console's promise untrue on the most common Linux setup, so the
card now says so for every option rather than only warning about `always`, and
the docs get the three cases as a table. Worded so a non-gamescope host reads it
and moves on — a desktop-session launch is an ordinary process and none of it
applies.

Not changing behavior here: making `keep` pin the display would pit it against
"a stop must not leave a ghost display", and which wins is a product call rather
than something to decide silently mid-test.

web: codegen + tsc + biome clean, en/de complete. docs-site: build + tsc clean.
2026-07-27 01:43:40 +02:00
enricobuehler 39a9f09f04 fix(mgmt/web): the running-game cover slot says "game" when there is no art
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m46s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 1m1s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 31s
deb / build-publish-client-arm64 (push) Successful in 8m0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 31s
deb / build-publish-host (push) Successful in 10m43s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
ci / docs-site (push) Successful in 1m57s
arch / build-publish (push) Successful in 13m21s
android / android (push) Successful in 17m20s
ci / rust-arm64 (push) Successful in 13m46s
deb / build-publish (push) Successful in 14m47s
ci / rust (push) Successful in 21m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m39s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
ci / bench (push) Successful in 6m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m21s
docker / build-push-arm64cross (push) Successful in 8m21s
docker / deploy-docs (push) Successful in 40s
apple / swift (push) Successful in 5m37s
apple / screenshots (push) Successful in 23m3s
windows-host / package (push) Successful in 18m16s
windows-host / winget-source (push) Skipped
Rendered the Dashboard stories headlessly and looked at them, which is how this
turned up: with no cover the slot was an empty grey rectangle, reading as
something that failed to load rather than something absent.

Plenty of rows will never have art — an operator-typed command has no catalog
entry behind it, a custom entry may carry none, and nothing does until
`/library` has loaded. The slot keeps its fixed size so rows stay aligned when
only some titles have covers; it just holds a gamepad glyph instead of nothing.
2026-07-27 00:47:05 +02:00
56 changed files with 1770 additions and 209 deletions
+6
View File
@@ -438,3 +438,9 @@ jobs:
echo "winget source serving ${GITHUB_REF_NAME#v}"
env:
GITHUB_REF_NAME: ${{ gitea.ref_name }}
# `env:` populates the RUNNER's environment; this action runs `script` on the REMOTE
# host, which inherits nothing from it. `envs:` is the action's own allow-list of names
# to forward into the remote shell — without it `set -u` aborted at the first expansion
# ("GITHUB_REF_NAME: unbound variable") and the step failed on every tag, after the
# catalogue had already shipped correctly.
envs: GITHUB_REF_NAME
Generated
+34 -27
View File
@@ -1020,6 +1020,13 @@ dependencies = [
"subtle",
]
[[package]]
name = "display-disturb"
version = "0.20.1"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -2194,7 +2201,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.20.0"
version = "0.20.1"
[[package]]
name = "lazy_static"
@@ -2299,7 +2306,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"bindgen",
"cmake",
@@ -2334,7 +2341,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"punktfunk-core",
]
@@ -2823,7 +2830,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ashpd",
@@ -2844,7 +2851,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ash",
@@ -2868,7 +2875,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ashpd",
@@ -2886,7 +2893,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ash",
@@ -2907,7 +2914,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ash",
@@ -2931,7 +2938,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"ash",
"bindgen",
@@ -2940,7 +2947,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"libc",
@@ -2952,7 +2959,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2966,11 +2973,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.20.0"
version = "0.20.1"
[[package]]
name = "pf-inject"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ashpd",
@@ -2999,14 +3006,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ash",
@@ -3021,7 +3028,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ashpd",
@@ -3053,7 +3060,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"pf-paths",
@@ -3065,7 +3072,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ash",
@@ -3273,7 +3280,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"android_logger",
"jni",
@@ -3289,7 +3296,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"async-channel",
@@ -3305,7 +3312,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"pf-client-core",
@@ -3320,7 +3327,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3339,7 +3346,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"aes-gcm",
"bytes",
@@ -3371,7 +3378,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"aes",
"aes-gcm",
@@ -3455,7 +3462,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3469,7 +3476,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"anyhow",
"ksni",
@@ -3492,7 +3499,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"bindgen",
"cmake",
+2 -1
View File
@@ -28,6 +28,7 @@ members = [
"clients/session",
"clients/windows",
"clients/android/native",
"tools/display-disturb",
"tools/latency-probe",
"tools/loss-harness",
]
@@ -48,7 +49,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.20.0"
version = "0.20.1"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+2 -2
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.19.2"
"version": "0.20.0"
},
"paths": {
"/api/v1/clients": {
@@ -6633,7 +6633,7 @@
"mbps": {
"type": "number",
"format": "float",
"description": "Transmit goodput (Mb/s)."
"description": "Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes\nplus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned\nmode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops."
},
"packets_dropped": {
"type": "integer",
+17 -1
View File
@@ -13,8 +13,17 @@
// console behind the couch UI looks like a crash.
#![cfg_attr(windows, windows_subsystem = "windows")]
// The shared client log sink (std-only): a couch launch has no console, so the session's
// stderr would otherwise evaporate — same reasoning as the shell's tee. This shim only
// initializes + forwards; the module's subscriber-side surface stays unused here.
#[cfg(windows)]
#[path = "../logfile.rs"]
#[allow(dead_code)]
mod logfile;
#[cfg(windows)]
fn main() {
logfile::init();
// The session binary ships beside us in the package; fall back to PATH for a dev run.
let session = std::env::current_exe()
.ok()
@@ -27,7 +36,14 @@ fn main() {
if !std::env::args().any(|a| a == "--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(_) => std::process::exit(1),
}
+98
View File
@@ -0,0 +1,98 @@
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
//!
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
//! latency-sawtooth field report had to be triaged from host logs alone because the client
//! side had nowhere to land.
//!
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
/// Rotate at the next start once the file exceeds this (the host's cap).
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
fn log_dir() -> Option<PathBuf> {
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
}
/// The log file's path, for the "logs land here" startup line (and any future UI affordance).
pub(crate) fn path() -> Option<PathBuf> {
Some(log_dir()?.join("client.log"))
}
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
/// subscriber installs; every later [`tee`] shares the handle.
pub(crate) fn init() {
SINK.get_or_init(|| {
let dir = log_dir()?;
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("client.log");
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
let old = dir.join("client.log.old");
// Windows `rename` refuses an existing destination — drop the old generation first.
let _ = std::fs::remove_file(&old);
let _ = std::fs::rename(&path, &old);
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()?;
Some(Arc::new(Mutex::new(file)))
});
}
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
/// `with_writer` factory and the session-stderr forwarder both use it.
pub(crate) struct Tee;
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
pub(crate) fn tee() -> Tee {
Tee
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let _ = io::stderr().write_all(buf);
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().write_all(buf);
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
let _ = io::stderr().flush();
if let Some(Some(f)) = SINK.get() {
let _ = f.lock().unwrap().flush();
}
Ok(())
}
}
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
/// pipe (child exit).
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
let _ = std::thread::Builder::new()
.name("punktfunk-session-log".into())
.spawn(move || {
let mut reader = io::BufReader::new(stderr);
let mut line = String::new();
let mut tee = Tee;
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
let _ = tee.write_all(line.as_bytes());
line.clear();
}
});
}
+21 -1
View File
@@ -26,6 +26,8 @@ mod discovery;
#[cfg(windows)]
mod gpu;
#[cfg(windows)]
mod logfile;
#[cfg(windows)]
mod probe;
#[cfg(windows)]
mod shell_window;
@@ -49,11 +51,20 @@ fn main() {
}
set_app_user_model_id();
// Everything logs to stderr AND `%LOCALAPPDATA%\punktfunk\logs\client.log` (see [`logfile`]):
// a GUI/MSIX launch has no console, so without the file the client side of any field report
// simply doesn't exist. ANSI off — the file is what users send, keep it grep-clean.
logfile::init();
tracing_subscriber::fmt()
.with_ansi(false)
.with_writer(logfile::tee)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
if let Some(p) = logfile::path() {
tracing::info!(path = %p.display(), "client log file (rotated at 10 MB, one .old kept)");
}
let args: Vec<String> = std::env::args().collect();
let flag = |name: &str| args.iter().any(|a| a == name);
@@ -88,7 +99,16 @@ fn main() {
if !flag("--windowed") {
cmd.arg("--fullscreen");
}
match cmd.status() {
// Spawn (not `status()`) so the session's stderr rides the log tee — a couch launch
// (Start-menu tile, Steam shortcut) has no console to inherit either.
cmd.stderr(std::process::Stdio::piped());
let run = cmd.spawn().and_then(|mut child| {
if let Some(stderr) = child.stderr.take() {
logfile::forward_child_stderr(stderr);
}
child.wait()
});
match run {
Ok(st) => std::process::exit(st.code().unwrap_or(0)),
Err(e) => {
eprintln!("could not start the console UI: {e}");
+7 -1
View File
@@ -169,13 +169,19 @@ fn spawn_with(
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit()) // session logs interleave with the shell's (dev runs)
// Piped through the log tee: dev-terminal runs keep the interleaved stderr they always
// had, and GUI runs — which have no console — finally keep the session's whole
// receive/decode/present log in the client log file.
.stderr(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW);
let mut child = cmd
.spawn()
.map_err(|e| format!("couldn't start punktfunk-session: {e}"))?;
tracing::info!(host = %host_label, "session binary spawned");
if let Some(stderr) = child.stderr.take() {
crate::logfile::forward_child_stderr(stderr);
}
let stdout = child.stdout.take().expect("piped stdout");
// Park the child where the kill handle (and the reader, for the final reap) reach it.
*slot.0.lock().unwrap() = Some(child);
+12
View File
@@ -732,6 +732,18 @@ impl IddPushCapturer {
return; // no new sample since last consume
}
self.desc_seq = seq;
// A topology reassert is in flight (the exclusive watchdog announced it): every sample in
// this window is potentially the TRANSIENT eviction state, and acting on one recreates
// the ring at a mode the reassert's recovery chain (`recreate_ring_in_place`, keyed off
// the reassert generation) is about to undo — the field hdr=true→false→true double
// recreate. Consume the sample, disarm the debounce, act on nothing; a REAL change that
// races the window survives it (the descriptor still differs once the hold clears, and
// the poller re-samples in ~250 ms). This also keeps the negotiated-depth pin-back below
// from issuing a CCD write mid-eviction, where it would fight the reassert itself.
if pf_win_display::topology_churn::held() {
self.pending_desc = None;
return;
}
// Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR"
// flip — flip the display back and treat the descriptor as the negotiated state (so the ring
// is never recreated at the wrong format):
@@ -115,7 +115,7 @@ impl StallWatch {
delivered no frame for the gap; the present path stalled below capture"
);
if let Some(period) = stall.metronomic {
let suspects = pf_win_display::display_events::connected_inactive_externals();
let suspects = pf_win_display::display_events::connected_inactive_physicals();
let suspects = if suspects.is_empty() {
"none".to_string()
} else {
@@ -151,10 +151,13 @@ impl StallWatch {
display-poller software (the SteelSeries-GG/SignalRGB class — \
correlate 'slow display-descriptor poll' lines), or the DWM present \
clock (try a different refresh rate). If connected_inactive lists a \
display, its standby probing is the prime suspect: unplug it at the \
GPU, disable its OSD auto input scan (TVs: instant-on/quick-start + \
CEC off), use an HPD-holding adapter/dummy, or keep it active while \
streaming"
display, its standby servicing is the prime suspect. For a LAPTOP \
PANEL (the exclusive isolate deactivated it — the dark-but-connected \
head is itself the disturbance on hybrid laptops): keep it active \
with `topology: primary`, or try the `pnp_disable_monitors` axis. \
For an external display: unplug it at the GPU, disable its OSD auto \
input scan (TVs: instant-on/quick-start + CEC off), use an \
HPD-holding adapter/dummy, or keep it active while streaming"
);
}
}
+21 -1
View File
@@ -395,6 +395,9 @@ pub struct PyroWaveEncoder {
/// packet to it, so each wire shard carries whole self-delimiting packets. `None` =
/// one packet per AU (the dense MVP shape).
wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
wire_budget: crate::pyrowave_wire::WireBudget,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
frame_count: u64,
@@ -653,6 +656,7 @@ impl PyroWaveEncoder {
chroma444,
frame_budget: budget_for(bitrate, fps),
wire_chunk: None,
wire_budget: crate::pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(),
pending: VecDeque::new(),
frame_count: 0,
@@ -1120,6 +1124,16 @@ impl PyroWaveEncoder {
Ok(self.cpu_img.unwrap().2)
}
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
/// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command
/// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`.
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
@@ -1175,6 +1189,8 @@ impl PyroWaveEncoder {
// paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s
// before anything touches `cmd`; a buffer that completed its one-time submit is INVALID,
// which the next `begin` may implicitly reset.
// Resolved before the closure (which borrows `self` mutably for the recording calls).
let rate_budget = self.rate_budget();
let record_and_submit = (|| -> Result<()> {
dev.begin_command_buffer(
self.cmd,
@@ -1396,7 +1412,7 @@ impl PyroWaveEncoder {
],
};
let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget,
maximum_bitstream_size: rate_budget,
};
pw::pyrowave_device_set_command_buffer(
self.pw_dev,
@@ -1476,6 +1492,10 @@ impl PyroWaveEncoder {
// single packet, or the datagram-aligned windowed AU (§4.4).
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.frame_count += 1;
self.pending.push_back(EncodedFrame {
data: au,
+86
View File
@@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32
count
}
/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode.
///
/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing
/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the
/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At
/// 1440p/~850 KiB frames that is ×1.21.3 — the 2026-07 field report's "Automatic" 407 Mb/s
/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget
/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and
/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing
/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions
/// carry those on top of the configured bitrate too, and the pin must mean the same thing for
/// every codec.
pub(crate) struct WireBudget {
/// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point.
scale_x1024: u32,
}
impl WireBudget {
/// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA
/// converges onto the session's real ratio within ~a second of frames.
const PRIOR_X1024: u32 = 1280;
/// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change
/// re-converges in ~16 frames.
const EMA_SHIFT: u32 = 3;
/// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never
/// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely).
const MIN_X1024: u32 = 1024;
const MAX_X1024: u32 = 2048;
pub(crate) fn new() -> WireBudget {
WireBudget {
scale_x1024: Self::PRIOR_X1024,
}
}
/// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the
/// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire).
pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) {
if bitstream_len == 0 {
return;
}
let sample = ((au_len as u64 * 1024) / bitstream_len as u64)
.clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32;
let ema = self.scale_x1024 as i64;
self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32;
}
/// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the
/// currently-measured inflation.
pub(crate) fn deflate(&self, budget: usize) -> usize {
let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64;
((budget as u64 * 1024) / scale) as usize
}
}
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
@@ -259,6 +314,37 @@ mod tests {
assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32);
}
/// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation,
/// deflates the budget by exactly that ratio, and clamps runaway samples.
#[test]
fn wire_budget_converges_and_deflates() {
let mut wb = WireBudget::new();
// Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative.
assert_eq!(wb.deflate(1_024_000), 819_200);
// Feed a steady ×1.30 inflation; the EMA must converge onto it.
for _ in 0..64 {
wb.observe(1000, 1300);
}
let b = wb.deflate(1_024_000);
let expect = 1_024_000_u64 * 1000 / 1300;
assert!(
(b as i64 - expect as i64).unsigned_abs() < 8_000,
"budget {b} should approach {expect}"
);
// A dense-ish run (×1.0) walks it back down to no deflation.
for _ in 0..64 {
wb.observe(1000, 1000);
}
assert_eq!(wb.deflate(1_024_000), 1_024_000);
// Garbage samples are clamped: an absurd ratio can at most halve the budget…
for _ in 0..256 {
wb.observe(10, 1000);
}
assert!(wb.deflate(1_024_000) >= 512_000);
// …and a zero-length observation is ignored, never a division by zero.
wb.observe(0, 1000);
}
#[test]
fn stamp_color_bits_sets_range_and_hdr_bits() {
let mut bs = vec![0u8; 16];
+19 -1
View File
@@ -159,6 +159,9 @@ pub struct PyroWaveEncoder {
frame_budget: usize,
/// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU.
wire_chunk: Option<usize>,
/// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the
/// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]).
wire_budget: pyrowave_wire::WireBudget,
bitstream: Vec<u8>,
pending: VecDeque<EncodedFrame>,
}
@@ -288,6 +291,7 @@ impl PyroWaveEncoder {
hdr16,
frame_budget,
wire_chunk: None,
wire_budget: pyrowave_wire::WireBudget::new(),
bitstream: Vec::new(),
pending: VecDeque::new(),
})
@@ -441,6 +445,16 @@ impl PyroWaveEncoder {
///
/// # Safety
/// Runs on the single encode thread; all pyrowave calls take handles this struct owns.
/// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the
/// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is
/// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]).
fn rate_budget(&self) -> usize {
match self.wire_chunk {
Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024),
None => self.frame_budget,
}
}
unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> {
// A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than
// handing null to pyrowave (see the Linux twin).
@@ -616,7 +630,7 @@ impl PyroWaveEncoder {
sync: std::mem::zeroed(),
};
let rc = pw::pyrowave_rate_control {
maximum_bitstream_size: self.frame_budget,
maximum_bitstream_size: self.rate_budget(),
};
pw_check(
pw::pyrowave_encoder_encode_gpu_synchronous(
@@ -664,6 +678,10 @@ impl PyroWaveEncoder {
}
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
if self.wire_chunk.is_some() {
let raw: usize = pkts.iter().map(|&(_, s)| s).sum();
self.wire_budget.observe(raw, au.len());
}
self.pending.push_back(EncodedFrame {
data: au,
pts_ns: frame.pts_ns,
+1 -1
View File
@@ -77,7 +77,7 @@ pub use session::{session_epoch, try_recover_session};
#[path = "vdisplay/routing.rs"]
pub(crate) mod routing;
pub use routing::{
apply_input_env, managed_session_available, restore_managed_session,
apply_input_env, managed_session_available, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
};
#[cfg(target_os = "linux")]
+8 -3
View File
@@ -181,9 +181,14 @@ pub fn panel_off_except(exclude_gdi: &str) -> u32 {
acked += set_power(m.hmon, &m.device, POWER_OFF);
}
if acked == 0 {
tracing::debug!(
"DDC/CI: no physical panel accepted the DPMS-off command \
(no DDC/CI-capable panel besides the virtual display)"
// INFO, not debug: the user opted into this axis, so "it did nothing" is an answer they
// asked for. The common case is a laptop — internal eDP/LVDS panels have NO DDC/CI at
// all (their brightness/power runs over the driver's own channel), so `ddc_power_off`
// is structurally a no-op for them (reporter feedback 2026-07-27).
tracing::info!(
"DDC/CI: no panel accepted the DPMS-off command — the ddc_power_off axis did \
nothing on this display set (internal eDP/LVDS panels expose no DDC/CI; external \
monitors may have it disabled in the OSD or dropped by a dock/KVM)"
);
}
acked
@@ -71,14 +71,23 @@ static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(
/// master — live-proven on the Nobara repro VM 2026-07-24).
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// mtime of the `steamos-session-select` sentinel at managed-session launch — the baseline the
/// in-stream "Switch to Desktop" detector compares against. Steam's session-select script writes
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
/// "Switch to Desktop" detector compares against. Steam's session-select script writes
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
/// therefore the one durable trace of the user's switch request.
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<std::time::SystemTime>> =
///
/// Two levels of `Option`, because "no baseline" and "no sentinel" mean opposite things:
/// * **outer `None`** — never baselined (no takeover this host lifetime). Nothing can read as an
/// in-stream request: the sentinel is a permanent file, so any box whose user has EVER switched
/// sessions has one, and comparing against a missing baseline made that ancient write look like
/// a live "Switch to Desktop".
/// * **`Some(None)`** — baselined while no sentinel existed yet; a later one was created inside the
/// session, which IS a request.
/// * **`Some(Some(t))`** — baselined at mtime `t`; anything newer is a request.
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<Option<std::time::SystemTime>>> =
std::sync::Mutex::new(None);
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
@@ -1155,8 +1164,8 @@ const DM_HELPER_PATHS: &[&str] = &[
"/usr/lib/punktfunk/pf-dm-helper",
];
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
/// Run the packaged DM helper (`stop` | `restore` | `linger`) via pkexec. `false` when the helper
/// isn't installed (tarball/old package), pkexec is missing, or polkit denies the action.
fn dm_helper(verb: &str) -> bool {
let Some(helper) = DM_HELPER_PATHS
.iter()
@@ -1172,18 +1181,102 @@ fn dm_helper(verb: &str) -> bool {
.unwrap_or(false)
}
/// `systemctl` on the SYSTEM bus, **never interactively**. Every privileged verb below runs on the
/// stream's own thread — the capture-loss rebuild, or the restore worker — with no way to answer a
/// question. Without `--no-ask-password`, systemctl asks polkit for interactive authorization, and
/// on a box whose desktop session is still alive (the host is a `--user` unit inside it) polkit
/// hands that to the session's agent: a password dialog on the box's OWN screen, which during a
/// managed takeover is off or mid-switch. Nobody sees it, nobody answers it, and the call blocks
/// while the rebuild budget burns — the takeover then lands after the session it was for already
/// ended. `--no-ask-password` turns that into the immediate "interactive authentication required"
/// failure the callers are written for, so the pkexec helper (`allow_any`, no agent needed) takes
/// over instead of a dialog. Field-suspect in the 0.20.0 Nobara report (intermittent disconnect +
/// a screen that never comes back), where the timing is a race against the KDE agent's own death.
fn systemctl_system(args: &[&str]) -> bool {
let mut cmd = Command::new("systemctl");
cmd.arg("--no-ask-password").args(args);
cmd.status().map(|s| s.success()).unwrap_or(false)
}
/// Would stopping the display manager also stop US? A packaged host runs as a `systemd --user`
/// unit, so its lifetime hangs off the user manager — and the DM stop ends the user's last login
/// session. logind then stops `user@<uid>.service` once `UserStopDelaySec` (10 s by default)
/// elapses, taking the host with it: the stream dies mid-takeover, and nothing is left to restart
/// the display manager, so the box stays dark until someone reaches a VT. **Field-proven on 0.20.0**
/// (Nobara, 2026-07-27): DM stopped at 12:34:18.9, the user manager stopped the host at 12:34:29.0
/// — 10.1 s, textbook `UserStopDelaySec`. It never showed on the repro VM because lingering was
/// enabled there for the sessionless tests.
///
/// Lingering (`loginctl enable-linger` — which the KDE/GNOME/Arch setup docs already ask for) is
/// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure
/// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then
/// degrades to attach, which mirrors the box's own session and never stops the DM.
fn ensure_host_survives_dm_stop() -> bool {
if !host_is_under_user_manager() {
return true; // root / a system unit — the DM stop cannot reach us
}
if linger_enabled() {
return true;
}
// `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the
// user's session can do this itself; a sessionless one (the packaged unit) goes through the
// helper, whose grant is scoped to the calling uid.
let uid = uid_string();
let _ = Command::new("loginctl")
.args(["--no-ask-password", "enable-linger", &uid])
.status();
if linger_enabled() || (dm_helper("linger") && linger_enabled()) {
tracing::info!(
uid,
"enabled lingering for this user — the managed takeover stops the display manager, \
which ends this login session, and without lingering logind would stop the host \
along with it (`loginctl disable-linger` reverts it)"
);
return true;
}
false
}
/// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager
/// stop take us down)? Read from our own cgroup path.
fn host_is_under_user_manager() -> bool {
std::fs::read_to_string("/proc/self/cgroup")
.as_deref()
.map(cgroup_under_user_manager)
.unwrap_or(false)
}
/// [`host_is_under_user_manager`]'s test: does this `/proc/self/cgroup` content sit under a
/// `user@<uid>.service` manager? Pure + unit-tested. A system unit
/// (`/system.slice/punktfunk-host.service`) does not, and neither does a bare process started from
/// a login shell (`/user.slice/user-1000.slice/session-2.scope`) — logind's user-manager stop only
/// reaches units the user manager owns.
fn cgroup_under_user_manager(cgroup: &str) -> bool {
cgroup.contains("user@")
}
/// Our uid as a string — what `loginctl` wants for a user argument.
fn uid_string() -> String {
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
unsafe { libc::getuid() }.to_string()
}
/// Is lingering on for this user (logind keeps the `--user` manager alive with no session)?
fn linger_enabled() -> bool {
Command::new("loginctl")
.args(["show-user", &uid_string(), "-p", "Linger", "--value"])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes")
.unwrap_or(false)
}
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
/// attach.
fn try_stop_display_manager(dm: &str) -> bool {
let direct = Command::new("systemctl")
.args(["stop", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("stop")
systemctl_system(&["stop", dm]) || dm_helper("stop")
}
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
@@ -1192,15 +1285,8 @@ fn try_stop_display_manager(dm: &str) -> bool {
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
/// two steps as root.
fn restore_display_manager(dm: &str) -> bool {
let _ = Command::new("systemctl")
.args(["reset-failed", dm])
.status();
let direct = Command::new("systemctl")
.args(["restart", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("restore")
let _ = systemctl_system(&["reset-failed", dm]);
systemctl_system(&["restart", dm]) || dm_helper("restore")
}
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
@@ -1226,24 +1312,39 @@ fn session_select_mtime() -> Option<std::time::SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
/// Record the sentinel baseline at managed-session launch, so a LATER write (the user's in-stream
/// "Switch to Desktop") is distinguishable from the switch that led into this session.
/// Record the sentinel baseline, so a LATER write (the user's in-stream "Switch to Desktop") is
/// distinguishable from the switch that led into this session. Taken at **takeover** (the moment
/// [`STOPPED_DM`] is set, which is what arms the honor gate) and again at a successful launch: the
/// switch INTO game mode writes the sentinel on its way in, and that write must never read as a
/// request to go back out. Baselining only at launch left the window in between — a takeover whose
/// launch failed, then a client retry inside the restore debounce — reading a months-old sentinel
/// as a live request and pushing the box to the desktop the user never asked for.
fn record_session_select_baseline() {
*SESSION_SELECT_BASELINE
.lock()
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime();
.unwrap_or_else(|e| e.into_inner()) = Some(session_select_mtime());
}
/// Did a session-select run inside the managed session since its launch (sentinel newer than the
/// recorded baseline, or newly created)? Inside a managed game session the only switch Steam
/// offers is TO the desktop, so an advanced sentinel reads as that request.
/// Did a session-select run inside the managed session since the baseline? Inside a managed game
/// session the only switch Steam offers is TO the desktop, so an advanced sentinel reads as that
/// request.
fn session_select_requested() -> bool {
let baseline = *SESSION_SELECT_BASELINE
.lock()
.unwrap_or_else(|e| e.into_inner());
match (baseline, session_select_mtime()) {
(Some(base), Some(now)) => now > base,
(None, Some(_)) => true, // created during the session
sentinel_advanced(baseline, session_select_mtime())
}
/// [`session_select_requested`]'s decision, as a pure function of the two readings (the
/// unit-testable core). No baseline ⇒ no request: see [`SESSION_SELECT_BASELINE`] for why a
/// missing baseline must not be read as "the sentinel appeared during the session".
fn sentinel_advanced(
baseline: Option<Option<std::time::SystemTime>>,
now: Option<std::time::SystemTime>,
) -> bool {
match (baseline, now) {
(Some(Some(base)), Some(now)) => now > base,
(Some(None), Some(_)) => true, // no sentinel at baseline — created during the session
_ => false,
}
}
@@ -1395,6 +1496,19 @@ fn stop_autologin_sessions() -> Result<()> {
return Ok(());
}
let dm = dm.expect("!is_none_or ⇒ Some");
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
// box's display manager down and nobody left to bring it back. Degrading to attach is
// strictly better than a black screen that needs a VT to recover.
if !ensure_host_survives_dm_stop() {
bail!(
"stopping {dm} ends this user's last login session, and without lingering logind \
would stop the user manager and this host with it about 10s later, leaving \
the box with no display manager and nothing to restore it; enabling lingering \
failed, so the managed takeover is unavailable (run `sudo loginctl enable-linger \
$USER` once, as the setup docs ask, then reconnect)"
);
}
if !try_stop_display_manager(&dm) {
bail!(
"the box's gaming session is driven by {dm}, which does not survive a masked \
@@ -1408,6 +1522,11 @@ fn stop_autologin_sessions() -> Result<()> {
%dm,
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
);
// Baseline the switch sentinel HERE, not just at a successful launch: setting STOPPED_DM
// is what arms the honor gate, so from this instant an unbaselined sentinel would read as
// an in-stream "Switch to Desktop" — including the write from the switch that just brought
// the box INTO game mode. A successful launch re-baselines (tighter still).
record_session_select_baseline();
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
}
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
@@ -1567,21 +1686,7 @@ fn restore_delay() -> Option<Duration> {
/// the managed session is pinned (gaming-rig). No-op when nothing was stolen (non-Bazzite / headless
/// box). Idempotent / safe to call on every session end.
pub fn schedule_restore_tv_session() {
let nothing_to_restore = STOPPED_AUTOLOGIN
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
&& STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none()
// A managed session that took nothing over (started beside a live desktop — e.g. a client
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
// was ORPHANED forever after disconnect ("closing the app does not end the session",
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
&& MANAGED_SESSION
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none();
if nothing_to_restore {
if !takeover_live() {
return; // nothing was taken over → nothing to restore (also the non-managed path)
}
match restore_delay() {
@@ -1604,6 +1709,48 @@ pub fn schedule_restore_tv_session() {
}
}
/// Is anything of the box's own session ours right now — an autologin unit we stopped, a stopped
/// display manager, a SteamOS target we re-pointed, or a managed session we launched beside a live
/// desktop? The precondition for every restore path.
fn takeover_live() -> bool {
!STOPPED_AUTOLOGIN
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
|| *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
|| STOPPED_DM
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
// A managed session that took nothing over (started beside a live desktop — e.g. a client
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
// was ORPHANED forever after disconnect ("closing the app does not end the session",
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
|| MANAGED_SESSION
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
}
/// Give the box its own session back **now**, synchronously — the host is going away (SIGTERM from
/// `systemctl --user stop`/`restart`, a package update, Ctrl-C) and a live takeover must not
/// outlive it. On a DM-flavor takeover the display manager is STOPPED: nothing else on the box
/// will ever restart it, and the persisted crash-restore state lives in `$XDG_RUNTIME_DIR`, which
/// logind removes along with the user manager — so not even the next host start can heal it. The
/// box would stay dark until someone reached a VT.
///
/// Deliberately ignores the keep-alive policy that [`schedule_restore_tv_session`] honors:
/// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host
/// that would serve them is exiting. No-op when nothing was taken over.
pub fn restore_takeover_now() {
if !takeover_live() {
return;
}
*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();
}
/// Does any DRM connector report a physically `connected` display? Scans
/// `/sys/class/drm/*/status` — only connector nodes (`card0-eDP-1`, `card0-HDMI-A-1`, …) have a
/// `status` file, so the bare `cardN` device dirs and `renderD*` nodes filter themselves out. A
@@ -2147,10 +2294,51 @@ impl Drop for GamescopeProc {
#[cfg(test)]
mod tests {
use super::{
cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
dm_survives_masked_unit, is_steam_launch, nested_wrapper_script, shape_dedicated_command,
cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under,
display_manager_unit_under, dm_survives_masked_unit, is_steam_launch,
nested_wrapper_script, sentinel_advanced, shape_dedicated_command,
};
#[test]
fn user_manager_lifetime_detection() {
// The packaged host: a `--user` unit, so logind's user-manager stop takes it down with the
// login session the DM stop ends — this is the case that needs lingering.
assert!(cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/punktfunk-host.service\n"
));
assert!(cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/user@1000.service/session.slice/punktfunk-gamescope.service\n"
));
// A system unit outlives every session — the DM stop cannot reach it.
assert!(!cgroup_under_user_manager(
"0::/system.slice/punktfunk-host.service\n"
));
// Started from a login shell: owned by the session scope, not the user manager.
assert!(!cgroup_under_user_manager(
"0::/user.slice/user-1000.slice/session-2.scope\n"
));
}
#[test]
fn session_select_sentinel_needs_a_baseline() {
let t0 = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
let t1 = t0 + std::time::Duration::from_secs(1);
// Never baselined: the sentinel is a permanent file, so a box whose user EVER switched
// sessions has one — that ancient write is not a live "Switch to Desktop" request. This is
// the case that pushed a Nobara box to the desktop after a failed managed launch.
assert!(!sentinel_advanced(None, Some(t0)));
assert!(!sentinel_advanced(None, None));
// Baselined with no sentinel yet, then one appeared inside the session: a real request.
assert!(sentinel_advanced(Some(None), Some(t0)));
assert!(!sentinel_advanced(Some(None), None));
// Baselined at an mtime: only a NEWER one is the user's in-stream switch. The write that
// brought the box into game mode is the baseline itself, so it reads as no request.
assert!(sentinel_advanced(Some(Some(t0)), Some(t1)));
assert!(!sentinel_advanced(Some(Some(t0)), Some(t0)));
assert!(!sentinel_advanced(Some(Some(t1)), Some(t0)));
assert!(!sentinel_advanced(Some(Some(t0)), None));
}
#[test]
fn nested_wrapper_script_shapes() {
let relay = std::path::Path::new("/run/user/1000/pf-ei");
@@ -268,6 +268,18 @@ pub fn restore_takeover_on_startup() {
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_on_startup() {}
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
/// to restart it. No-op when nothing was taken over.
#[cfg(target_os = "linux")]
pub fn restore_takeover_now() {
gamescope::restore_takeover_now();
}
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_now() {}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
@@ -865,11 +865,21 @@ impl VirtualDisplayManager {
reasserts = fighting,
"exclusive topology stable again — no non-managed display active"
);
// Close the churn window early — descriptor-following resumes now
// instead of waiting out the hold's self-expiry.
pf_win_display::topology_churn::release();
}
fighting = 0;
continue;
}
fighting += 1;
// Announce the churn BEFORE evicting: every descriptor the capturer's poller
// samples from here until "stable again" (or self-expiry) is potentially the
// TRANSIENT eviction state — acting on it would recreate the ring at a mode
// the recovery chain is about to undo (the field hdr=true→false→true double
// recreate). Window = watchdog interval + recovery/debounce margin, refreshed
// every fighting round.
pf_win_display::topology_churn::hold(interval + Duration::from_secs(3));
match fighting {
1..=3 => tracing::warn!(
survivors,
+18 -6
View File
@@ -156,15 +156,27 @@ pub fn summarize(events: &[DisplayEvent]) -> String {
out.join(", ")
}
/// The prime suspects for link-probe disturbances, from the cached inventory: external physical
/// displays that are CONNECTED but not part of the desktop (standby TV / input-switched monitor).
/// Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
pub fn connected_inactive_externals() -> Vec<String> {
/// The prime suspects for link-probe/dark-head disturbances, from the cached inventory: PHYSICAL
/// displays — external connectors AND internal panels — that are CONNECTED but not part of the
/// desktop. External = the classic standby TV / input-switched monitor; internal = the laptop
/// panel the exclusive isolate deactivated, whose driver-level servicing produces the identical
/// ~2 s metronome on hybrid laptops (field A/B 2026-07-27: reporter's Legion, exclusive
/// 16.3 stalls/min vs primary 0 — the old external-only filter reported `none` there and steered
/// the diagnosis AWAY from the real cause for hours). Virtual/indirect targets stay excluded
/// (precision rule). Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
pub fn connected_inactive_physicals() -> Vec<String> {
let st = state().lock().unwrap();
st.inventory
.iter()
.filter(|t| t.external_physical && !t.active)
.map(|t| format!("{} ({})", t.friendly, t.tech))
.filter(|t| (t.external_physical || t.internal_panel) && !t.active)
.map(|t| {
let name = if t.friendly.is_empty() && t.internal_panel {
"laptop panel"
} else {
&t.friendly
};
format!("{} ({})", name, t.tech)
})
.collect()
}
+3
View File
@@ -16,6 +16,9 @@ pub mod display_events;
mod input_desktop;
#[cfg(target_os = "windows")]
pub mod monitor_devnode;
/// Cross-crate "topology churn in flight" latch (pure std — no Windows surface, so unconditionally
/// compiled and unit-tested on every platform).
pub mod topology_churn;
#[cfg(target_os = "windows")]
pub mod win_display;
@@ -0,0 +1,74 @@
//! Cross-crate "topology churn in flight" latch (stall-immunity program): the exclusive-topology
//! reassert watchdog (pf-vdisplay's manager) announces that it is evicting/restoring displays, and
//! the IDD-push capturer's descriptor follower (pf-capture) defers acting on descriptor changes
//! while the window is open.
//!
//! Why: the reassert's forced re-commit transiently bounces the virtual display's mode — the
//! descriptor poller can sample the EVICTION state (field log 2026-07-27 10:30:44Z: `hdr=true` →
//! `hdr=false` → recreate → recovery restores `hdr=true` → second recreate). Every descriptor
//! sampled inside the window is potentially that transient, and acting on it recreates the ring at
//! a mode the recovery chain is about to undo. The deliberate recovery rebuild
//! (`recreate_ring_in_place`, keyed off the reassert generation) is NOT affected — only the
//! passive descriptor-following is.
//!
//! Deadline semantics, not a flag: a `hold()` self-expires, so a holder that dies mid-churn (or a
//! release lost to a teardown race) can never wedge descriptor-following off forever. `release()`
//! just expires the deadline early on the watchdog's "stable again" observation.
use std::sync::{
atomic::{AtomicU64, Ordering},
OnceLock,
};
use std::time::{Duration, Instant};
/// Deadline as milliseconds on the process-local [`clock`]; `0` = no hold.
static HOLD_UNTIL_MS: AtomicU64 = AtomicU64::new(0);
/// Process-local monotonic epoch (an `Instant` cannot live in an atomic).
fn clock() -> u64 {
static EPOCH: OnceLock<Instant> = OnceLock::new();
EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
}
/// Open (or extend) the churn window for `dur` from now. `fetch_max` so overlapping holders — a
/// reassert round racing a slot transition — never shorten each other's window.
pub fn hold(dur: Duration) {
let until = clock().saturating_add(dur.as_millis() as u64);
HOLD_UNTIL_MS.fetch_max(until, Ordering::Relaxed);
}
/// Expire the window now (the watchdog observed a stable topology again).
pub fn release() {
HOLD_UNTIL_MS.store(0, Ordering::Relaxed);
}
/// Is a churn window open? Cheap enough for a per-frame path (one atomic load + a monotonic read).
#[must_use]
pub fn held() -> bool {
clock() < HOLD_UNTIL_MS.load(Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
/// The latch's contract end-to-end: closed at rest, open after `hold`, extended by the longer
/// of two overlapping holds, closed by `release`, and self-expiring without one.
#[test]
fn hold_release_expire() {
release();
assert!(!held());
hold(Duration::from_secs(60));
assert!(held());
// A shorter overlapping hold must not shorten the window.
hold(Duration::from_millis(1));
assert!(held());
release();
assert!(!held());
// Self-expiry: a millisecond-scale hold lapses on its own.
hold(Duration::from_millis(30));
assert!(held());
std::thread::sleep(Duration::from_millis(60));
assert!(!held());
}
}
+10 -3
View File
@@ -844,15 +844,21 @@ pub unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option<u32> {
}
/// One CONNECTED display target from a full (`QDC_ALL_PATHS`) CCD sweep — the disturbance-
/// attribution inventory. `external_physical` is the load-bearing bit: a standby TV/monitor on a
/// real connector is the prime suspect for the periodic link-probe stutter class, while internal
/// panels and indirect/virtual targets (our own IDD included) are not.
/// attribution inventory. `external_physical` and `internal_panel` are the load-bearing bits: a
/// standby TV/monitor on a real connector is the classic suspect for the periodic link-probe
/// stutter class, and a laptop panel the exclusive isolate DEACTIVATED is the hybrid-laptop
/// variant of the same disturbance (field A/B 2026-07-27: dark-but-connected eDP head on the
/// iGPU → ~2 s stall metronome; `topology: primary` → zero) — only indirect/virtual targets
/// (our own IDD included) can never be suspects.
pub struct TargetInventory {
pub target_id: u32,
/// Whether any active path drives this target (part of the desktop right now).
pub active: bool,
/// External physical connector (HDMI/DP/DVI/…): candidate for standby link-probe churn.
pub external_physical: bool,
/// Internal panel (eDP/LVDS/embedded): candidate for dark-head servicing churn when
/// connected-but-inactive (the exclusive isolate on a laptop creates exactly that state).
pub internal_panel: bool,
/// Short connector label for logs (`"HDMI"`, `"DisplayPort"`, `"internal-panel"`, …).
pub tech: &'static str,
/// The monitor's friendly name (`"LG TV SSCR2"`); empty when the EDID carries none.
@@ -953,6 +959,7 @@ pub unsafe fn target_inventory() -> Vec<TargetInventory> {
target_id: t.id,
active: active.contains(&key),
external_physical,
internal_panel: tech == "internal-panel",
tech,
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
monitor_device_path: utf16z_str(&req.monitorDevicePath),
+2 -2
View File
@@ -52,8 +52,8 @@ pub(crate) struct Negotiated {
/// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks).
pub(crate) clock_offset_ns: i64,
/// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered —
/// mid-stream re-syncs are pointless then and stay off. The re-sync acceptance guard
/// compares each batch against this baseline ([`accept_resync`]).
/// mid-stream re-syncs are pointless then and stay off. Seeds the re-sync admission
/// guard's session-floor baseline ([`ResyncGuard`]).
pub(crate) clock_rtt_ns: Option<u64>,
/// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session.
pub(crate) bit_depth: u8,
@@ -248,7 +248,16 @@ pub(crate) struct EncodeLatAcc {
/// reference-chain break the loss counters never saw). A transient burst fills it briefly and drains on
/// its own, so a clump never costs a keyframe.
///
/// **All-intra exception** ([`set_all_intra`], PyroWave): every AU is independently decodable, so
/// the reference-chain reasoning above does not apply — a consumer that falls behind can skip
/// straight to the newest queued AU with zero recovery cost (no keyframe round-trip, no corrupt
/// dependents). [`pop`] then drains to the newest instead of returning the oldest, which caps any
/// standing queue at ~1 frame structurally; the 2026-07 field report's 780M client otherwise
/// ratcheted a genuine multi-frame backlog between the coarse jump-to-live thresholds.
///
/// [`clear`]: FrameChannel::clear
/// [`set_all_intra`]: FrameChannel::set_all_intra
/// [`pop`]: FrameChannel::pop
pub(crate) struct FrameChannel {
inner: Mutex<FrameQueue>,
ready: Condvar,
@@ -259,6 +268,11 @@ struct FrameQueue {
/// Set when the pump exits so a blocked [`FrameChannel::pop`] reports the stream ended
/// ([`PunktfunkError::Closed`]) rather than a spurious timeout (the old mpsc did this on sender drop).
closed: bool,
/// Every AU decodes independently (PyroWave): [`FrameChannel::pop`] drains to the newest.
all_intra: bool,
/// AUs skipped by the all-intra drain since the last [`FrameChannel::take_skipped`] — NOT
/// losses (the wire delivered them); the pump surfaces them at debug on its report tick.
skipped_total: u64,
}
/// Outcome of [`FrameChannel::pop`] — mirrors the old `recv_timeout` results so `next_frame`'s
@@ -275,11 +289,25 @@ impl FrameChannel {
inner: Mutex::new(FrameQueue {
q: VecDeque::new(),
closed: false,
all_intra: false,
skipped_total: 0,
}),
ready: Condvar::new(),
}
}
/// Pump side, once at session start: mark the stream all-intra (every AU independently
/// decodable) — [`Self::pop`] then drains to the newest queued AU instead of strict FIFO.
pub(crate) fn set_all_intra(&self, all_intra: bool) {
self.inner.lock().unwrap().all_intra = all_intra;
}
/// Pump side: AUs skipped by the all-intra drain since the last call (reset on read).
pub(crate) fn take_skipped(&self) -> u64 {
let mut st = self.inner.lock().unwrap();
std::mem::take(&mut st.skipped_total)
}
/// Pump side: append a completed AU and wake a blocked consumer. Enforces the memory backstop
/// ([`FRAME_QUEUE_HARD_CAP`]) by dropping the oldest (see its doc — a jump-to-live keyframe is
/// already in flight by the time this can bite).
@@ -312,12 +340,21 @@ impl FrameChannel {
self.ready.notify_all();
}
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive.
/// Consumer side: pop the oldest AU, waiting up to `timeout` for one to arrive. On an
/// all-intra stream ([`Self::set_all_intra`]) a multi-deep queue drains to the NEWEST AU
/// instead — the skipped ones are already superseded and decode independently, so showing
/// them only adds latency.
pub(crate) fn pop(&self, timeout: Duration) -> FramePop {
let mut st = self.inner.lock().unwrap();
if st.q.is_empty() && !st.closed {
st = self.ready.wait_timeout(st, timeout).unwrap().0;
}
if st.all_intra && st.q.len() > 1 {
st.skipped_total += (st.q.len() - 1) as u64;
let newest = st.q.pop_back().expect("len > 1");
st.q.clear();
return FramePop::Frame(newest);
}
if let Some(f) = st.q.pop_front() {
FramePop::Frame(f)
} else if st.closed {
@@ -364,6 +401,25 @@ mod frame_channel_tests {
assert_eq!(ch.depth(), 0);
}
/// The all-intra exception: a multi-deep queue drains to the NEWEST AU (every frame
/// decodes independently — older queued ones are superseded), the skips are counted
/// separately from losses, and a single-deep queue behaves exactly like FIFO.
#[test]
fn all_intra_drains_to_newest_and_counts_skips() {
let ch = FrameChannel::new();
ch.set_all_intra(true);
for i in 1..=3 {
ch.push(frame(i));
}
assert_eq!(popped(&ch), Some(3));
assert_eq!(ch.depth(), 0);
assert_eq!(ch.take_skipped(), 2);
assert_eq!(ch.take_skipped(), 0); // reset on read
ch.push(frame(4));
assert_eq!(popped(&ch), Some(4)); // depth 1 = plain FIFO, no skip accounting
assert_eq!(ch.take_skipped(), 0);
}
#[test]
fn empty_pop_times_out_not_closed() {
let ch = FrameChannel::new();
+2 -2
View File
@@ -11,8 +11,8 @@ use crate::abr::BitrateController;
use crate::config::Role;
use crate::packet::FLAG_PROBE;
use crate::quic::{
accept_resync, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho,
ClockResync, Hello, LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe,
io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClipState, ClockEcho, ClockResync, Hello,
LossReport, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, ResyncAdmit, ResyncGuard,
ResyncStep, SetBitrate, Start, Welcome,
};
use crate::session::Session;
@@ -52,6 +52,14 @@ impl ControlTask {
// the read arm below; only when the host answered the connect-time handshake — an
// old host would just eat the probes.
let mut resync = ClockResync::new();
let mut resync_guard = clock_rtt_ns.map(ResyncGuard::new);
// Inter-round spacing: without it the whole 8-round batch completes inside one
// ~6 ms video burst and every round samples the same congestion state — a batch
// that starts mid-burst is then wholly congested and gets rejected. 7 ms staggers
// the rounds across the ~16.7 ms frame cycle so the min-RTT round almost always
// lands in a quiet inter-burst gap, even at PyroWave-class bitrates.
const RESYNC_ROUND_SPACING: std::time::Duration = std::time::Duration::from_millis(7);
let mut staged_round: Option<tokio::time::Instant> = None;
let mut resync_tick = tokio::time::interval_at(
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
CLOCK_RESYNC_INTERVAL,
@@ -72,6 +80,7 @@ impl ControlTask {
if clock_rtt_ns.is_none() {
continue; // no connect-time handshake — host can't answer
}
staged_round = None; // a new batch abandons any staged round
resync.begin(wall_clock_ns()).encode()
}
CtrlRequest::ClipControl(c) => c.encode(),
@@ -83,11 +92,21 @@ impl ControlTask {
}
}
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
staged_round = None; // a new batch abandons any staged round
let probe = resync.begin(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break;
}
}
_ = async { tokio::time::sleep_until(staged_round.unwrap()).await },
if staged_round.is_some() => {
staged_round = None;
// Stamped at send time so the inter-round spacing stays out of the RTT.
let probe = resync.next_probe(wall_clock_ns());
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
break;
}
}
msg = ctrl_recv.read_msg() => {
let Ok(msg) = msg else { break }; // stream closed
if let Ok(ack) = Reconfigured::decode(&msg) {
@@ -132,16 +151,36 @@ impl ControlTask {
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
} else if let Ok(echo) = ClockEcho::decode(&msg) {
match resync.on_echo(&echo, wall_clock_ns()) {
ResyncStep::Probe(p) => {
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
break;
}
ResyncStep::MoreRounds => {
staged_round = Some(
tokio::time::Instant::now() + RESYNC_ROUND_SPACING,
);
}
ResyncStep::Done { offset_ns, rtt_ns } => {
// Never let a congested window bias the offset (frames read
// late exactly then) — keep the old estimate and let the next
// periodic batch try again.
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
let Some(guard) = resync_guard.as_mut() else {
continue; // no connect handshake — batches never start
};
let (apply, best_of_streak) = match guard.admit(offset_ns, rtt_ns)
{
ResyncAdmit::Fresh => (Some((offset_ns, rtt_ns)), false),
ResyncAdmit::BestOfStreak { offset_ns, rtt_ns } => {
(Some((offset_ns, rtt_ns)), true)
}
ResyncAdmit::Rejected { streak } => {
// warn, not debug: repeated rejections are exactly the
// stale-offset starvation signature the 2026-07
// PyroWave-sawtooth report had to be diagnosed without.
tracing::warn!(
rtt_us = rtt_ns / 1000,
floor_us = guard.floor_rtt_ns() / 1000,
streak,
"clock re-sync batch rejected — RTT above the \
session floor (congested window)"
);
(None, false)
}
};
if let Some((offset_ns, rtt_ns)) = apply {
// info, not debug: ≤1/min, and it is THE forensic
// trail for a stale-offset (stepped/slewed wall clock)
// latency plateau — the 2026-07 two-pair investigation
@@ -149,16 +188,11 @@ impl ControlTask {
tracing::info!(
offset_ns,
rtt_us = rtt_ns / 1000,
best_of_streak,
"mid-stream clock re-sync applied"
);
clock_offset.store(offset_ns, Ordering::Relaxed);
clock_gen.fetch_add(1, Ordering::Relaxed);
} else {
tracing::info!(
rtt_us = rtt_ns / 1000,
"clock re-sync batch discarded — RTT above the \
connect-time baseline (congested window)"
);
}
}
ResyncStep::Idle => {}
+54 -12
View File
@@ -87,6 +87,10 @@ impl DataPump {
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
// All-intra streams have no reference chains: the frame channel drains to the newest
// AU instead of strict FIFO (see `FrameChannel::set_all_intra`), so a slow consumer
// caps its standing queue at ~1 frame with zero recovery cost.
frames.set_all_intra(negotiated_codec == crate::quic::CODEC_PYROWAVE);
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
resolved_bitrate_kbps
} else {
@@ -114,6 +118,15 @@ impl DataPump {
// in; the embedder path had neither, so an unanswered request wedged the report tick and a
// finished one left the ABR window anchored before the burst.
let mut was_probing = false;
// Set when a probe ends: the FIRST post-probe report window is discarded outright (no
// LossReport, no standing-latency close, no ABR feed). The `last_*` rebase below cannot
// fully clean it — probe frames still pending in the reassembler age out as
// `frames_dropped` for another LOSS_WINDOW (~120 ms) AFTER the rebase, and the burst may
// have latched `flush_in_window` — and either reads as SEVERE congestion. The 2026-07
// field report's Automatic session backed off 20→14 Mb/s one second in (exactly one
// report tick after its capacity probe) and, with slow start dead from that first
// "congestion", crawled additively for the entire match.
let mut discard_abr_window = false;
let mut probe_watchdog: Option<Instant> = None;
let (mut owd_sum_ns, mut owd_frames) = (0i128, 0u32);
let mut flush_in_window = false;
@@ -193,6 +206,8 @@ impl DataPump {
last_dropped = st.frames_dropped;
last_bytes = st.bytes_received;
last_report = Instant::now();
discard_abr_window = true;
flush_in_window = false;
}
// Arm a watchdog on the leading edge of ANY probe, so a host that silently ignores
// `ProbeRequest` (an old build — anticipated, see the capacity-probe timeout below)
@@ -289,6 +304,14 @@ impl DataPump {
resync_wanted = false;
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
}
// All-intra drain-to-newest skips are NOT losses (the wire delivered them) —
// surface them at debug so a slow consumer is visible without alarming the
// OSD loss counters.
let skipped = frames.take_skipped();
if skipped > 0 {
tracing::debug!(skipped, "all-intra frame channel drained to newest");
}
let discard = std::mem::take(&mut discard_abr_window);
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
let loss_ppm = window_loss_ppm(
st.fec_recovered_shards.wrapping_sub(last_recovered),
@@ -296,14 +319,26 @@ impl DataPump {
st.packets_received.wrapping_sub(last_received),
window_dropped,
);
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
if discard {
// Probe-tail residue (see `discard_abr_window`): a LossReport from this
// window would also spike the host's adaptive FEC off deliberate overload.
tracing::debug!(
loss_ppm,
window_dropped,
"discarding the first post-probe ABR window (probe-tail residue)"
);
} else {
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
}
// Standing-latency bleed: close the detector's window with this report's loss
// verdict and run its escalation ladder — re-sync first (free; a stale offset
// from a stepped wall clock produces exactly this signature and the applied
// re-sync rebases the floor), then a bounded flush+keyframe (drains a real
// sub-threshold standing backlog the jump-to-live thresholds tolerate), then a
// loud disarm (the path latency itself changed; nothing local fixes that).
match standing_lat.on_window(loss_ppm == 0 && window_dropped == 0) {
// A discard window closes the detector as NOT-loss-free: its clean-run resets
// (conservative) and no action can fire off probe residue.
match standing_lat.on_window(!discard && loss_ppm == 0 && window_dropped == 0) {
StandingLatAction::None => {}
StandingLatAction::Resync { above_ms } => {
tracing::info!(
@@ -386,16 +421,23 @@ impl DataPump {
let window_ms = last_report.elapsed().as_millis().max(1) as u64;
let actual_kbps = (st.bytes_received.wrapping_sub(last_bytes).saturating_mul(8)
/ window_ms) as u32;
if let Some(kbps) = abr.on_window(
Instant::now(),
window_dropped,
loss_ppm,
owd_mean_us,
decode_mean_us,
encode_mean_us,
actual_kbps,
flush_in_window,
) {
// A discard window feeds the controller NOTHING — its signals are probe-tail
// residue, and one "congestion" verdict here ends slow start for good.
let verdict = if discard {
None
} else {
abr.on_window(
Instant::now(),
window_dropped,
loss_ppm,
owd_mean_us,
decode_mean_us,
encode_mean_us,
actual_kbps,
flush_in_window,
)
};
if let Some(kbps) = verdict {
// Log the window's signals alongside the decision so an on-glass session can
// tell a decode-/encode-driven re-target (the new signals — elevated with
// loss/OWD flat) from a network-driven one.
+155 -17
View File
@@ -82,8 +82,12 @@ pub fn wall_clock_ns() -> u64 {
pub enum ResyncStep {
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
Idle,
/// Send this next-round probe and keep feeding echoes.
Probe(ClockProbe),
/// The round was recorded and the batch wants another: wait the inter-round spacing, then
/// stamp + send [`ClockResync::next_probe`]. Spacing the rounds makes the batch sample
/// several phases of the periodic video-burst cycle instead of completing inside one burst
/// — at high bitrates the whole 8-round batch otherwise fits in a single ~6 ms burst and
/// every round reads the same congested (or same quiet) instant.
MoreRounds,
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
Done { offset_ns: i64, rtt_ns: u64 },
}
@@ -118,6 +122,13 @@ impl ClockResync {
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
self.samples.clear();
self.next_probe(now_ns)
}
/// Stamp + arm the next round's probe at `now_ns` — send it immediately. Called after
/// [`ResyncStep::MoreRounds`] once the caller's inter-round spacing has elapsed; stamping
/// at send time keeps that spacing out of the measured RTT.
pub fn next_probe(&mut self, now_ns: u64) -> ClockProbe {
self.pending_t1 = Some(now_ns);
ClockProbe { t1_ns: now_ns }
}
@@ -129,11 +140,12 @@ impl ClockResync {
}
self.samples
.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
if self.samples.len() < Self::ROUNDS {
self.pending_t1 = Some(now_ns);
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
}
// No probe in flight until the driver arms the next round (or a batch restarts) — a
// duplicate of this round's echo must not double-record.
self.pending_t1 = None;
if self.samples.len() < Self::ROUNDS {
return ResyncStep::MoreRounds;
}
match clock_offset_ns(&self.samples) {
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
@@ -147,12 +159,97 @@ impl Default for ClockResync {
}
}
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
/// biases the offset by its queueing delay, and frames already read late exactly then; better
/// to keep the old estimate and let the next batch try again.
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
/// Acceptance predicate for a re-sync batch: its min RTT must be comparable to the best RTT
/// this session has evidenced — `≤ max(2 ms, 1.5 × floor)`. A congested window biases the
/// offset by its queueing delay, and frames already read late exactly then; better to keep the
/// old estimate and let the next batch try again.
pub fn accept_resync(batch_rtt_ns: u64, floor_rtt_ns: u64) -> bool {
batch_rtt_ns <= (floor_rtt_ns + floor_rtt_ns / 2).max(2_000_000)
}
/// Admission decision for a completed re-sync batch (see [`ResyncGuard::admit`]).
#[derive(Debug, PartialEq, Eq)]
pub enum ResyncAdmit {
/// Batch RTT is within the guard band of the session floor: apply this batch's offset.
Fresh,
/// Batch rejected (congested window) — keep the previous offset; `streak` counts the
/// consecutive rejections since the last applied batch.
Rejected { streak: u32 },
/// The rejection streak hit [`ResyncGuard::MAX_REJECTED_STREAK`]: apply the best (min-RTT)
/// batch of the streak instead of drifting further. Carries that batch's estimate.
BestOfStreak { offset_ns: i64, rtt_ns: u64 },
}
/// Admission control for mid-stream re-sync batches. Two fixes over the original static
/// `≤ max(2 ms, 1.5 × connect RTT)` guard (2026-07 PyroWave-sawtooth field report):
///
/// - **The baseline is the session floor, not the connect-time RTT.** The connect handshake
/// runs before the video data plane exists; comparing loaded mid-stream batches against that
/// idle figure rejected essentially every batch of a high-bitrate LAN session, and the
/// offset went stale while the wall clocks drifted apart — the OSD latency ramped for
/// minutes and snapped back only when a lucky batch landed. The floor now folds in every
/// completed batch's min RTT (rejected ones included: their min-RTT round is still floor
/// evidence), so the baseline tracks what this path can actually do under load.
/// - **Staleness is bounded.** After [`Self::MAX_REJECTED_STREAK`] consecutive rejections the
/// best batch of the streak is applied anyway: its queueing bias is at most ~half its RTT
/// (a few ms), while unbounded wall-clock drift is worth that many ms *per minute* on a
/// slewing clock. A bounded bias beats an unbounded drift.
pub struct ResyncGuard {
/// Best RTT this session has evidenced: connect-time RTT, then min over every batch.
floor_rtt_ns: u64,
rejected_streak: u32,
/// Min-RTT batch among the current rejection streak.
best_pending: Option<(i64, u64)>,
}
impl ResyncGuard {
/// Consecutive rejected batches tolerated before the best of them is applied anyway.
pub const MAX_REJECTED_STREAK: u32 = 3;
pub fn new(connect_rtt_ns: u64) -> ResyncGuard {
ResyncGuard {
floor_rtt_ns: connect_rtt_ns,
rejected_streak: 0,
best_pending: None,
}
}
/// The current baseline the guard compares batches against (log/debug surface).
pub fn floor_rtt_ns(&self) -> u64 {
self.floor_rtt_ns
}
/// Judge a completed batch. The caller applies the offset on [`ResyncAdmit::Fresh`] (this
/// batch's) or [`ResyncAdmit::BestOfStreak`] (the carried one) and keeps the old offset on
/// [`ResyncAdmit::Rejected`].
pub fn admit(&mut self, offset_ns: i64, rtt_ns: u64) -> ResyncAdmit {
// Judge against the floor as evidenced BEFORE this batch, then fold this batch in —
// comparing a batch against a floor that already includes it would accept everything.
let fresh = accept_resync(rtt_ns, self.floor_rtt_ns);
self.floor_rtt_ns = self.floor_rtt_ns.min(rtt_ns);
if fresh {
self.rejected_streak = 0;
self.best_pending = None;
return ResyncAdmit::Fresh;
}
let best = match self.best_pending {
Some((o, r)) if r <= rtt_ns => (o, r),
_ => (offset_ns, rtt_ns),
};
self.best_pending = Some(best);
self.rejected_streak += 1;
if self.rejected_streak >= Self::MAX_REJECTED_STREAK {
self.rejected_streak = 0;
self.best_pending = None;
return ResyncAdmit::BestOfStreak {
offset_ns: best.0,
rtt_ns: best.1,
};
}
ResyncAdmit::Rejected {
streak: self.rejected_streak,
}
}
}
#[cfg(test)]
@@ -218,9 +315,14 @@ mod tests {
let echo = echo_for(probe.t1_ns, one_way);
let t4 = t4_for(&echo, one_way);
match rs.on_echo(&echo, t4) {
ResyncStep::Probe(p) => {
ResyncStep::MoreRounds => {
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
probe = p;
// A duplicate of the just-consumed echo must not double-record: no probe
// is in flight until the driver arms the next round.
assert_eq!(rs.on_echo(&echo, t4), ResyncStep::Idle);
// The driver stamps the next probe at SEND time (after its inter-round
// spacing), so the spacing never lands in the measured RTT.
probe = rs.next_probe(t4 + 7_000_000);
}
ResyncStep::Done { offset_ns, rtt_ns } => {
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
@@ -243,10 +345,46 @@ mod tests {
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
ResyncStep::Idle
);
assert!(matches!(
assert_eq!(
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
ResyncStep::Probe(_)
));
ResyncStep::MoreRounds
);
}
/// The guard's two field-report fixes: the baseline tracks the SESSION floor (a batch
/// better than the stale connect figure re-anchors it), and a rejection streak is bounded
/// — the best batch of the streak is applied rather than letting the offset go stale
/// while the wall clocks drift apart.
#[test]
fn resync_guard_floor_tracking_and_bounded_streak() {
// Connect measured 400 µs idle; the 2 ms floor of accept_resync governs early on.
let mut g = ResyncGuard::new(400_000);
assert_eq!(g.admit(10, 1_500_000), ResyncAdmit::Fresh);
// A better batch lowers the floor evidence.
assert_eq!(g.admit(11, 300_000), ResyncAdmit::Fresh);
assert_eq!(g.floor_rtt_ns(), 300_000);
// Loaded stretch: batches at 46 ms all exceed max(2 ms, 1.5 × 300 µs).
assert_eq!(g.admit(100, 6_000_000), ResyncAdmit::Rejected { streak: 1 });
// The best (min-RTT) batch of the streak is remembered…
assert_eq!(g.admit(200, 4_000_000), ResyncAdmit::Rejected { streak: 2 });
// …and applied when the streak hits the cap — offset 200 (the 4 ms batch), not 300.
assert_eq!(
g.admit(300, 5_000_000),
ResyncAdmit::BestOfStreak {
offset_ns: 200,
rtt_ns: 4_000_000
}
);
// The streak reset: the next congested batch starts a new one.
assert_eq!(g.admit(400, 5_000_000), ResyncAdmit::Rejected { streak: 1 });
// A quiet batch clears it and applies normally.
assert_eq!(g.admit(500, 350_000), ResyncAdmit::Fresh);
// A batch that IS the new floor is always fresh (compared against the pre-batch floor).
let mut g2 = ResyncGuard::new(10_000_000);
assert_eq!(g2.admit(1, 8_000_000), ResyncAdmit::Fresh);
assert_eq!(g2.floor_rtt_ns(), 8_000_000);
}
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
+54
View File
@@ -350,6 +350,8 @@ pub(crate) async fn serve(
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
crate::vdisplay::restore_takeover_on_startup();
// …and the other end of that: give the box its session back when WE are the ones going away.
install_shutdown_restore();
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
// blocks on the network. A no-op on a host whose stores all carry their own art.
@@ -486,6 +488,58 @@ pub(crate) async fn serve(
Ok(())
}
/// How long a shutdown waits for the box's session to be handed back before exiting anyway. The
/// restore is a couple of `systemctl` calls (or one `pkexec` helper run); this only bounds a
/// genuine wedge, well inside systemd's 90 s `TimeoutStopSec`.
const SHUTDOWN_RESTORE_GRACE: std::time::Duration = std::time::Duration::from_secs(20);
/// Hand the box's own session back on the way out. Until this existed the host had NO signal
/// handling at all: `SIGTERM` killed it outright, which is fine for a host that owns nothing — but
/// a managed gamescope takeover owns the box's session, and on a mask-fragile display manager
/// (Nobara's plasmalogin) it has STOPPED that display manager for the length of the stream. Killed
/// there, the host leaves a box with no graphical session and nothing left to restart it: the
/// crash-restore state lives in `$XDG_RUNTIME_DIR`, which logind removes along with the user
/// manager, so even the next host start can't heal it. `systemctl --user restart punktfunk-host`
/// mid-stream — or a package update doing it for you — was enough.
///
/// So: catch `SIGTERM`/`SIGINT`, restore, then exit. Restoring runs on a blocking thread (it shells
/// out) under [`SHUTDOWN_RESTORE_GRACE`], and a host that took nothing over exits immediately.
fn install_shutdown_restore() {
#[cfg(unix)]
tokio::spawn(async {
use tokio::signal::unix::{signal, SignalKind};
let (Ok(mut term), Ok(mut int)) = (
signal(SignalKind::terminate()),
signal(SignalKind::interrupt()),
) else {
tracing::warn!(
"could not install shutdown signal handlers — a host stopped mid-takeover will \
leave the box's own session down until it is restarted"
);
return;
};
let sig = tokio::select! {
_ = term.recv() => "SIGTERM",
_ = int.recv() => "SIGINT",
};
tracing::info!(
signal = sig,
"host stopping — handing the box's session back"
);
let restore = tokio::task::spawn_blocking(crate::vdisplay::restore_takeover_now);
if tokio::time::timeout(SHUTDOWN_RESTORE_GRACE, restore)
.await
.is_err()
{
tracing::warn!(
secs = SHUTDOWN_RESTORE_GRACE.as_secs(),
"the session restore did not finish in time — exiting anyway"
);
}
std::process::exit(0);
});
}
/// The accept loop is sequential, so the control phase must be bounded — a client that
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
+11 -2
View File
@@ -335,6 +335,11 @@ struct FrameMsg {
/// nor a stats capture is armed). The send thread accumulates them for the web-console sample:
/// `cap_us` = `try_latest` (ring read + colour convert), `submit_us` = NVENC `encode_picture`
/// launch, `wait_us` = `lock_bitstream` (the scheduling wait + ASIC encode = the "encode" stage).
/// SYNCHRONOUS backends (PyroWave: the whole GPU encode + fence wait runs inside `submit`)
/// carry their real encode time in `submit_us`, and the "encode" stage reads ~0 by
/// construction — read the pair together (the 2026-07 field triage read "encode 0.00" as an
/// instrumentation hole; it's the stage split's shape). The client-facing 0xCF `encode_us`
/// is unaffected: its anchor is stamped before `submit`, so it spans both.
cap_us: u32,
submit_us: u32,
wait_us: u32,
@@ -1118,9 +1123,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
}
if streamed_wire {
// Client capability only — whether AUs actually stream per-slice depends on the encoder
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
// exist yet at this point. The old wording ("chunked encoder output will stream
// per-slice") sent a 2026-07 field triage chasing a streaming path AMF doesn't have.
tracing::info!(
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
will stream per-slice"
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — used if this session's \
encoder supports chunked output"
);
}
tracing::info!(
+3 -1
View File
@@ -45,7 +45,9 @@ pub struct StatsSample {
pub fps: f32,
/// Re-encoded holds/s (source-starvation indicator).
pub repeat_fps: f32,
/// Transmit goodput (Mb/s).
/// Attempted sealed wire bytes/s (Mb/s): full UDP payloads at seal time — video AU bytes
/// plus shard framing (header + AEAD) plus FEC parity, and for PyroWave's datagram-aligned
/// mode the zero-padded window tails. NOT goodput, and NOT reduced by socket send drops.
pub mbps: f32,
/// Configured target bitrate.
pub bitrate_kbps: u32,
+8
View File
@@ -69,6 +69,14 @@ depends on the display manager driving the autologin:
restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
[Configuration](/docs/configuration)) is fired as the fallback.
**Lingering is required here**, and the host turns it on for you the first time it takes the box
over. Stopping the display manager ends your last login session, and without
`loginctl enable-linger` logind stops your `systemd --user` manager about ten seconds later —
taking the host with it, mid-stream, with the display manager down and nothing left to bring it
back. If lingering can't be enabled the host refuses the takeover and degrades to attach instead
(above) rather than risk that. Run `sudo loginctl enable-linger "$USER"` once, as the setup guides
ask; `loginctl disable-linger "$USER"` reverts it.
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
@@ -179,6 +179,23 @@ shutdown — and only forces the issue after ten seconds of being ignored.
> (5 minutes). A display set to **Forever** stays up regardless of what happens to the game — a
> pinned display is a deliberate "this box is a game host" choice, and closing a game doesn't undo it.
### On a gamescope session, the display has the final say
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam Deck
or a Bazzite couch box — the game runs *inside* the streamed display. So it lives exactly as long as
that display does, and **Keep alive decides that, not the setting above**:
| you disconnect by | what happens to the game |
|---|---|
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is deliberately skipped for a real stop — and the game goes with it, even on *Leave it running* |
| dropping out (network, sleep) | the display lingers for your keep-alive window, then tears down; the game ends with it |
| dropping out, keep-alive **Forever** | the display is pinned, so the game genuinely survives — and *Always close it* still ends it when the reconnect window closes |
So on a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
window long enough to come back in), not just this setting. On a desktop session — KWin, GNOME, Sway —
the game is an ordinary process next to your desktop and none of this applies; the setting above is
the whole story.
### Automation
The host publishes `game.running` and `game.exited` events (the latter says whether the player quit
+11 -2
View File
@@ -36,13 +36,22 @@ Every library entry can now carry **platform, description, developer, publisher,
## New: install and update the Windows host with winget
Windows had **no update path at all** — no self-update, no package manager — so keeping a host current meant noticing that a release had happened and re-running an installer by hand. Now, after adding Punktfunk's package source once:
Windows had **no update path at all** — no self-update, no package manager — so keeping a host current meant noticing that a release had happened and re-running an installer by hand. Now it is two commands.
Punktfunk lives in **its own package source**, not the public winget catalogue, so add that first. Once per machine, from an **Administrator** terminal:
```
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
```
Then install, and from then on upgrade:
```
winget install unom.PunktfunkHost
winget upgrade unom.PunktfunkHost
```
and `winget upgrade` from then on. The source is ours rather than the public catalogue, and it knows about every release, so you can pin an older version or upgrade from one.
**If you skip the first command, `winget install unom.PunktfunkHost` will tell you no package was found** — winget only searches the sources it knows about, and Punktfunk is not in the public one. The source knows about every release, so you can also pin an older version with `--version` or upgrade from one.
A silent install shows you the same disclosures the wizard does — including where the bundled virtual audio device comes from — and takes the same defaults the wizard offers, so installing without a screen doesn't quietly get you a different machine.
+53
View File
@@ -0,0 +1,53 @@
Update whenever it suits you — the app and the machine you stream from can still be updated one at a time, and everything already paired keeps working. This release is entirely fixes and hardening on top of last release's session⇄game work — nothing new to opt into.
## Fixed: installing or updating the Windows host
Three separate reports came in within hours of the last release, all blocking install:
- **`winget install` could fail outright if you'd ever had another streaming host on the machine** — even one you had since disabled or mostly uninstalled. A leftover, disabled service or an empty folder from an old install was enough to trip the "conflicting host" check and abort. Only a host actually set to start on its own counts now.
- **Installing through a package-manager app (like UniGetUI) could fail with a file-path error** before the install wizard ever appeared, because of a broken setting the installer passed itself. Fixed.
- **The package source instructions were incomplete.** `winget install unom.PunktfunkHost` says "no package found" until you've pointed winget at Punktfunk's own source, once per machine — the command for that is now spelled out.
## Fixed: Moonlight compatibility no longer turns on without asking
A fresh Windows install used to enable Moonlight-app compatibility silently, with no checkbox ever shown — even though that mode pairs over a less secure connection and the host warns about it on every start. It's opt-in now. Existing installs are untouched either way; Punktfunk's own clients were never affected.
## Fixed: switching into Steam's Game Mode on Linux could black out the machine
Last release shipped the permission needed to properly take over the screen for Steam's Game Mode — and surfaced a few sharp edges that only show up once that takeover is actually live:
- **The takeover could kill Punktfunk's own background service a few seconds later**, leaving the display dark with nothing left to bring it back except a physical console or SSH.
- **It could also hang waiting on a permission prompt nobody could see**, delaying recovery until well after the stream had already dropped — or act on a leftover marker file from months ago and jump the machine to the desktop for no reason.
- **Restarting the host mid-stream** — including a routine update — **could leave the display stuck off**, since nothing told the screen to come back.
All three are fixed. Recognizing the machine's built-in Steam Game Mode may now need a one-time permission on some distributions; installing or updating normally takes care of it.
## Fixed: a laptop with its built-in screen turned off stuttered constantly
On a laptop with two graphics chips and the internal screen switched off — a common setup for streaming from a laptop docked to a TV or monitor — streaming stuttered roughly every two seconds, for the whole session. Punktfunk now recognizes a dark-but-connected laptop screen as the cause and points at the actual fix, instead of pointing at an external monitor that isn't the problem.
## Fixed: latency creeping up over the course of a fast, high-bitrate stream
On a fast local network, especially at higher bitrates, latency could climb steadily over a long session instead of staying flat. Several separate causes are fixed at once: the stream's internal clock sync could get starved by a busy connection and let the two ends' clocks drift; a stream mode could put more data on the wire than its bitrate setting actually allowed; a slow-to-decode moment could build up a backlog that took a while to work through instead of catching up immediately; and the one-time startup speed test could be mistaken for real network congestion and permanently cap the stream below what the connection could actually handle.
## Fixed: missing cover art looked broken
A library entry with no cover art showed as an empty grey box, which read as something that failed to load. It now shows a controller icon instead, so "no art yet" looks intentional rather than broken.
## Improved: the Windows app now keeps its own log file
The desktop app writes its own log to `%LOCALAPPDATA%\punktfunk\logs\client.log` now, instead of only printing to a console window that a normal launch never shows. If you're ever asked to send logs for a problem, this is where they are.
## Under the hood (for developers)
- **Nothing changed on the wire or at the API boundary.** Streaming protocol stays at version **2**, the embeddable C ABI at **13**, the Windows virtual-display driver protocol at **6** — 0.180.20 hosts and clients keep mixing freely.
- **Windows conflict detection** now keys on the competing service's `Start` registry value (02 = boot/system/auto only), matching the tray's own dormant-is-harmless logic from last release; install directories no longer count.
- **The winget `Log` switch used the literal string `|LOGPATH|`** instead of winget's actual `<LOGPATH>` substitution token, so Inno received an illegal filename on any install that requested a log (UniGetUI does by default). Guarded by a new manifest check.
- **`gamestream` installer task lost its default-checked flag**; a silent/unattended install now lands `PUNKTFUNK_HOST_CMD=serve`. `/MERGETASKS=gamestream` opts in. Fresh installs only — `GamestreamParam` already omitted the flag on upgrades.
- **The gamescope display-manager takeover** now calls privileged `systemctl` verbs non-interactively (`--no-ask-password`) so a headless polkit prompt can no longer block the stream's own recovery thread; `session_select_requested`'s sentinel baseline is now recorded at takeover time as well as at successful launch, closing a window where a months-old `~/.config/steamos-session-select` write could false-positive a session-select honor; and `punktfunk-host` now handles SIGTERM/SIGINT by restoring the display manager it stopped (20 s grace) before exiting, so a `systemctl --user restart` or package upgrade mid-takeover can't strand the box. The takeover also now ensures the invoking user has systemd lingering enabled before touching the display manager — without it, stopping the DM stops the user's own service manager ~10 s later and takes the host down with it.
- **A new `internal_panel` target class** lets the exclusive-topology stall detector name a deactivated eDP/LVDS laptop panel as a disturbance suspect; previously the suspect list was filtered to external physicals only, so the one display responsible on a hybrid laptop reported as none. `EvtIddCxMonitorI2CTransmit/Receive` now answer every DDC/CI probe against the virtual monitor with an immediate `STATUS_NOT_SUPPORTED`, so third-party brightness/monitor tools stop occupying win32k's serialized physical-monitor path on it. The swap-chain drain thread now falls back to `TIME_CRITICAL` priority if MMCSS registration is refused, and raises `IddMinimumVersionRequired` 4 → 10 to use `IddCxSetRealtimeGPUPriority`.
- **Clock re-sync** now spaces its probe rounds ~7 ms apart, tracks a running best-RTT guard band instead of a static connect-time floor, and applies the best batch of a rejection streak after three consecutive rejections rather than drifting unbounded.
- **A new per-frame `WireBudget`** (shared between encoder backends) tracks the real bitstream-to-wire inflation ratio for datagram-aligned PyroWave sessions and deflates the rate-control target by it, so the configured bitrate pin holds on the wire rather than on the raw codec bitstream.
- **The pre-decode frame channel now drains to the newest AU for all-intra (PyroWave) streams**, since those have no reference chain forbidding a mid-stream drop — capping any standing backlog at roughly one frame instead of ratcheting between the coarse jump-to-live thresholds.
- **The ABR capacity probe's first post-probe report window is now discarded outright**, rather than measured, so the probe's own deliberate overload can no longer read as session congestion and permanently end slow-start early.
- New `display-disturb` dev tool for reproducing DDC/CI and modeset-class display disturbances on demand during stall-immunity testing; not shipped to end users.
+5
View File
@@ -680,6 +680,11 @@
#define ClockResync_ROUNDS 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Consecutive rejected batches tolerated before the best of them is applied anyway.
#define ResyncGuard_MAX_REJECTED_STREAK 3
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Type byte of [`Reconfigure`] (first byte after the magic).
#define MSG_RECONFIGURE 1
+8 -4
View File
@@ -60,10 +60,14 @@ parse breakage that silently failed installs on non-English boxes.
**`PunktfunkWeb`** scheduled task (boot, SYSTEM, restart-on-failure → `web-run.cmd``bun` on
`:47992`), opens TCP 47992, and starts it. It proxies the host's loopback mgmt API with the host's
own `%ProgramData%\punktfunk\mgmt-token`.
- **GameStream (Moonlight) compatibility is a wizard task** (checked by default): the choice is passed
to `service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or
`serve`, the secure native-only host) into `host.env`. Upgrade-safe: a hand-customized
`PUNKTFUNK_HOST_CMD` is never overwritten.
- **GameStream (Moonlight) compatibility is a wizard task** (**unchecked** by default — it pairs over
plain HTTP, so it is opt-in like the Public-firewall task): the choice is passed to
`service install --gamestream=on|off`, which writes `PUNKTFUNK_HOST_CMD=serve --gamestream` (or
`serve`, the secure native-only host) into `host.env`. Unattended, add it with
`/MERGETASKS=gamestream`. Upgrade-safe: a hand-customized `PUNKTFUNK_HOST_CMD` is never
overwritten, and on an upgrade the task is inert entirely (the flag is omitted, so `host.env`
keeps whatever it already says) — change an existing host with
`punktfunk-host service install --gamestream=on|off` plus a service restart.
- **Branded, modern wizard**: `WizardStyle=modern dynamic windows11` (Inno ≥ 6.6 — Windows-11-style
controls following the system light/dark theme; pre-6.6 compilers fall back to plain `modern`), with
the punktfunk lens mark on the side panel / header tile and a multi-size `punktfunk.ico`
@@ -375,3 +375,30 @@ pub unsafe extern "C" fn device_io_control(
// SAFETY: `request` is the framework-provided WDFREQUEST; `control::dispatch` completes it exactly once.
unsafe { crate::control::dispatch(request, ioctl_code) };
}
/// DDC/CI transmit toward the virtual monitor — refuse INSTANTLY (stall-immunity: DDC fail-fast).
///
/// In exclusive topology the virtual display is the ONLY monitor on the desktop, so
/// monitor-control software (the Twinkle Tray / PowerToys PowerDisplay / Monitorian class) aims
/// its entire DDC traffic — brightness polls, capabilities-string requests — at THIS monitor.
/// There is no bus and no sink here; the only wrong answer is a slow one (a timeout-shaped
/// failure occupies win32k's physical-monitor path, serialized per monitor, for its full
/// duration). Registering the pair pins every probe's cost to one immediate
/// `STATUS_NOT_SUPPORTED`. EDID needs no equivalent: the OS serves descriptor queries from the
/// blob supplied at monitor creation without calling the driver.
pub unsafe extern "C" fn monitor_i2c_transmit(
_monitor: iddcx::IDDCX_MONITOR,
_p_in: *const iddcx::IDARG_IN_I2C_TRANSMIT,
) -> NTSTATUS {
crate::STATUS_NOT_SUPPORTED
}
/// DDC/CI receive from the virtual monitor — same contract as [`monitor_i2c_transmit`]. (The
/// receive DDI has no out-arg at the callback — data would flow back through an async completion;
/// a synchronous failure return refuses the whole transaction, which is exactly the point.)
pub unsafe extern "C" fn monitor_i2c_receive(
_monitor: iddcx::IDDCX_MONITOR,
_p_in: *const iddcx::IDARG_IN_I2C_RECEIVE,
) -> NTSTATUS {
crate::STATUS_NOT_SUPPORTED
}
@@ -89,6 +89,11 @@ extern "C" fn driver_add(_driver: WDFDRIVER, mut init: PWDFDEVICE_INIT) -> NTSTA
cfg.EvtIddCxMonitorSetGammaRamp = Some(callbacks::set_gamma_ramp);
cfg.EvtIddCxMonitorAssignSwapChain = Some(callbacks::assign_swap_chain);
cfg.EvtIddCxMonitorUnassignSwapChain = Some(callbacks::unassign_swap_chain);
// DDC fail-fast (stall-immunity): monitor-control software polls the virtual monitor's DDC —
// in exclusive topology it is the ONLY monitor. These answer every probe with an immediate
// STATUS_NOT_SUPPORTED instead of whatever slow path an unregistered interface takes.
cfg.EvtIddCxMonitorI2CTransmit = Some(callbacks::monitor_i2c_transmit);
cfg.EvtIddCxMonitorI2CReceive = Some(callbacks::monitor_i2c_receive);
cfg.EvtIddCxDeviceIoControl = Some(callbacks::device_io_control);
// SAFETY: init is the framework device-init; cfg is fully populated + sized. (Links IddCxStub.)
@@ -35,12 +35,22 @@ use wdk_sys::NTSTATUS;
// NTSTATUS codes the driver returns (wdk-sys doesn't surface all of these as constants).
pub(crate) const STATUS_SUCCESS: NTSTATUS = 0;
pub(crate) const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
pub(crate) const STATUS_NOT_SUPPORTED: NTSTATUS = 0xC000_00BBu32 as NTSTATUS;
pub(crate) const STATUS_NOT_FOUND: NTSTATUS = 0xC000_0225u32 as NTSTATUS;
pub(crate) const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
pub(crate) const STATUS_BUFFER_TOO_SMALL: NTSTATUS = 0xC000_0023u32 as NTSTATUS;
/// IddCx (stub mode) requires the driver to export the minimum IddCx framework version it needs — the
/// `#ifndef IDD_STUB` branch of `IddCxFuncEnum.h` that normally emits it is compiled out under
/// `IDD_STUB`. `4` matches the proven `wdf-umdf` oracle.
/// `IDD_STUB`.
///
/// `10` = IddCx 1.10, the TRUTHFUL floor: the drain loop calls
/// `IddCxSwapChainReleaseAndAcquireBuffer2` (1.10) unconditionally and the swap-chain worker uses
/// `IddCxSetRealtimeGPUPriority` (1.9); the product floor is already Windows 11 22H2 / build 22621,
/// whose framework is 1.10 (the installer gate — `MinVersion=10.0.22621` in punktfunk-host.iss —
/// exists precisely for this driver). The oracle's historical `4` predated that floor and would let
/// the driver load against a SHORT `IddFunctions` table, where dispatching any post-1.4 DDI reads
/// past the populated entries. With `10`, an older framework fails the bind cleanly (driver doesn't
/// load) instead of limping into undefined dispatch.
#[unsafe(no_mangle)]
pub static IddMinimumVersionRequired: wdk_sys::ULONG = 4;
pub static IddMinimumVersionRequired: wdk_sys::ULONG = 10;
@@ -29,8 +29,8 @@ use std::{
};
use wdk_sys::iddcx::{
IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SWAPCHAINSETDEVICE,
IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN,
IDARG_IN_RELEASEANDACQUIREBUFFER2, IDARG_IN_SETREALTIMEGPUPRIORITY,
IDARG_IN_SWAPCHAINSETDEVICE, IDARG_OUT_RELEASEANDACQUIREBUFFER2, IDDCX_SWAPCHAIN,
};
// `HANDLE` is the shared wdk-sys typedef (`crate::types`) re-used by the iddcx bindings — take it from
// the crate root, which is guaranteed to export it (the iddcx module only re-exports it if bindgen
@@ -44,7 +44,8 @@ use windows::{
Dxgi::{IDXGIDevice, IDXGIResource},
},
System::Threading::{
AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, WaitForSingleObject,
AvRevertMmThreadCharacteristics, AvSetMmThreadCharacteristicsW, GetCurrentThread,
SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL, WaitForSingleObject,
},
},
core::{Interface, w},
@@ -125,12 +126,25 @@ impl SwapChainProcessor {
// MMCSS can fail under the restricted WUDFHost token ('Distribution' task unregistered /
// service unavailable). The MS sample CONTINUES unprioritized — never abort: returning
// here would leave the assigned swap-chain undrained (the monitor stalls, DWM blocks on
// it) and leak the WDF swap-chain object until device teardown.
// it) and leak the WDF swap-chain object until device teardown. But "unprioritized" is
// not acceptable either: this thread is the whole display's frame pump, and at normal
// priority a display-stack disturbance (DDC/HPD servicing DPC pressure, poller-software
// storms) can starve it into multi-hundred-ms delivery holes. Fall back to
// TIME_CRITICAL — the highest band available without the realtime priority class, and
// the closest to what MMCSS 'Distribution' would have granted. The thread spends its
// life blocked on the surface-available event / keyed mutex, so it cannot starve others.
let av_handle = match res {
Ok(h) => Some(h),
Err(e) => {
// SAFETY: plain FFI; GetCurrentThread returns a pseudo-handle (never fails,
// nothing to close), SetThreadPriority on it affects only this thread.
let fallback = unsafe {
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL)
};
dbglog!(
"[pf-vd] swap-chain: MMCSS prioritization failed ({e:?}) — continuing unprioritized"
"[pf-vd] swap-chain: MMCSS prioritization failed ({e:?}) — fell back to \
TIME_CRITICAL thread priority (ok={})",
fallback.is_ok()
);
None
}
@@ -229,6 +243,29 @@ impl SwapChainProcessor {
}
thread::sleep(Duration::from_millis(50));
}
// IddCx 1.9 realtime GPU scheduling priority for the processing device (stall-immunity
// program, branch-2 hardening): swap-chain buffer processing outruns ordinary GPU
// contention — "higher priority than any regular application can set". The slot is
// guaranteed populated (`IddMinimumVersionRequired = 10`, lib.rs); the DDI itself may
// still decline (e.g. E_NOTIMPL on pre-WDDM-3.0 hardware) — best-effort, never fatal.
// Called while our borrowed device reference is still alive; IddCx uses it synchronously.
if set_ok {
let mut rt = pod_init!(IDARG_IN_SETREALTIMEGPUPRIORITY);
rt.pDevice = dxgi_device.as_raw().cast();
// SAFETY: driver is loaded; `swap_chain` is the live assigned swap-chain whose device
// bind just succeeded; `rt.pDevice` is that same bound DXGI device, alive across the
// synchronous call; `rt` points to valid local storage.
let hr = unsafe { wdk_iddcx::IddCxSetRealtimeGPUPriority(swap_chain, &rt) };
if hr_success(hr) {
dbglog!(
"[pf-vd] swap-chain: processing device raised to REALTIME GPU priority (target={target_id})"
);
} else {
dbglog!(
"[pf-vd] swap-chain: realtime GPU priority declined ({hr:#x}) — normal scheduling (target={target_id})"
);
}
}
// Release our borrowed device reference — IddCx holds its own now, or we gave up. (Explicit drop
// so NLL can't release it mid-loop while the swap-chain still references the raw ptr.)
drop(dxgi_device);
@@ -200,3 +200,20 @@ iddcx_ddi!(
IddCxSwapChainFinishedProcessingFrame(swap_chain: iddcx::IDDCX_SWAPCHAIN)
@ IddCxSwapChainFinishedProcessingFrameTableIndex as PFN_IDDCXSWAPCHAINFINISHEDPROCESSINGFRAME
);
iddcx_ddi!(
/// Raise the swap-chain's processing D3D device to realtime GPU scheduling priority — "higher
/// than any regular application can set" (IddCx 1.9) — so buffer processing outruns ordinary
/// GPU contention. It does NOT help against adapter-wide display servicing (modeset-class DDIs
/// idle the hardware outright); it defends the contention case only. Best-effort at the call
/// site: the DDI itself may decline (e.g. E_NOTIMPL on WDDM < 3.0 hardware).
///
/// Table-slot availability rests on the driver's `IddMinimumVersionRequired = 10` export
/// (pf-vdisplay lib.rs): the loader refuses to bind a framework older than IddCx 1.10, so
/// every slot of our compiled 1.10 surface — this 1.9 one included — is populated wherever the
/// driver runs at all.
IddCxSetRealtimeGPUPriority(
swap_chain: iddcx::IDDCX_SWAPCHAIN,
in_args: *const iddcx::IDARG_IN_SETREALTIMEGPUPRIORITY,
) @ IddCxSetRealtimeGPUPriorityTableIndex as PFN_IDDCXSETREALTIMEGPUPRIORITY
);
+48 -17
View File
@@ -160,8 +160,20 @@ Name: "installhdrlayer"; Description: "Install the HDR Vulkan layer (lets Vulkan
#endif
; Host-config choice, applied via `service install --gamestream=on|off` (writes PUNKTFUNK_HOST_CMD
; in host.env; a hand-customized value is left alone). Checked = the Moonlight-compatible unified
; host (the common Windows setup); unchecked = the secure native-only host (Punktfunk clients only).
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"
; host; unchecked (DEFAULT) = the secure native-only host (Punktfunk clients only).
;
; OPT-IN, like allowpublicfw below and for the same reason: the host itself WARNs on every start
; that this plane pairs over plain HTTP and its legacy control encryption can reuse GCM nonces
; (security-review #5/#9), so an on-path LAN attacker could MITM pairing or recover input. A
; default-on security downgrade cannot be squared with that warning - least of all on the silent
; path, where the wizard never appears and 1839d756 makes an unattended install take these very
; defaults. Reported by a user who found the warning in their log and had never been shown a
; choice, because they installed through winget.
;
; Turning it on unattended is `/MERGETASKS="gamestream"`; on an UPGRADE this task is inert either
; way (GamestreamParam omits the flag unless FreshHostInstall), so an existing host keeps whatever
; host.env already says - changing it afterwards is `service install --gamestream=on|off`.
Name: "gamestream"; Description: "Enable GameStream (Moonlight) compatibility - lets stock Moonlight clients connect (uses legacy plain-HTTP pairing; for trusted LANs)"; Flags: unchecked
; Firewall scope, forwarded as `--allow-public-network` to `service install` / `web setup`. Unchecked
; (default) = accept connections on Private + Domain networks only (the trusted-network profiles
; punktfunk is meant for). Check ONLY for a network you trust that Windows classifies as Public (e.g.
@@ -354,15 +366,31 @@ begin
Result := ExpandConstant('{commonappdata}\punktfunk\host.env');
end;
{ True if another Moonlight-compatible streaming host is installed - by its SCM service key or its
Program Files directory. Sunshine and its forks all register a "<Name>Service" and install under
Program Files, so a registry + directory probe catches them without enumerating processes (which
pure Pascal can't do cleanly). }
function StreamHostPresent(SvcKey, DirName: String): Boolean;
{ True if another Moonlight-compatible streaming host is not merely present but will actually RUN.
Sunshine and its forks register a "<Name>Service"; `Start` is that service's start type
(REG_DWORD): 0 boot, 1 system, 2 automatic, 3 manual, 4 disabled. Only 0-2 come up on their own,
and only a host that comes up can take the GameStream ports or load a second virtual-display
driver - which is the entire content of the warning below.
DELIBERATELY NARROWER than it was. The old probe also counted the service key existing at ANY
start type, plus a bare Program Files\<Name> directory - so a disabled service, or a leftover folder
from an uninstall, read as a live conflict. Combined with the silent-install default of IDNO that
aborted setup, and a field report followed within hours of 0.20.0: `winget install
unom.PunktfunkHost` failed with exit code 1 (0x8A150006) on a box whose Sunshine was not running.
Nothing about a dormant install can clash, and the tray reached the same conclusion in this same
release (3e782852 dropped its always-on warning over a merely-INSTALLED Sunshine as a false
alarm) - the two surfaces now agree.
A host running as a plain user process rather than a service is not detected here, by choice:
that cannot be seen from pure Pascal, it is a runtime condition rather than an install-time one,
and the host already reports it where it belongs (the `punktfunk::detect` startup warning, the
`detect-conflicts` subcommand, and /api/v1/local/summary). }
function StreamHostEnabled(SvcKey: String): Boolean;
var
StartType: Cardinal;
begin
Result := RegKeyExists(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey)
or DirExists(ExpandConstant('{commonpf}\' + DirName))
or DirExists(ExpandConstant('{commonpf32}\' + DirName));
Result := RegQueryDWordValue(HKLM, 'SYSTEM\CurrentControlSet\Services\' + SvcKey, 'Start', StartType)
and (StartType <= 2);
end;
{ Runs before any wizard page - the earliest point we can warn. Detect a conflicting host and let
@@ -375,11 +403,11 @@ begin
{ Record the fresh-vs-upgrade verdict while host.env still reflects the PREVIOUS run. }
FreshHostInstall := not FileExists(HostEnvPath);
Found := '';
if StreamHostPresent('SunshineService', 'Sunshine') then Found := Found + ' - Sunshine' + #13#10;
if StreamHostPresent('ApolloService', 'Apollo') then Found := Found + ' - Apollo' + #13#10;
if StreamHostPresent('VibeshineService', 'Vibeshine') then Found := Found + ' - Vibeshine' + #13#10;
if StreamHostPresent('VibepolloService', 'Vibepollo') then Found := Found + ' - Vibepollo' + #13#10;
if StreamHostPresent('LuminalShineService', 'LuminalShine') then Found := Found + ' - LuminalShine' + #13#10;
if StreamHostEnabled('SunshineService') then Found := Found + ' - Sunshine' + #13#10;
if StreamHostEnabled('ApolloService') then Found := Found + ' - Apollo' + #13#10;
if StreamHostEnabled('VibeshineService') then Found := Found + ' - Vibeshine' + #13#10;
if StreamHostEnabled('VibepolloService') then Found := Found + ' - Vibepollo' + #13#10;
if StreamHostEnabled('LuminalShineService') then Found := Found + ' - LuminalShine' + #13#10;
if Found <> '' then
{ SuppressibleMsgBox, NOT MsgBox: a plain MsgBox ignores /SUPPRESSMSGBOXES and displays even
under /VERYSILENT - i.e. an unattended install (winget) would block on a modal dialog with no
@@ -387,12 +415,15 @@ begin
install onto a box that already runs Sunshine/Apollo ABORTS (Setup exits non-zero) instead of
proceeding into the unsupported dual-host state the message describes. }
Result := SuppressibleMsgBox(
'Another game-streaming host is already installed on this PC:' + #13#10#13#10 + Found + #13#10 +
{ NB: keep #13#10 off the START of a line - ISPP reads a leading '#' as a preprocessor
directive and aborts the compile with "Unknown preprocessor directive". }
'Another game-streaming host is installed on this PC and set to start automatically:' + #13#10#13#10 + Found + #13#10 +
'Running Punktfunk alongside Sunshine / Apollo / other Moonlight-compatible hosts is NOT ' +
'supported. They bind the same GameStream network ports (47984, 47989, 47998-48010) and ' +
'install a conflicting virtual-display driver, which causes pairing failures, "address ' +
'already in use" errors and capture glitches.' + #13#10#13#10 +
'Stop and uninstall the other host before using Punktfunk.' + #13#10#13#10 +
'Stop and disable its service (or uninstall it) before using Punktfunk. A host that is ' +
'installed but disabled does not clash and is not reported here.' + #13#10#13#10 +
'Continue with the installation anyway?',
mbConfirmation, MB_YESNO or MB_DEFBUTTON2, IDNO) = IDYES;
end;
+10
View File
@@ -100,6 +100,16 @@ const post = (p, body) =>
check("manifest: installer has URL + sha256", !!inst?.InstallerUrl && /^[0-9A-F]{64}$/i.test(inst?.InstallerSha256 ?? ""));
check("manifest: installer-level fields folded into the entry", inst?.InstallerType === "inno" && inst?.Scope === "machine");
check("manifest: ProductCode preserved for correlation", !!inst?.ProductCode || !!v?.ProductCodes?.length);
// A Log switch is only useful if winget SUBSTITUTES the path. <LOGPATH> is the one token it
// replaces; anything else is passed through verbatim, and `|LOGPATH|` shipped in 0.20.0 — Inno
// then rejected `|` as a filename and aborted with "Error creating log file", in EVERY install
// mode, for any caller that requests a log (UniGetUI does by default). Reported from the field.
const log = inst?.InstallerSwitches?.Log;
check(
"manifest: Log switch uses winget's <LOGPATH> token, if present",
log === undefined || (log.includes("<LOGPATH>") && !/\|[A-Z]+\|/.test(log)),
`got ${JSON.stringify(log)}`,
);
}
{
const res = await get("packageManifests/UNOM.PUNKTFUNKHOST");
@@ -29,13 +29,23 @@ InstallerSwitches:
# disclosures the wizard puts on screen are carried by Agreements/InstallationNotes in the
# locale manifest instead.
#
# To opt out of individual tasks, callers use Inno's ! negation via --override, e.g.
# winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream"
# To CHANGE individual tasks, callers pass Inno's /MERGETASKS via --override: a bare name adds a
# task, a `!` prefix removes one. GameStream is opt-in (its plane pairs over plain HTTP), so
# enabling it unattended is the additive form:
# winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=gamestream"
# and dropping a default-on task is the negated form, e.g. /MERGETASKS=!trayicon
# Task names: installdriver, installgamepad, installaudiocable, installhdrlayer,
# gamestream, allowpublicfw, startservice, trayicon
Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART /SP-
Log: /LOG="|LOGPATH|"
# <LOGPATH> is winget's OWN token and the only spelling it substitutes (see the schema this file
# declares: "…<LOGPATH> token can be included in the switch value so that winget will replace the
# token with user provided path"). It was written |LOGPATH| — not a token, so winget passed the
# literal string through and Inno rejected it: `|` is not legal in a Windows filename, giving
# "Error creating log file: The filename, directory name, or volume label syntax is incorrect."
# That aborted the install in EVERY mode, interactive included, for any caller that asks for a
# log — UniGetUI does so by default, which is how it was reported.
Log: /LOG="<LOGPATH>"
# Inno writes its ARP entry under <AppId>_is1; this is what correlates an installed host with the
# package for `winget list` / `winget upgrade`. Must track AppId in the .iss.
@@ -46,13 +46,14 @@ Agreements:
redistributed under VB-Audio's bundling grant; the full notice is installed to
%ProgramFiles%\punktfunk\licenses\VB-CABLE-NOTICE.txt.
AgreementUrl: https://vb-audio.com/Cable/
- AgreementLabel: GameStream (Moonlight) compatibility is enabled by default
- AgreementLabel: GameStream (Moonlight) compatibility is OFF by default
Agreement: >-
The default install enables GameStream so stock Moonlight clients can connect. GameStream uses
legacy plain-HTTP pairing and is intended for trusted LANs only. To install without it, use
--override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream", or run
--interactive and clear the GameStream checkbox. It can also be changed later via
PUNKTFUNK_HOST_CMD in %ProgramData%\punktfunk\host.env.
Punktfunk's own clients work out of the box. Support for stock Moonlight clients is a separate,
opt-in plane, because it pairs over legacy plain HTTP and is intended for trusted LANs only. To
install WITH it, use --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-
/MERGETASKS=gamestream", or run --interactive and tick the GameStream checkbox. It can also be
turned on later with `punktfunk-host service install --gamestream=on` (or via
PUNKTFUNK_HOST_CMD in %ProgramData%\punktfunk\host.env), followed by a service restart.
AgreementUrl: https://docs.punktfunk.unom.io/docs/windows-host
# Displayed after the install completes. The console password is generated per install, so it can
+17 -1
View File
@@ -32,8 +32,24 @@ case "${1-}" in
systemctl reset-failed "$dm" 2>/dev/null || :
exec systemctl restart "$dm"
;;
linger)
# Keep the caller's own `systemd --user` manager alive across the stop. Stopping the display
# manager ends the user's last login session, and logind then stops user@<uid>.service after
# UserStopDelaySec (10s by default) — which takes the host that asked for the takeover with
# it, so nothing is left to restart the display manager and the box stays dark. Lingering is
# what breaks that dependency (the setup docs already ask for it).
#
# The user is NEVER caller-named: PKEXEC_UID is set by pkexec from the authenticated caller,
# so this grant enables lingering for that caller alone.
uid=${PKEXEC_UID:-}
[ -n "$uid" ] || {
echo "pf-dm-helper: no PKEXEC_UID in the environment — refusing to guess a user" >&2
exit 1
}
exec loginctl enable-linger "$uid"
;;
*)
echo "usage: pf-dm-helper stop|restore" >&2
echo "usage: pf-dm-helper stop|restore|linger" >&2
exit 2
;;
esac
+9 -5
View File
@@ -33,11 +33,15 @@ PUNKTFUNK_SECURE_DDA=1
# Log level (info | debug | trace). Logs land in %ProgramData%\punktfunk\logs\.
RUST_LOG=info
# The host subcommand the service launches. Default: `serve --gamestream` (native punktfunk/1 host
# ALWAYS on + the GameStream/Moonlight-compat planes). Use `serve` for a SECURE native-only host
# (no plain-HTTP pairing / legacy GCM nonce reuse — security-review #5/#9). The installer's
# "Enable GameStream (Moonlight) compatibility" task sets this; a custom value you write here is
# never overwritten by a reinstall/upgrade.
# The host subcommand the service launches. The native punktfunk/1 host is ALWAYS on; `--gamestream`
# adds the GameStream/Moonlight-compat planes, which pair over plain HTTP and reuse legacy GCM
# nonces (security-review #5/#9) — so `serve` alone is the SECURE native-only host.
#
# A Windows install writes this line explicitly from the installer's "Enable GameStream (Moonlight)
# compatibility" task, which is OPT-IN (unchecked) — so a default install lands `serve`. Only if
# this line is absent altogether does the service fall back to its built-in `serve --gamestream`.
# Change it later with `punktfunk-host service install --gamestream=on|off` + a service restart; a
# custom value you write here by hand is never overwritten by a reinstall/upgrade.
#PUNKTFUNK_HOST_CMD=serve --gamestream
# Multi-GPU boxes only: force the NVENC/Desktop-Duplication GPU by Description substring. Leave
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "display-disturb"
description = "Deterministic display-stack disturbance generator (vdisplay stall-immunity bench)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.62", features = [
"Win32_Devices_Display",
"Win32_Graphics_Gdi",
"Win32_Foundation",
] }
+332
View File
@@ -0,0 +1,332 @@
//! `display-disturb` — deterministic display-stack disturbance generator (Windows).
//!
//! The vdisplay stall-immunity bench (design: punktfunk-planning
//! `design/vdisplay-disturbance-immunity.md`) needs both stall classes reproducible on demand,
//! without waiting for a standby TV or a monitor-tool storm:
//!
//! * `ddc` — Class 2: DDC/CI traffic through the win32k → dxgkrnl → miniport I2C path, exactly
//! what Twinkle-Tray/PowerDisplay-class tools emit after every HPD blip (one VCP read ≈ 100 ms,
//! a capabilities string up to ~1 s — serialized per physical I2C bus). Requires a physical
//! monitor; virtual displays expose no DDC handle.
//! * `modeset` — Class 1: a same-mode `ChangeDisplaySettingsExW(CDS_RESET)` re-commit — a
//! Level-Two modeset-class DDI entry that idles the whole adapter ("the graphics hardware is
//! idle") without changing anything Win32-visible.
//!
//! Every operation prints `epoch_ms op target duration_ms result` so stalls in a concurrent
//! stream's host.log correlate line-for-line. The per-op duration is itself measurement: it is
//! the I2C/modeset service time the GPU driver spent, per disturbance.
//!
//! Usage: `display-disturb ddc [--interval-ms 2000] [--caps] [--vcp 0x10]`
//! `display-disturb modeset [--interval-ms 2000]`
#[cfg(not(target_os = "windows"))]
fn main() {
eprintln!("display-disturb is Windows-only (it exercises the WDDM display stack).");
std::process::exit(2);
}
#[cfg(target_os = "windows")]
fn main() {
win::main()
}
#[cfg(target_os = "windows")]
mod win {
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::{
core::{BOOL, PCWSTR},
Win32::{
Devices::Display::{
CapabilitiesRequestAndCapabilitiesReply, DestroyPhysicalMonitor,
GetCapabilitiesStringLength, GetNumberOfPhysicalMonitorsFromHMONITOR,
GetPhysicalMonitorsFromHMONITOR, GetVCPFeatureAndVCPFeatureReply, SetDisplayConfig,
PHYSICAL_MONITOR, SDC_APPLY, SDC_TOPOLOGY_EXTEND,
},
Foundation::{HANDLE, LPARAM, RECT},
Graphics::Gdi::{
ChangeDisplaySettingsExW, EnumDisplayDevicesW, EnumDisplayMonitors,
EnumDisplaySettingsW, GetMonitorInfoW, CDS_RESET, DEVMODEW, DISPLAY_DEVICEW,
DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICE_MIRRORING_DRIVER,
DISP_CHANGE_SUCCESSFUL, ENUM_CURRENT_SETTINGS, HDC, HMONITOR, MONITORINFOEXW,
},
},
};
fn epoch_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
/// One timed op: print the correlation line the bench greps for.
fn report(op: &str, target: &str, took: Duration, ok: bool) {
println!(
"{} {op} {target} took_ms={} ok={ok}",
epoch_ms(),
took.as_millis()
);
}
struct Args {
mode: String,
interval: Duration,
caps: bool,
vcp: u8,
}
fn parse_args() -> Args {
let argv: Vec<String> = std::env::args().collect();
let mode = argv.get(1).cloned().unwrap_or_default();
if !matches!(mode.as_str(), "ddc" | "modeset" | "extend") {
eprintln!(
"usage: display-disturb <ddc|modeset|extend> [--interval-ms N] [--caps] [--vcp 0xNN]"
);
std::process::exit(2);
}
let mut a = Args {
mode,
interval: Duration::from_millis(2000),
caps: false,
vcp: 0x10, // brightness — universally implemented, read-only harmless
};
let mut i = 2;
while i < argv.len() {
match argv[i].as_str() {
"--interval-ms" => {
i += 1;
a.interval = Duration::from_millis(
argv.get(i).and_then(|s| s.parse().ok()).unwrap_or(2000),
);
}
"--caps" => a.caps = true,
"--vcp" => {
i += 1;
let s = argv.get(i).map(String::as_str).unwrap_or("0x10");
a.vcp = u8::from_str_radix(s.trim_start_matches("0x"), 16).unwrap_or(0x10);
}
other => {
eprintln!("unknown arg: {other}");
std::process::exit(2);
}
}
i += 1;
}
a
}
pub fn main() {
let args = parse_args();
eprintln!(
"display-disturb: mode={} interval={}ms (Ctrl-C to stop) — correlate `took_ms` lines \
against the host log's stall reports",
args.mode,
args.interval.as_millis()
);
match args.mode.as_str() {
"ddc" => ddc_loop(&args),
"extend" => extend_once(),
_ => modeset_loop(&args),
}
}
/// One `SetDisplayConfig(SDC_TOPOLOGY_EXTEND)` poke — re-activates every attachable display
/// from the CCD database, i.e. exactly the "a non-managed display re-activated after the
/// verified isolate" event the exclusive-topology watchdog exists to evict. Fired ONCE (not a
/// loop): the point is to trigger one reassert round and observe the recovery — one
/// `reassert-recover` trace, ONE ring recreate, stream alive.
fn extend_once() -> ! {
let t = Instant::now();
// SAFETY: the no-buffers topology form of SetDisplayConfig; flags request the stored
// EXTEND topology be applied — a pure CCD database operation with no pointers involved.
let rc = unsafe { SetDisplayConfig(None, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY) };
report("topology-extend", "all", t.elapsed(), rc == 0);
std::process::exit(if rc == 0 { 0 } else { 1 });
}
// ---- Class 2: the DDC hammer ----
/// Collect the desktop's HMONITORs (the DDC handles hang off them), labeled by GDI device
/// name (`\\.\DISPLAYn`) so a virtual display and a panel are tellable apart in the output.
fn monitors() -> Vec<(HMONITOR, String)> {
unsafe extern "system" fn cb(mon: HMONITOR, _dc: HDC, _rc: *mut RECT, out: LPARAM) -> BOOL {
let mut info = MONITORINFOEXW::default();
info.monitorInfo.cbSize = std::mem::size_of::<MONITORINFOEXW>() as u32;
// SAFETY: `mon` is the live enumeration handle; `info.cbSize` is stamped for the EX
// variant so the device-name field is filled.
let name = if unsafe { GetMonitorInfoW(mon, &mut info.monitorInfo) }.as_bool() {
let d = info.szDevice;
String::from_utf16_lossy(&d[..d.iter().position(|&c| c == 0).unwrap_or(d.len())])
} else {
"?".into()
};
// SAFETY: `out.0` is the `&mut Vec<(HMONITOR, String)>` this enumeration call passed
// in below, alive for the whole synchronous enumeration.
unsafe { &mut *(out.0 as *mut Vec<(HMONITOR, String)>) }.push((mon, name));
BOOL(1)
}
let mut v: Vec<(HMONITOR, String)> = Vec::new();
// SAFETY: null dc/clip = enumerate all display monitors; `cb` only touches the Vec whose
// address rides in LPARAM for the duration of this synchronous call.
let _ =
unsafe { EnumDisplayMonitors(None, None, Some(cb), LPARAM(&mut v as *mut _ as isize)) };
v
}
/// The physical (DDC-capable) monitors behind one HMONITOR, with their descriptions. The
/// handle-ACQUISITION timing is itself reported (`ddc-open`): it is the poller's first contact
/// with every monitor — including a virtual one that then yields no handles — and on a
/// streaming host in exclusive topology the virtual monitor is the only one there is, so the
/// cost of this failing path is exactly what the DDC-fail-fast driver work changes.
fn physical_monitors(mon: HMONITOR, label: &str) -> Vec<(HANDLE, String)> {
let t = Instant::now();
let mut count = 0u32;
// SAFETY: valid HMONITOR from enumeration; `count` is a valid out-param.
if unsafe { GetNumberOfPhysicalMonitorsFromHMONITOR(mon, &mut count) }.is_err()
|| count == 0
{
report("ddc-open", label, t.elapsed(), false);
return Vec::new();
}
let mut phys = vec![PHYSICAL_MONITOR::default(); count as usize];
// SAFETY: `phys` holds exactly `count` entries as the API requires.
let got = unsafe { GetPhysicalMonitorsFromHMONITOR(mon, &mut phys) }.is_ok();
report("ddc-open", label, t.elapsed(), got);
if !got {
return Vec::new();
}
phys.iter()
.map(|p| {
// Copy the field out: PHYSICAL_MONITOR is packed(1), so referencing the array
// in place would be an unaligned reference (E0793).
let name = p.szPhysicalMonitorDescription;
let desc = String::from_utf16_lossy(
&name[..name.iter().position(|&c| c == 0).unwrap_or(0)],
);
(p.hPhysicalMonitor, desc.trim().to_string())
})
.collect()
}
fn ddc_loop(args: &Args) -> ! {
let mut warned_none = false;
loop {
let mut any = false;
for (mon, dev) in monitors() {
for (h, desc) in physical_monitors(mon, &dev) {
any = true;
let label = if desc.is_empty() {
"monitor".into()
} else {
desc.clone()
};
if args.caps {
// The heavy transaction: length + full capabilities string (the
// PowerDisplay-discovery load, ~100 ms-1 s of serialized I2C).
let t = Instant::now();
let mut len = 0u32;
// SAFETY: `h` is a live physical-monitor handle; `len` a valid out-param.
let mut ok =
unsafe { GetCapabilitiesStringLength(h, &mut len) } == 1 && len > 0;
if ok {
let mut buf = vec![0u8; len as usize];
// SAFETY: `buf` is exactly the reported capabilities length.
ok = unsafe { CapabilitiesRequestAndCapabilitiesReply(h, &mut buf) }
== 1;
}
report("ddc-caps", &label, t.elapsed(), ok);
} else {
let t = Instant::now();
let (mut cur, mut max) = (0u32, 0u32);
// SAFETY: `h` is a live physical-monitor handle; out-params are valid; a
// read of a standard VCP code mutates nothing monitor-side.
let ok = unsafe {
GetVCPFeatureAndVCPFeatureReply(
h,
args.vcp,
None,
&mut cur,
Some(&mut max),
)
} == 1;
report("ddc-vcp", &label, t.elapsed(), ok);
}
// SAFETY: closing the handle GetPhysicalMonitorsFromHMONITOR returned.
let _ = unsafe { DestroyPhysicalMonitor(h) };
}
}
if !any && !warned_none {
warned_none = true;
eprintln!(
"no DDC-capable (physical) monitor yielded handles — continuing anyway: the \
ddc-open timing against handle-less monitors (virtual displays included) IS \
the measurement on a streaming host"
);
}
std::thread::sleep(args.interval);
}
}
// ---- Class 1: the no-op modeset tick ----
/// The first attached, non-mirroring display device (device name, e.g. `\\.\DISPLAY1`).
fn first_active_display() -> Option<[u16; 32]> {
for i in 0u32.. {
let mut dd = DISPLAY_DEVICEW {
cb: std::mem::size_of::<DISPLAY_DEVICEW>() as u32,
..Default::default()
};
// SAFETY: null device name = enumerate adapters by index; `dd.cb` is stamped.
if unsafe { !EnumDisplayDevicesW(PCWSTR::null(), i, &mut dd, 0).as_bool() } {
return None;
}
if (dd.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP).0 != 0
&& (dd.StateFlags & DISPLAY_DEVICE_MIRRORING_DRIVER).0 == 0
{
return Some(dd.DeviceName);
}
}
None
}
fn modeset_loop(args: &Args) -> ! {
let Some(name) = first_active_display() else {
eprintln!("no active display device found");
std::process::exit(1);
};
let label = String::from_utf16_lossy(
&name[..name.iter().position(|&c| c == 0).unwrap_or(name.len())],
);
loop {
let mut dm = DEVMODEW {
dmSize: std::mem::size_of::<DEVMODEW>() as u16,
..Default::default()
};
// SAFETY: `name` is the NUL-terminated device string from enumeration; `dm.dmSize`
// is stamped; ENUM_CURRENT_SETTINGS fills the live mode.
let got = unsafe {
EnumDisplaySettingsW(PCWSTR(name.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm)
}
.as_bool();
if !got {
eprintln!("EnumDisplaySettingsW failed for {label}");
std::process::exit(1);
}
let t = Instant::now();
// SAFETY: re-applying the CURRENT mode with CDS_RESET — the same-mode re-commit that
// forces the modeset path without changing anything user-visible. Null hwnd + null
// lparam per the API contract for this flag combination.
let rc = unsafe {
ChangeDisplaySettingsExW(PCWSTR(name.as_ptr()), Some(&dm), None, CDS_RESET, None)
};
report(
"modeset-reset",
&label,
t.elapsed(),
rc == DISP_CHANGE_SUCCESSFUL,
);
std::thread::sleep(args.interval);
}
}
}
+2 -9
View File
@@ -45,6 +45,7 @@
},
},
"overrides": {
"brace-expansion": "^5.0.8",
"dompurify": "^3.4.12",
"fast-uri": "^3.1.4",
"immutable": "^4.3.9",
@@ -1116,7 +1117,7 @@
"body-scroll-lock": ["body-scroll-lock@4.0.0-beta.0", "", {}, "sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -2544,8 +2545,6 @@
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
"rollup-plugin-visualizer/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
"sass/chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -2556,14 +2555,8 @@
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
"archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"sass/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"archiver-utils/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
}
}
+2 -1
View File
@@ -413,5 +413,6 @@
"session_game_grace": "Zeitfenster für die Rückkehr",
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
"session_game_inert": "Dieser Host kann keine Spiele starten hier bewirken diese Einstellungen nichts"
"session_game_inert": "Dieser Host kann keine Spiele starten hier bewirken diese Einstellungen nichts",
"session_game_nested_note": "In einer gamescope-Spielsitzung läuft das Spiel *innerhalb* der gestreamten Anzeige und lebt daher genau so lange wie diese das entscheidet „Offen halten“ oben, nicht diese Einstellung. Ein bewusstes Stoppen reißt die Anzeige sofort ab und nimmt das Spiel mit, egal was du hier wählst."
}
+2 -1
View File
@@ -413,5 +413,6 @@
"session_game_grace": "Reconnect window",
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
"session_game_saved": "Session and game settings saved",
"session_game_inert": "This host has no way to launch games, so these settings do nothing here"
"session_game_inert": "This host has no way to launch games, so these settings do nothing here",
"session_game_nested_note": "On a gamescope game session the game runs *inside* the streamed display, so it lives exactly as long as that display does — which is what Keep alive above decides, not this setting. A deliberate Stop tears that display down at once and takes the game with it, whichever option you pick here."
}
+2 -1
View File
@@ -65,6 +65,7 @@
"immutable": "^4.3.9",
"undici": "^7.28.0",
"postcss": "^8.5.10",
"js-yaml": "^4.3.0"
"js-yaml": "^4.3.0",
"brace-expansion": "^5.0.8"
}
}
+8 -2
View File
@@ -56,14 +56,20 @@ const GameRow: FC<{
const waiting = game.state === "grace";
return (
<div className="flex items-center gap-3">
<div className="h-16 w-11 shrink-0 overflow-hidden rounded bg-muted">
{art && (
{/* Fixed slot so rows line up whether or not a title has a cover. Plenty won't: an
operator-typed command has no catalog entry behind it, a custom entry may carry no
art, and nothing does until `/library` has loaded an empty box reads as broken, so
the placeholder says "game" instead of nothing. */}
<div className="flex h-16 w-11 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
{art ? (
<img
src={art}
alt=""
loading="lazy"
className="size-full object-cover"
/>
) : (
<Gamepad2 className="size-5 text-muted-foreground/60" />
)}
</div>
<div className="min-w-0 flex-1">
@@ -116,6 +116,14 @@ export const SessionGameCard: FC = () => {
{m.session_game_always_warning()}
</p>
)}
{/* Shown for every option, including "leave it running": on a nested
gamescope launch the game IS inside the streamed display, so the
display's own keep-alive outranks anything chosen here verified
on glass (.41), where a deliberate stop ended the game under
`keep`. Worded so a non-gamescope host reads it and moves on. */}
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_nested_note()}
</p>
</Field>
{(server.game_on_session_end ?? "keep") === "always" && (