Compare commits

..
Author SHA1 Message Date
enricobuehler 00a9d16201 fix(abr): the encode stand-down re-probes instead of lasting the session
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 1m58s
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
android / android (pull_request) Successful in 5m0s
ci / rust (pull_request) Successful in 6m13s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m5s
Nothing this controller learns from evidence is permanent — both learned caps
re-probe on the CAP_REPROBE_WINDOWS_MIN ladder, and the clock-flush detector was
itself changed from "off for the rest of the session" to re-armable for exactly
this reason. The encode stand-down had no business being the exception.

What it answers is transient by nature: contention on the host's GPU ends when
the game exits to a menu, when the shader storm finishes, when the second app on
the card closes. What it silences is the only signal that can descend when the
encoder is genuinely past its compute knee on a link that shows nothing else. So
a permanent stand-down trades a bounded problem for an unbounded one — one
contended stretch would strip knee protection from every calm minute that
follows, including the ones where a climb can reach a rate the ASIC cannot hold.

A clean run now re-arms it, and the interval doubles each time the silence is
immediately re-earned, so a genuinely standing contention settles into a slow
poll rather than thrashing. The asymmetry is the whole argument: a too-eager
re-arm costs one ×0.7 and stands down again; a too-permanent silence costs the
protection outright.
2026-08-24 15:52:17 +02:00
enricobuehler 7246f0fe60 fix(abr): the host-encode down-driver must stand down when the rate isn't the lever
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m12s
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 18s
ci / docs-site (pull_request) Successful in 1m7s
ci / rust-arm64 (pull_request) Successful in 2m15s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 4m42s
android / android (pull_request) Successful in 6m25s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m45s
ci / rust (pull_request) Successful in 15m37s
A 1440p60 Lutris session ratcheted 57 → 5 Mbps over ten minutes on a link that
never dropped a packet — no keyframe asks, no flushes, a flat decoder, and the
host granting every rate it was asked for. Two things did it, both in the
client's encode down-driver.

The thresholds were absolute durations calibrated at 120 Hz ("~half a frame
budget", "≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary
one-frame encode hiccup cleared the SEVERE tier and took the immediate ×0.7
where the same hiccup at 120 Hz (8.3 ms) never reached it — which is exactly the
asymmetry the field log shows against 1440p120 sessions on the same host and
client. Size them in the session's own frame budget instead, plumbed from the
negotiated refresh and re-read on a mode switch.

And the signal fired for a cause the rate cannot fix. It exists to find the
encoder's compute knee, where cutting the rate cuts the work; when encode time
is held up by a game saturating the GPU that premise is false, the backoff
changes nothing, and `on_ack`'s baseline re-seed erases the evidence that
nothing improved. So remember the level each encode-attributed backoff fires at:
two in a row that fire no lower mean the rate is not the lever, and the signal
disarms for the session. Same shape as NOOP_CLOCK_FLUSHES_TO_DISARM. Loss, OWD,
decode and keyframe signals keep their full power throughout, and the host's own
climb refusal stays the backstop for a genuine knee.
2026-08-24 15:28:54 +02:00
enricobuehler 46d9e0d20f Merge pull request 'The gamepad console had no door — only a command line' (#390) from worktree-console-ui-front-door into main
ci / bun-nix (push) Successful in 25s
ci / docs-site (push) Successful in 1m7s
ci / docs-drift (push) Successful in 25s
deb / build-publish-gamescope (push) Successful in 1m2s
ci / web (push) Successful in 2m41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 4m11s
deb / build-publish-client-arm64 (push) Successful in 2m7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m4s
deb / build-publish (push) Successful in 4m5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 56s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
deb / build-publish-host (push) Successful in 4m51s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 8s
deb / smoke-install (push) Successful in 2m31s
docker / deploy-docs (push) Successful in 32s
ci / rust (push) Successful in 10m15s
arch / build-publish (push) Successful in 10m34s
flatpak / build-publish (push) Successful in 7m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m29s
nix / flake (push) Successful in 22m27s
2026-08-24 10:15:53 +00:00
enricobuehler 8e8451ca0c Merge pull request 'Sleeping or shutting the host down during a stream works again' (#391) from worktree-host-power-during-stream into main
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m34s
ci / bun-nix (push) Successful in 19s
android / android (push) Successful in 8m57s
ci / docs-drift (push) Successful in 22s
ci / rust-arm64 (push) Successful in 6m31s
deb / build-publish-gamescope (push) Successful in 1m1s
arch / build-publish (push) Successful in 8m18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
windows-host / package (push) Successful in 14m48s
windows-host / winget-source (push) Skipped
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
deb / build-publish (push) Successful in 3m20s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 20s
deb / build-publish-client-arm64 (push) Successful in 2m58s
deb / build-publish-host (push) Successful in 4m34s
docker / builders-arm64cross (push) Successful in 12s
windows-host / canary-manifest (push) Successful in 31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m3s
ci / rust (push) Successful in 11m40s
docker / deploy-docs (push) Successful in 36s
deb / smoke-install (push) Successful in 2m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m52s
2026-08-24 09:52:59 +00:00
enricobuehler 0e1bab019c feat(clients/linux): the console UI gets a front door, in the shell and in the app grid
ci / rust-arm64 (pull_request) Successful in 1m33s
ci / web (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 1m13s
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 31s
nix / flake (pull_request) Successful in 10m31s
ci / rust (pull_request) Successful in 28m5s
The gamepad console has shipped since the arch split, but on Linux the only way
in was `punktfunk-session --browse` (or `punktfunk-client --browse`, which execs
it) on a command line — no way to find a mode. The WinUI shell has had both doors
for a while; this is the GTK half.

Three entry points, one destination:

* A gamepad button in the hosts header (`input-gaming-symbolic`), left of the
  hamburger — the same placement WinUI gives it.
* "Console UI" in the main menu, so the mode has a searchable name and not just
  a tooltip.
* `io.unom.Punktfunk.Console.desktop` — a second launcher shipped by the deb,
  rpm, arch and flatpak packagings. It is what an app-grid search finds and what
  gets added to Steam as a non-Steam game. `Exec=punktfunk-client --browse
  --fullscreen` deliberately goes through the shell binary: that argv already
  execs the session, and it is the command flatpak's Exec rewrite expects.

The in-shell launch is a `gio::Subprocess` — `wait_check_async` lands the child's
exit on the GTK main loop with no thread and no channel, releases `busy`,
refreshes the host list (the console can pair hosts), and banners a non-zero
exit. That last part is also how a session built without its `ui` feature
surfaces: it prints "--browse needs the console UI" and exits non-zero.

Which is why the Nix build does NOT install the new desktop entry — it compiles
the session `--no-default-features`, so that launcher could only ever print an
error. Noted in packaging/nix/README.md next to the existing Skia caveat.
2026-08-24 11:41:58 +02:00
enricobuehler 7951d12b06 Merge pull request 'The console's bitrate row reaches 2 Gbps, steps finely at the bottom, and takes a typed rate' (#388) from worktree-bitrate-picker into main
ci / web (push) Successful in 2m22s
ci / bun-nix (push) Successful in 18s
ci / rust-arm64 (push) Successful in 3m8s
ci / docs-drift (push) Successful in 31s
ci / docs-site (push) Successful in 2m38s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
deb / build-publish-gamescope (push) Successful in 2m5s
deb / build-publish (push) Successful in 4m19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
deb / build-publish-host (push) Successful in 5m2s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Failing after 2m44s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 9s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
android / android (push) Successful in 9m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 43s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m20s
arch / build-publish (push) Successful in 11m47s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m29s
ci / rust (push) Successful in 12m49s
flatpak / build-publish (push) Successful in 4m52s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 36s
deb / smoke-install (push) Canceled after 1m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 1m19s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5m47s
2026-08-24 09:37:34 +00:00
enricobuehler 4690a166ca Merge pull request 'The mic tap's format snapshot could abort the whole app' (#387) from worktree-fix-mic-tap-format-abort into main
apple / swift (push) Successful in 2m7s
ci / web (push) Successful in 2m3s
ci / rust-arm64 (push) Successful in 3m6s
ci / bun-nix (push) Successful in 19s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
ci / docs-drift (push) Successful in 24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
ci / docs-site (push) Successful in 2m2s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
ci / rust (push) Canceled after 5m10s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 58s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 28s
docker / deploy-docs (push) Canceled after 0s
apple / distribute (push) Successful in 13m26s
apple / screenshots (push) Successful in 10m11s
2026-08-24 09:32:26 +00:00
enricobuehler f60b6e30e2 fix(clients/apple): the mic tap's format snapshot could abort the whole app
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m8s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m59s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 9m38s
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the
bus and raises an Objective-C exception on ANY mismatch. Swift cannot catch
that, so it reached the terminate handler and aborted the process — SIGABRT in
`AVAudioEngineGraph::InstallTapOnNode`, crashing macOS 0.31.0 at session start,
not at launch.

The format handed to the tap is necessarily read a moment earlier
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath
it in that window: a device switch, a clock/rate change, or the
`kAudioOutputUnitProperty_CurrentDevice` swap `startCapture` itself performs two
lines before. The existing guard only rejected the 0 Hz / 0-channel case, which
is a different failure — a device that is absent, not one that changed.

Install with `format: nil` instead, which is the documented "use the bus's own
format" and makes the mismatch unrepresentable rather than merely unlikely.
The tap then has to follow the real format, so the rate-dependent pieces (mono
bus, resampler, both scratch buffers) move into a `MicChain` the tap rebuilds
when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would
resample by the wrong ratio and pitch-shift the mic. That rebuild subsumes the
old grow-on-larger-quantum branch; the steady state still allocates nothing.

Tests cover the sizing arithmetic the rebuild rests on — including that
`staging` fits the UPWARD ratio, which silently truncates every packet on any
device below 48 kHz if it is sized for the input rate instead.
2026-08-24 11:16:07 +02:00
enricobuehler 4d155f4985 fix(packaging): a takeover that stops the display manager keeps Steam's power menu working
ci / web (pull_request) Successful in 1m53s
ci / bun-nix (pull_request) Successful in 30s
ci / docs-drift (pull_request) Successful in 28s
android / android (pull_request) Successful in 5m44s
ci / rust-arm64 (pull_request) Successful in 5m59s
ci / docs-site (pull_request) Successful in 5m44s
ci / rust (pull_request) Successful in 15m20s
Second half of "the power menu does nothing during a stream", and an
independent fault from the sleep veto: on the takeover flavors that STOP
the display manager, the box is left with no active local session, and
that is what logind's power actions are gated on.

logind ships `power-off`/`reboot`/`suspend` as `allow_active: yes`.
polkit decides "active" from the caller's own logind session and, for a
caller that has none — every `systemd --user` unit, which is exactly what
the managed gamescope session is — falls back to the user's elected
DISPLAY session. logind elects that only from `user`/`greeter` class
sessions, never from the user manager's own, so stopping the display
manager removes the last candidate and all three actions drop to
`auth_admin_keep`: an interactive password prompt, put to a
non-interactive caller, on a screen that is switched off.

On SteamOS-like boxes that is the call being refused. Steam does not ask
logind for "Shut Down" at all — it writes $STEAMOS_STEAM_SHUTDOWN_SENTINEL
and exits, and gamescope-session-plus runs a plain `poweroff` once Steam
is gone. During a stream that wrapper is ours, in the session-less
transient unit.

Measured on Bazzite, 2026-08-24: the identical
`pkcheck --action-id org.freedesktop.login1.power-off` from a
`systemd --user` unit answers authorized with sddm up, and
`auth_admin_keep` with sddm stopped — and answers authorized again, in
that same stopped state, with this rule installed.

Scope it to the (shipped-empty) `punktfunk` group, which is the same
group the takeover's own root helper authorizes on: a takeover that stops
a display manager cannot work without that helper, so this grants to
exactly the population the fault reaches. The three actions are the three
entries in Steam's power menu; the `-multiple-sessions` and
`-ignore-inhibit` variants are deliberately left out.
2026-08-24 11:00:24 +02:00
enricobuehler 4b5f0dac6b fix(host): a stream no longer vetoes the box's own sleep
Since b7a00137 the host has held a logind `sleep:idle` inhibitor in BLOCK
mode for the whole length of a stream, so a passive (video-only) viewer
could not have the box suspend out from under them. A block lock on
`sleep` refuses EVERY suspend, though, not just the one an idle timer
asks for: "Sleep" in Steam's Big Picture power menu reaches logind as the
same `Suspend()` call, and logind answers it with

    Operation inhibited by "Punktfunk" (PID …), reason is "a client is streaming".

Nothing in that UI surfaces a D-Bus error, so the menu entry simply does
nothing — for as long as anyone is watching the box. Reproduced on a
Bazzite host, 2026-08-24, by taking the same lock by hand.

Hold the veto only while the stream is QUIET. Any client input on either
plane drops it synchronously — releasing is a close(2) on the inhibitor
fd, no round trip, so a Sleep press cannot race it — and it is re-taken
only after 30 s of silence. That is the line the original justification
already drew ("remote INPUT resets the compositor's idle timers, but a
video-only viewer sends none"): a person choosing Sleep is, by
definition, sending input, and a passive viewer never does. The case the
lock was written for is unchanged — 30 s of nothing and the veto is back.

Both planes stamp the clock at their single input entry point: the native
input thread's channel recv (before the grant tests — a denied event
still means a person is there) and the GameStream control plane's three
decode arms, past the keepalive gate, because a keepalive is the one
thing a passive viewer does send.
2026-08-24 11:00:08 +02:00
19 changed files with 1013 additions and 54 deletions
@@ -1479,39 +1479,28 @@ public final class SessionAudio {
"mic capture: \(Int(inFormat.sampleRate)) Hz, \(inChannels) ch, \(channelPlan)")
#endif
// Encode a single mono bus (folded from `inFormat` in the tap): the resampler goes
// Encode a single mono bus (folded from the tap's own buffer format): the resampler goes
// mono@inputSR the encoder's 48 kHz mono, so it handles the rate change and the
// wrong-channel downmix never happens. Mono end to end the host's decoder upmixes,
// so the old duplicate-into-stereo step only cost bits and cycles.
//
// `mono`/`staging` are the per-callback scratch buffers, preallocated HERE (grown only
// if a larger-than-expected device quantum ever arrives) the steady-state tap path
// allocates nothing.
// `chain` carries the rate-dependent pieces INCLUDING the per-callback scratch buffers,
// preallocated HERE for the rate the input currently reports the steady-state tap path
// allocates nothing. The tap rebuilds it if the device's real rate or quantum differ,
// which is the price of installing the tap with the bus's own format (see below).
let scratchFrames: AVAudioFrameCount = 8192
let stagingCapacity = { (frames: AVAudioFrameCount) -> AVAudioFrameCount in
AVAudioFrameCount(
(Double(frames) * 48_000 / inFormat.sampleRate).rounded(.up)) + 64
}
guard let monoFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: inFormat.sampleRate,
channels: 1, interleaved: false),
let encoder = try? OpusEncoder(),
let resampler = AVAudioConverter(from: monoFormat, to: encoder.pcmFormat),
guard let encoder = try? OpusEncoder(),
var chain = Self.micChain(
rate: inFormat.sampleRate, frames: scratchFrames, to: encoder.pcmFormat),
let chunk = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket),
let monoScratch = AVAudioPCMBuffer(
pcmFormat: monoFormat, frameCapacity: scratchFrames),
let stagingScratch = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames))
pcmFormat: encoder.pcmFormat, frameCapacity: encoder.framesPerPacket)
else {
log.error("Opus encoder unavailable — mic uplink disabled")
return false
}
// Tap-thread-confined state: fold into `mono`, resample into `staging`, accumulate in
// `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
var mono = monoScratch
var staging = stagingScratch
// Tap-thread-confined state: fold into `chain.mono`, resample into `chain.staging`,
// accumulate in `fifo`, slice `framesPerPacket` (10 ms) chunks for the encoder.
var fifo: [Float] = []
fifo.reserveCapacity(48_000)
var seq: UInt32 = 0
@@ -1533,22 +1522,32 @@ public final class SessionAudio {
// 480 frames = 10 ms, matching the packet duration. Advisory CoreAudio delivers the
// device quantum whatever we ask (the old 2048 request came back as 42.7 ms bursts, most
// of the uplink's latency) but where the system honors it, the tap fires per-packet.
input.installTap(onBus: 0, bufferSize: 480, format: inFormat) { buffer, _ in
// `format: nil` NOT the format read above. `installTap` validates a non-nil format
// against the bus and raises an Objective-C exception on any mismatch; Swift cannot catch
// that, so it aborts the process (SIGABRT in `AVAudioEngineGraph::InstallTapOnNode`). The
// format was necessarily read a moment EARLIER, and on macOS the input can move underneath
// it a device switch, a clock/rate change, or the `setDevice` swap `startCapture` itself
// performs two lines before this. `nil` means "whatever the bus emits", which is what the
// chain wants anyway, and the mismatch cannot arise by construction. The tap then follows
// the real format below.
input.installTap(onBus: 0, bufferSize: 480, format: nil) { buffer, _ in
if flag.isStopped { return }
let frames = Int(buffer.frameLength)
guard frames > 0, let src = buffer.floatChannelData else { return }
if frames > Int(mono.frameCapacity) {
// A quantum larger than the scratch (bufferSize is advisory both ways) regrow
// once to the new high-water mark; the steady state stays allocation-free.
guard let biggerMono = AVAudioPCMBuffer(
pcmFormat: monoFormat, frameCapacity: buffer.frameLength),
let biggerStaging = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat,
frameCapacity: stagingCapacity(buffer.frameLength))
// Rebuild the rate-dependent chain when the device changes rate under a live tap
// (resampling by the old ratio would pitch-shift the mic), and when a quantum larger
// than the scratch arrives (`bufferSize` is advisory both ways) regrown once to the
// new high-water mark, so the steady state stays allocation-free.
if buffer.format.sampleRate != chain.monoFormat.sampleRate
|| buffer.frameLength > chain.mono.frameCapacity {
guard let rebuilt = Self.micChain(
rate: buffer.format.sampleRate,
frames: max(buffer.frameLength, scratchFrames),
to: encoder.pcmFormat)
else { return }
mono = biggerMono
staging = biggerStaging
chain = rebuilt
}
let mono = chain.mono, staging = chain.staging, resampler = chain.resampler
guard let dst = mono.floatChannelData?[0] else { return }
mono.frameLength = buffer.frameLength
@@ -1620,6 +1619,41 @@ public final class SessionAudio {
return true
}
/// The rate-dependent half of the mic chain: a mono bus at `rate`, the resampler from it onto
/// the encoder's 48 kHz mono, and the two scratch buffers sized for `frames`. Grouped so the
/// tap can swap all four together they are only ever valid as a set.
struct MicChain {
let monoFormat: AVAudioFormat
let resampler: AVAudioConverter
let mono: AVAudioPCMBuffer
let staging: AVAudioPCMBuffer
}
/// Build a `MicChain` for `rate`, or nil if the rate is unusable or an allocation fails.
/// Built once up front for the format the input reports, and again from the tap whenever the
/// device's real rate differs a macOS input can change rate under a live tap, and a chain
/// pinned to the old rate resamples by the wrong ratio (a pitch-shifted mic).
/// `internal` for unit testing: it needs no engine, device or permission.
static func micChain(
rate: Double, frames: AVAudioFrameCount, to pcmFormat: AVAudioFormat
) -> MicChain? {
// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio from `rate`
// (a 44.1 kHz quantum grows by ~1.088); +64 covers the converter's own slack.
guard rate > 0, frames > 0,
let monoFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: rate, channels: 1,
interleaved: false),
let resampler = AVAudioConverter(from: monoFormat, to: pcmFormat),
let mono = AVAudioPCMBuffer(pcmFormat: monoFormat, frameCapacity: frames),
let staging = AVAudioPCMBuffer(
pcmFormat: pcmFormat,
frameCapacity: AVAudioFrameCount(
(Double(frames) * 48_000 / rate).rounded(.up)) + 64)
else { return nil }
return MicChain(
monoFormat: monoFormat, resampler: resampler, mono: mono, staging: staging)
}
/// Fold `channels` of input (`floatChannelData` layout: `interleaved` one buffer strided by
/// channel count; else one buffer per channel) down to a single mono bus in `out` (`frames`
/// long). `pinned` (0-based, must be `< channels`) copies exactly that channel the fix for a
@@ -0,0 +1,61 @@
// The rate-dependent half of the mic chain (SessionAudio.micChain). The tap now installs with
// `format: nil` a non-nil format is validated against the bus and raises an Objective-C
// exception on mismatch, which Swift cannot catch, so it aborted the whole app (SIGABRT in
// AVAudioEngineGraph::InstallTapOnNode, reported against 0.31.0). With nil the tap follows
// whatever the bus emits, which means the chain has to be rebuildable at the device's real rate.
// This pins the sizing arithmetic that rebuild depends on, without an engine, device or mic grant.
#if !os(tvOS)
import AVFoundation
import XCTest
@testable import PunktfunkKit
final class AudioMicChainTests: XCTestCase {
/// The encoder's target: 48 kHz mono float what every chain resamples ONTO.
private let target = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 1, interleaved: false)!
/// A chain is built at the device's rate, mono, and resamples onto the 48 kHz encoder format.
func testBuildsMonoChainAtDeviceRate() throws {
let chain = try XCTUnwrap(
SessionAudio.micChain(rate: 44_100, frames: 8192, to: target))
XCTAssertEqual(chain.monoFormat.sampleRate, 44_100)
XCTAssertEqual(chain.monoFormat.channelCount, 1)
XCTAssertEqual(chain.mono.frameCapacity, 8192)
XCTAssertEqual(chain.resampler.outputFormat.sampleRate, 48_000)
}
/// `staging` holds the resampled 48 kHz mono, so it must fit the UPWARD ratio the bug this
/// guards is a staging buffer sized for the input rate, which silently truncates every packet
/// when the device runs below 48 kHz.
func testStagingFitsUpwardResampleRatio() throws {
for rate in [8_000.0, 16_000, 44_100, 48_000, 96_000] {
let chain = try XCTUnwrap(
SessionAudio.micChain(rate: rate, frames: 1024, to: target))
let needed = (1024.0 * 48_000 / rate).rounded(.up)
XCTAssertGreaterThanOrEqual(
Double(chain.staging.frameCapacity), needed,
"staging too small to hold 1024 frames resampled from \(rate) Hz")
}
}
/// A rate the device cannot report is refused rather than producing a chain that would
/// divide by zero in the staging arithmetic. The tap treats nil as "skip this buffer".
func testRejectsUnusableRateAndEmptyQuantum() {
XCTAssertNil(SessionAudio.micChain(rate: 0, frames: 8192, to: target))
XCTAssertNil(SessionAudio.micChain(rate: -48_000, frames: 8192, to: target))
XCTAssertNil(SessionAudio.micChain(rate: 48_000, frames: 0, to: target))
}
/// The rebuild path: a device that switches 48 kHz 44.1 kHz under a live tap yields a chain
/// at the NEW rate. Resampling by the stale ratio is what pitch-shifts the mic.
func testRebuildFollowsNewRate() throws {
let first = try XCTUnwrap(SessionAudio.micChain(rate: 48_000, frames: 512, to: target))
let second = try XCTUnwrap(SessionAudio.micChain(rate: 44_100, frames: 512, to: target))
XCTAssertEqual(first.monoFormat.sampleRate, 48_000)
XCTAssertEqual(second.monoFormat.sampleRate, 44_100)
XCTAssertGreaterThan(second.staging.frameCapacity, first.staging.frameCapacity)
}
}
#endif
+51
View File
@@ -148,6 +148,11 @@ pub enum AppMsg {
ended: Option<String>,
tofu: bool,
},
/// Hand over to the gamepad console (`punktfunk-session --browse`) — the couch UI's
/// door from the desktop shell.
OpenConsole,
/// The console child exited; `Some` carries why it ended badly.
ConsoleExited(Option<String>),
/// Request-access Cancel: the child was killed; release busy quietly.
CancelPending,
/// The speed-test dialog resolved (either way) — release `busy`.
@@ -519,6 +524,51 @@ impl SimpleComponent for AppModel {
))),
}
}
AppMsg::OpenConsole => {
if std::mem::replace(&mut self.busy, true) {
return;
}
// The console owns the screen and the pads while it runs, so it takes `busy`
// like a stream does. `gio::Subprocess` is the GLib-native child: its
// `wait_check_async` lands the exit on this very main loop — no thread, no
// channel — and reports a non-zero exit as an error. That is also how a
// build without the session's `ui` feature (Nix) surfaces: the child prints
// "--browse needs the console UI" and exits non-zero, and we banner it.
let mut argv = vec![
std::ffi::OsString::from(crate::spawn::session_binary()),
"--browse".into(),
];
// Same knob a stream uses — the session also fullscreens itself on the Deck
// and under gamescope regardless.
if self.settings.borrow().fullscreen_on_stream {
argv.push("--fullscreen".into());
}
let argv: Vec<&std::ffi::OsStr> =
argv.iter().map(std::ffi::OsString::as_os_str).collect();
match gio::Subprocess::newv(&argv, gio::SubprocessFlags::NONE) {
Ok(child) => {
let sender = sender.clone();
child.wait_check_async(gio::Cancellable::NONE, move |res| {
sender.input(AppMsg::ConsoleExited(res.err().map(|e| e.to_string())));
});
}
Err(e) => {
self.busy = false;
self.hosts.emit(HostsMsg::ShowError(format!(
"Couldn't start the console UI — {e}"
)));
}
}
}
AppMsg::ConsoleExited(err) => {
self.busy = false;
// Quitting the console (B at its root) exits 0 and returns here silently.
if let Some(e) = err {
self.hosts
.emit(HostsMsg::ShowError(format!("Console UI ended — {e}")));
}
self.hosts.emit(HostsMsg::Refresh);
}
AppMsg::CancelPending => {
self.close_waiting();
self.busy = false;
@@ -1007,6 +1057,7 @@ fn install_actions(window: &adw::ApplicationWindow, sender: &ComponentSender<App
window.add_action(&add("shortcuts", || AppMsg::ShowShortcuts));
window.add_action(&add("about", || AppMsg::ShowAbout));
window.add_action(&add("add-host", || AppMsg::ShowAddHost));
window.add_action(&add("console", || AppMsg::OpenConsole));
}
/// The Keyboard Shortcuts window — the SESSION window's keys (the shell itself has
+9
View File
@@ -859,7 +859,14 @@ impl SimpleComponent for HostsPage {
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
}
header.pack_start(&rescan_btn);
// The couch UI's front door, beside the page's other actions (same placement the
// WinUI shell gives it). It was previously reachable only as `--browse` on the
// command line, which is no way to find a mode.
let console_btn = gtk::Button::from_icon_name("input-gaming-symbolic");
console_btn.set_tooltip_text(Some("Console UI — the controller-driven couch interface"));
console_btn.set_action_name(Some("win.console"));
let menu = gio::Menu::new();
menu.append(Some("Console UI"), Some("win.console"));
menu.append(Some("Preferences"), Some("win.preferences"));
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
menu.append(Some("About Punktfunk"), Some("win.about"));
@@ -869,7 +876,9 @@ impl SimpleComponent for HostsPage {
.primary(true)
.tooltip_text("Main menu")
.build();
// Packed after the menu so the hamburger stays rightmost (pack_end fills inward).
header.pack_end(&menu_btn);
header.pack_end(&console_btn);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
+484 -5
View File
@@ -19,7 +19,10 @@
//! fat LAN never surfaces as loss/OWD/decode. Paired with the host's own climb refusal (a
//! behind-cadence host acks climbs at the current rate) and short-ack cap learning
//! ([`BitrateController::on_ack`]), this is what stops an Automatic session from driving the
//! encoder off a cliff the network could carry.
//! encoder off a cliff the network could carry. It is also the one signal that can fire for a
//! reason the rate cannot fix (contention on the host's GPU), so it stands itself down when
//! backing off stops helping, and a later clean run re-probes it — see
//! [`ENCODE_NOOP_BACKOFFS_TO_DISARM`].
//!
//! AIMD shape: a SEVERE window (an unrecoverable frame, a flush, ≥6 % loss, or a decode-latency
//! excursion far past baseline) backs off ×0.7 immediately; ordinary congestion
@@ -126,10 +129,49 @@ const PROVEN_HEADROOM_DEN: u32 = 2;
/// encode_us inflated by its retrieve-queue depth (~a frame), so an absolute budget threshold
/// would read permanently-red and drive the rate to the floor; a rise above the session's own
/// baseline survives that offset. ~half a 120 Hz frame budget of standing rise is real.
///
/// A FRAME BUDGET, not a fixed duration — the two constants here are the 120 Hz values, used
/// only until [`set_frame_budget`](BitrateController::set_frame_budget) supplies the session's
/// own (see [`BitrateController::encode_thresholds`]). Left absolute they encode a 120 Hz
/// assumption into every session: at 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode
/// hiccup clears the SEVERE tier and takes the immediate ×0.7 where the same hiccup at 120 Hz
/// (8.3 ms) does not even reach it. That asymmetry is a field report — a 1440p60 session ratcheted
/// to the floor while 1440p120 sessions on the same host and client climbed to their shape ceiling.
const ENCODE_RISE_US: i64 = 4_000;
/// Host-encode latency this far above baseline (≈1.5 × a 120 Hz budget) is SEVERE — the encode
/// queue is growing past the knee; skip the two-window confirmation.
/// queue is growing past the knee; skip the two-window confirmation. Frame-budget-scaled like
/// [`ENCODE_RISE_US`].
const ENCODE_SEVERE_US: i64 = 12_000;
/// Consecutive encode-attributed backoffs that did NOT bring host encode time down before the
/// encode down-driver is disarmed for the session.
///
/// The signal's whole premise is that encode time is a function of the rate the controller can
/// actuate: it exists to find the encoder's compute knee, where cutting the rate cuts the work.
/// When the rise comes from something else on the GPU — a game saturating the card, which is
/// exactly when the host is also behind cadence — the premise is false. The backoff changes
/// nothing, the signal fires again, and [`on_ack`](BitrateController::on_ack)'s baseline re-seed
/// erases the evidence that nothing improved, so the controller ratchets to the floor pulling the
/// one lever that cannot work (the field case: 57 → 5 Mbps over ten minutes with zero packet loss,
/// zero keyframe asks and a flat decoder).
///
/// So: remember the level each encode-attributed backoff fired at, and when the next one fires no
/// lower, count it. Two in a row means the rate is not what is driving encode time here — stop
/// letting it drive. Same shape as the clock-flush detector's
/// [`crate::client::frame_channel::NOOP_CLOCK_FLUSHES_TO_DISARM`]: a signal whose remedy is
/// demonstrably doing nothing should stand down rather than repeat forever.
///
/// Two, not one: a single pair of backoffs at a similar level is also what a real knee looks like
/// while the rate is still above it, and the knee is the case this signal was built for.
///
/// And a stand-down, never a permanent disarm. Nothing this controller learns from evidence is
/// permanent — both caps re-probe, and the clock-flush detector was itself changed from "off for
/// the session" to re-armable for exactly this reason. GPU contention is transient by nature (the
/// game exits to a menu, the shader storm ends), while what it silences is the only signal that
/// can descend when the encoder is past its knee on a link that shows nothing. So a clean run
/// re-arms it on the [`CAP_REPROBE_WINDOWS_MIN`] ladder, doubling each time the silence is
/// immediately re-earned. The loss, OWD, decode and keyframe signals keep their full power
/// throughout, and the host's own climb refusal stays the backstop for a genuine knee.
const ENCODE_NOOP_BACKOFFS_TO_DISARM: u32 = 2;
/// Clean windows parked at a learned cap before re-probing above it, and the ceiling that
/// interval backs off to.
///
@@ -329,6 +371,30 @@ pub(crate) struct BitrateController {
/// baseline like the decode signal. Cleared whenever OUR OWN rate decrease changes the
/// encode regime (see [`on_ack`](Self::on_ack)) and on a mode switch.
encode_means: VecDeque<i64>,
/// This session's frame budget in µs (one refresh interval), the unit the encode thresholds
/// are expressed in — see [`encode_thresholds`](Self::encode_thresholds). `None` = the mode
/// was never plumbed in, and the 120 Hz constants stand exactly as before.
frame_budget_us: Option<i64>,
/// The window mean host-encode latency (µs) that drove the last encode-attributed backoff;
/// `0` = none yet, or the streak was broken by a backoff something else drove.
encode_backoff_us: i64,
/// Consecutive encode-attributed backoffs after which encode time did NOT come down (see
/// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]).
encode_noop_backoffs: u32,
/// The encode down-driver is stood down: its rises are not answering the rate, so they
/// neither mark a window bad nor teach a baseline. Lifted by a clean run (see
/// `encode_reprobe_after`) or a mode switch — never permanent, like every other piece of
/// evidence-learned state here.
encode_disarmed: bool,
/// Clean windows since the stand-down, against `encode_reprobe_after`.
encode_disarm_clean_windows: u32,
/// Clean windows the stand-down must survive before the signal is re-armed. Doubles each
/// time a re-armed signal is immediately silenced again, so a standing contention settles
/// into a slow poll instead of thrashing ([`CAP_REPROBE_WINDOWS_MIN`]).
encode_reprobe_after: u32,
/// A stand-down has been lifted at least once, so the next one is re-silencing something the
/// re-probe already tried — the trigger for backing that clock off.
encode_rearmed: bool,
/// The host-taught rate cap (§ABR overdrive): latched when the host acks BELOW what we
/// asked twice consecutively at the same value — its encoder's codec-level ceiling, or a
/// climb refusal while host encode can't hold cadence. Kept apart from `ceiling_kbps` so
@@ -427,6 +493,13 @@ impl BitrateController {
owd_means: VecDeque::with_capacity(BASELINE_WINDOWS),
decode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
encode_means: VecDeque::with_capacity(BASELINE_WINDOWS),
frame_budget_us: None,
encode_backoff_us: 0,
encode_noop_backoffs: 0,
encode_disarmed: false,
encode_disarm_clean_windows: 0,
encode_reprobe_after: CAP_REPROBE_WINDOWS_MIN,
encode_rearmed: false,
host_cap_kbps: None,
last_requested_kbps: None,
short_ack_kbps: 0,
@@ -485,6 +558,33 @@ impl BitrateController {
self.stream_cap_kbps = Some(kbps);
}
/// Teach the controller this session's refresh rate, so the encode thresholds can be sized in
/// FRAME BUDGETS rather than the 120 Hz durations they were calibrated at (see
/// [`ENCODE_RISE_US`]). Ignored for a nonsense rate — the defaults are the old behavior, which
/// is the right answer when the mode is not known.
pub(crate) fn set_frame_budget(&mut self, refresh_hz: u32) {
if refresh_hz > 0 {
self.frame_budget_us = Some(1_000_000 / refresh_hz as i64);
}
}
/// `(rise, severe)` for the host-encode signal: half a frame budget and one and a half of
/// them, the shape [`ENCODE_RISE_US`] documents, against this session's actual budget.
///
/// Scales with the SESSION REFRESH, not with the rate the source actually delivers. A game
/// rendering below refresh stretches the real budget further still (the host stretches its own
/// cadence deadline by exactly that, `cadence_budget`), so a sub-refresh source can still
/// present a one-frame hiccup above the severe tier — that residue is what
/// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`] is for. Deliberately not chased here: the client would
/// have to infer the source period from arrival cadence, which is the same jitter the signal
/// is trying to read through.
fn encode_thresholds(&self) -> (i64, i64) {
match self.frame_budget_us {
Some(budget) => (budget / 2, budget * 3 / 2),
None => (ENCODE_RISE_US, ENCODE_SEVERE_US),
}
}
/// The host's [`crate::quic::BitrateChanged`] ack: its clamp is authoritative for what the
/// encoder now targets, and any ack proves the host renegotiates (resets the silence counter).
///
@@ -603,6 +703,16 @@ impl BitrateController {
self.owd_means.clear();
self.decode_means.clear();
self.encode_means.clear();
// The encode down-driver's disarm is mode-scoped like everything else here: the new mode
// is a different amount of encode work per frame, so a rate that could not move encode
// time under the old one says nothing about this one. Re-arm and let it prove itself
// again. (The caller re-sizes the frame budget for the new refresh alongside this.)
self.encode_disarmed = false;
self.encode_backoff_us = 0;
self.encode_noop_backoffs = 0;
self.encode_disarm_clean_windows = 0;
self.encode_reprobe_after = CAP_REPROBE_WINDOWS_MIN;
self.encode_rearmed = false;
self.proven_kbps = 0;
}
@@ -684,11 +794,17 @@ impl BitrateController {
// frame describe what reached the CLIENT, and they mean the same thing however little
// flowed — the periodic-capture-stall case (see [`STARVED_DELIVERY_DIV`]) still backs off
// on one window, as its tests require.
//
// Withheld the same way once the signal has DISARMED itself (see
// [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]): a rise the rate has twice failed to answer is not
// evidence about the rate, so it must neither mark a window bad nor teach a baseline.
let (encode_rise_us, encode_severe_us) = self.encode_thresholds();
let encode_usable = !starved && !self.encode_disarmed;
let (encode_bad, encode_severe) = score_baseline(
&mut self.encode_means,
encode_mean_us.filter(|_| !starved),
ENCODE_RISE_US,
ENCODE_SEVERE_US,
encode_mean_us.filter(|_| encode_usable),
encode_rise_us,
encode_severe_us,
);
// SEVERE = the user already saw damage (an unrecoverable frame, a jump-to-live flush, a
// deep decode-latency excursion, a window spent begging for keyframes) or loss far past
@@ -782,6 +898,42 @@ impl BitrateController {
}
}
}
// The encode down-driver's stand-down re-probes on the same clock, for the same reason
// the two caps do: it is EVIDENCE, not a spec limit. What silenced it — a game
// saturating the GPU, a shader-compile storm, another app on the card — is exactly the
// sort of thing that ENDS mid-session, and what it silences is the only signal that can
// descend when the encoder is genuinely past its compute knee on a link that shows
// nothing. Left permanent, one contended stretch would strip that protection from every
// later minute of the session, including the calm ones where a climb can reach a rate
// the ASIC cannot hold.
//
// A clean run is the cheapest moment to ask again: nothing else is unhappy, so if the
// rate still cannot move encode time, two more no-op backoffs stand it down again at a
// bounded cost — while the doubling interval keeps a genuinely standing contention from
// thrashing. The asymmetry decides it: a too-eager re-arm costs one ×0.7, a too-permanent
// silence costs the knee protection outright.
if self.encode_disarmed {
if bad {
self.encode_disarm_clean_windows = 0;
} else {
self.encode_disarm_clean_windows += 1;
if self.encode_disarm_clean_windows >= self.encode_reprobe_after {
self.encode_disarmed = false;
self.encode_rearmed = true;
self.encode_disarm_clean_windows = 0;
// Re-arm on a FRESH baseline and with no streak carried over: the level the
// old backoffs fired at describes a regime that has since been clean for
// seconds, so it is not the reference the next one should be judged against.
self.encode_backoff_us = 0;
self.encode_noop_backoffs = 0;
self.encode_means.clear();
tracing::debug!(
after_windows = self.encode_reprobe_after,
"adaptive bitrate: re-arming the encode down-driver after a clean run"
);
}
}
}
let cooled = self
.last_change
.is_none_or(|t| now.duration_since(t) >= CHANGE_COOLDOWN);
@@ -875,6 +1027,56 @@ impl BitrateController {
} else {
self.decode_backoff_kbps = 0;
}
// Encode attribution (see [`ENCODE_NOOP_BACKOFFS_TO_DISARM`]): did the LAST
// encode-driven backoff buy anything? Judged from the level this one fires at, not
// from the baseline — `on_ack` re-seeded that after the last decrease, so the firing
// level is the only surviving record of what encode time did in between. Network
// distress disqualifies the attribution: loss, a flush or a dropped frame explain the
// backoff without the encoder, and cutting the rate genuinely is the remedy for those.
let encode_attributed = (encode_severe || encode_bad)
&& dropped == 0
&& !flushed
&& loss_ppm < HEAVY_LOSS_PPM;
if let Some(mean) = encode_mean_us.filter(|_| encode_attributed) {
if self.encode_backoff_us > 0
&& mean >= self.encode_backoff_us.saturating_sub(encode_rise_us)
{
// Fired again no lower than last time: the ×0.7 in between did nothing.
self.encode_noop_backoffs += 1;
if self.encode_noop_backoffs >= ENCODE_NOOP_BACKOFFS_TO_DISARM {
// Re-silencing something the re-probe had already lifted means the
// contention is STANDING, not the transient the re-probe exists to ride
// out — back its clock off, exactly as both learned caps do.
self.encode_reprobe_after = if self.encode_rearmed {
self.encode_reprobe_after
.saturating_mul(2)
.min(CAP_REPROBE_WINDOWS_MAX)
} else {
CAP_REPROBE_WINDOWS_MIN
};
self.encode_disarmed = true;
self.encode_disarm_clean_windows = 0;
self.encode_means.clear();
tracing::info!(
at_kbps = self.current_kbps,
encode_mean_us = mean,
noop_backoffs = self.encode_noop_backoffs,
rearm_after_windows = self.encode_reprobe_after,
"adaptive bitrate: host encode time is not answering the rate — \
standing the encode down-driver down until a clean run re-probes it \
(loss, OWD, decode and keyframe signals keep driving)"
);
}
} else {
self.encode_noop_backoffs = 0;
}
self.encode_backoff_us = mean;
} else {
// Something else drove this one: the encode streak is broken, and the level the
// next encode-driven backoff would have to beat no longer means anything.
self.encode_backoff_us = 0;
self.encode_noop_backoffs = 0;
}
self.climb_since_backoff = false;
let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps);
self.bad_windows = 0;
@@ -2178,6 +2380,283 @@ mod tests {
}
}
/// One encode-attributed choke: re-seed the baseline `on_ack` cleared, then present `level`
/// again — the shape of an encoder held up by something the last ×0.7 did nothing about.
/// Four seed windows is under [`CLEAN_WINDOWS_TO_INCREASE`], so no cycle can climb its way
/// out from under the test.
fn encode_choke(
c: &mut BitrateController,
start: Instant,
tick: &mut u32,
level: i64,
) -> Option<u32> {
for _ in 0..BASELINE_MIN_WINDOWS {
let at = ticks(start, *tick);
*tick += 1;
// Seed windows are clean by construction; ack a climb if the controller takes one, so
// the helper stays usable in tests that leave climb headroom below the ceiling.
if let Some(k) = c.on_window(
at,
0,
0,
Some(10_000),
None,
Some(7_000),
1_000_000,
false,
0,
) {
c.on_ack(k);
}
}
let at = ticks(start, *tick);
*tick += 1;
c.on_window(
at,
0,
0,
Some(10_000),
None,
Some(level),
1_000_000,
false,
0,
)
}
/// `n` clean windows carrying no encode sample, acking any climb the controller takes.
fn clean_run(c: &mut BitrateController, start: Instant, tick: &mut u32, n: u32) {
for _ in 0..n {
let at = ticks(start, *tick);
*tick += 1;
if let Some(k) = c.on_window(at, 0, 0, Some(10_000), None, None, 1_000_000, false, 0) {
c.on_ack(k);
}
}
}
/// Drive the field ratchet: encode-attributed backoffs at a level the ×0.7s never move, until
/// the signal stands down.
fn disarm_encode(c: &mut BitrateController, start: Instant, tick: &mut u32) {
for _ in 0..=ENCODE_NOOP_BACKOFFS_TO_DISARM {
let verdict = encode_choke(c, start, tick, 20_000);
c.on_ack(verdict.expect("an unanswered encode rise must back off"));
}
assert!(c.encode_disarmed);
}
#[test]
fn a_stood_down_encode_signal_re_arms_after_a_clean_run() {
// The stand-down is EVIDENCE, not a spec limit, and what it answers — contention on the
// host's GPU — is exactly the sort of thing that ends mid-session. Left permanent, one
// contended stretch would strip the knee down-driver from every calm minute that follows,
// including the ones where a climb can reach a rate the ASIC cannot hold.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
disarm_encode(&mut c, start, &mut tick);
assert_eq!(c.encode_reprobe_after, CAP_REPROBE_WINDOWS_MIN);
// A short clean spell is not enough — the re-probe is a run, not a blip.
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
assert!(c.encode_disarmed);
clean_run(&mut c, start, &mut tick, 1);
assert!(!c.encode_disarmed);
// And it really drives again: a fresh excursion backs the rate off.
assert!(encode_choke(&mut c, start, &mut tick, 40_000).is_some());
}
#[test]
fn a_standing_contention_backs_the_re_arm_clock_off() {
// A re-armed signal silenced again means the contention is STANDING, not the transient
// the re-probe rides out. Same answer both caps give: poll it slowly rather than either
// giving up forever or thrashing every twelve seconds.
//
// Started high enough that two full ratchets stay clear of the floor — a rate pinned at
// `FLOOR_KBPS` stops backing off at all, which would starve the second stand-down of the
// backoffs it is counted from.
let mut c = BitrateController::new(200_000);
let start = Instant::now();
let mut tick = 0;
disarm_encode(&mut c, start, &mut tick);
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN);
assert!(!c.encode_disarmed);
// Re-armed, and the contention is still there.
disarm_encode(&mut c, start, &mut tick);
assert_eq!(c.encode_reprobe_after, CAP_REPROBE_WINDOWS_MIN * 2);
}
#[test]
fn a_bad_window_restarts_the_re_arm_run() {
// The re-probe wants a genuinely quiet stretch: a window the network spoiled says nothing
// about whether the encoder would answer the rate now, so the run starts over.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
disarm_encode(&mut c, start, &mut tick);
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
let at = ticks(start, tick);
tick += 1;
// A flush: severe, so it also costs a ×0.7 — and it resets the clean run behind it.
assert!(c
.on_window(at, 0, 0, Some(10_000), None, None, 1_000_000, true, 0)
.is_some());
clean_run(&mut c, start, &mut tick, CAP_REPROBE_WINDOWS_MIN - 1);
assert!(c.encode_disarmed, "the spoiled window must restart the run");
clean_run(&mut c, start, &mut tick, 1);
assert!(!c.encode_disarmed);
}
#[test]
fn the_encode_thresholds_follow_the_session_frame_budget() {
// The 1440p60-vs-1440p120 field asymmetry. One frame of encode delay is 8.3 ms at 120 Hz
// and 16.7 ms at 60 Hz, so against FIXED thresholds the 60 Hz session takes the immediate
// ×0.7 for the same physical hiccup the 120 Hz one shrugs off. Sized in frame budgets,
// both treat it the same way: ordinary, and confirmed by a second window.
let excursion = 23_700; // 7 ms baseline + ~one 60 Hz frame
let mut hz120 = BitrateController::new(20_000);
hz120.set_frame_budget(120);
let mut tick = 0;
let start = Instant::now();
assert_eq!(
encode_choke(&mut hz120, start, &mut tick, excursion),
Some(14_000),
"at 120 Hz that is ~2.8 frame budgets over baseline — severe, one window"
);
let mut hz60 = BitrateController::new(20_000);
hz60.set_frame_budget(60);
let mut tick = 0;
assert_eq!(
encode_choke(&mut hz60, start, &mut tick, excursion),
None,
"the same excursion is ~1 frame budget at 60 Hz — bad, but not severe"
);
// Confirmed by a second window, it still backs off — the signal is not weakened, only
// re-scaled.
let at = ticks(start, tick + 1);
assert_eq!(
hz60.on_window(
at,
0,
0,
Some(10_000),
None,
Some(excursion),
1_000_000,
false,
0
),
Some(14_000)
);
}
#[test]
fn unactuatable_encode_rises_disarm_the_down_driver() {
// The field ratchet (2026-08-22): a game saturating the GPU holds host encode time up,
// the client reads it as the compute knee, and every ×0.7 changes nothing — 57 Mbps to
// the floor over ten minutes with zero loss, zero keyframe asks and a flat decoder.
// `on_ack` re-seeds the encode baseline after each decrease, so nothing in the signal
// itself ever notices that the backoffs are not working. The firing LEVEL does.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
// First one is a legitimate knee sample — nothing has been learned yet.
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(14_000));
c.on_ack(14_000);
// Fires again no lower: the first ×0.7 bought nothing. One no-op is not a verdict — a
// real knee still above the current rate looks exactly like this.
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(9_800));
c.on_ack(9_800);
assert_eq!(c.encode_noop_backoffs, 1);
assert!(!c.encode_disarmed);
// Twice in a row ⇒ the rate is not the lever. This backoff still lands (the window was
// judged before the verdict), and it is the last one this signal drives until a clean run
// re-probes it (`a_stood_down_encode_signal_re_arms_after_a_clean_run`).
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(6_860));
c.on_ack(6_860);
assert!(c.encode_disarmed);
// The ratchet stops: the same excursion no longer moves the rate…
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), None);
// …and the session climbs back out instead of parking at the floor.
c.set_ceiling(200_000);
assert!(
run_clean(&mut c, start, tick, 8).is_some_and(|k| k > 6_860),
"a disarmed encode signal must not keep the session pinned"
);
}
#[test]
fn an_encode_backoff_that_helps_keeps_the_down_driver_armed() {
// The knee this signal was built for: the ×0.7 lands nearer it and encode time genuinely
// comes down, so the next excursion is a fresh event rather than evidence that the rate
// is the wrong lever. Nothing here may disarm.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
assert_eq!(encode_choke(&mut c, start, &mut tick, 40_000), Some(14_000));
c.on_ack(14_000);
assert_eq!(encode_choke(&mut c, start, &mut tick, 22_000), Some(9_800));
c.on_ack(9_800);
assert_eq!(c.encode_noop_backoffs, 0);
assert!(!c.encode_disarmed);
}
#[test]
fn a_network_driven_backoff_breaks_the_encode_streak() {
// Loss, a flush or a dropped frame explain a backoff without the encoder — and cutting
// the rate genuinely IS the remedy for those. Such a window must not count toward the
// disarm, even when encode time happens to be elevated in it too.
let mut c = BitrateController::new(20_000);
let start = Instant::now();
let mut tick = 0;
assert_eq!(encode_choke(&mut c, start, &mut tick, 20_000), Some(14_000));
c.on_ack(14_000);
assert_eq!(c.encode_backoff_us, 20_000);
// Re-seed so the encode signal is live again…
for _ in 0..BASELINE_MIN_WINDOWS {
let at = ticks(start, tick);
tick += 1;
assert_eq!(
c.on_window(
at,
0,
0,
Some(10_000),
None,
Some(7_000),
1_000_000,
false,
0
),
None
);
}
// …then a window carrying BOTH an encode excursion and a jump-to-live flush. The flush is
// the explanation, so the encode streak resets rather than advancing toward a disarm.
let at = ticks(start, tick);
assert_eq!(
c.on_window(
at,
0,
0,
Some(10_000),
None,
Some(20_000),
1_000_000,
true,
0
),
Some(9_800)
);
assert_eq!(c.encode_backoff_us, 0);
assert_eq!(c.encode_noop_backoffs, 0);
assert!(!c.encode_disarmed);
}
#[test]
fn env_max_mbps_caps_every_learned_ceiling() {
// PUNKTFUNK_ABR_MAX_MBPS=50 (injected — `new` reads the env exactly once, at
+11
View File
@@ -97,6 +97,12 @@ pub(super) async fn run_pump(args: WorkerArgs) {
negotiated.bit_depth,
negotiated.chroma_format,
);
// This session's frame budget, the unit the ABR's host-encode thresholds are expressed in
// (see [`crate::abr::BitrateController::encode_thresholds`]). The NEGOTIATED refresh, not the
// requested one — `mode_slot` still holds the request until the connect handshake seeds it,
// and a host that answered 60 to a 120 ask is exactly the session that must not be scored
// against a 120 Hz budget.
let refresh_hz = negotiated.mode.refresh_hz;
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
@@ -179,6 +185,9 @@ pub(super) async fn run_pump(args: WorkerArgs) {
// Control task (see [`control_task`]): the handshake stream stays open for mid-stream
// renegotiation, speed tests, clock re-sync, and clipboard metadata.
// The data pump re-reads the accepted mode when `mode_gen` moves, to re-size the ABR's
// frame-budget-scaled encode thresholds for the new refresh.
let mode_slot_pump = mode_slot.clone();
tokio::spawn(
control_task::ControlTask {
ctrl_rx,
@@ -271,6 +280,8 @@ pub(super) async fn run_pump(args: WorkerArgs) {
resolved_bitrate_kbps,
negotiated_codec,
stream_cap_kbps,
refresh_hz,
mode_slot: mode_slot_pump,
};
let _ = tokio::task::spawn_blocking(move || pump.run()).await;
@@ -44,6 +44,12 @@ pub(super) struct DataPump {
/// [`crate::abr::stream_ceiling_kbps`]) — the bound the probe-measured link ceiling is held
/// to. Computed where the negotiated geometry lives, so this module stays codec-agnostic.
pub(super) stream_cap_kbps: u32,
/// The negotiated refresh, which sets the frame budget the ABR sizes its host-encode
/// thresholds against (see [`crate::abr::BitrateController::set_frame_budget`]).
pub(super) refresh_hz: u32,
/// The accepted mode, written by the control task on a mode switch — read when `mode_gen`
/// moves so the frame budget follows the new refresh.
pub(super) mode_slot: Arc<Mutex<crate::config::Mode>>,
}
impl DataPump {
@@ -69,6 +75,8 @@ impl DataPump {
resolved_bitrate_kbps,
negotiated_codec,
stream_cap_kbps,
refresh_hz,
mode_slot: pump_mode_slot,
} = self;
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
@@ -121,6 +129,10 @@ impl DataPump {
// no inter-coded stream benefits from — the field session walked to 657 Mbps for 1440p120
// and drove the client's decode latency from 0.8 ms to 10 ms getting there.
abr.set_stream_cap(stream_cap_kbps);
// Size the host-encode thresholds in this session's frame budgets rather than the 120 Hz
// durations they were calibrated at — a 60 Hz session otherwise takes the SEVERE
// one-window ×0.7 on an ordinary one-frame encode hiccup.
abr.set_frame_budget(refresh_hz);
// Startup link-capacity probe (Automatic sessions): the controller's ceiling is the
// negotiated start rate — the conservative 20 Mbps default, historically a box Automatic
// could NEVER climb out of. One speed-test burst shortly after the stream settles
@@ -538,6 +550,10 @@ impl DataPump {
if mg != seen_mode_gen {
seen_mode_gen = mg;
abr.on_mode_switch();
// The frame budget is a property of the MODE: a switch that changes the
// refresh changes what one frame of encode time costs, and the encode
// thresholds are sized in those.
abr.set_frame_budget(pump_mode_slot.lock().unwrap().refresh_hz);
}
if let Some(acked) = bitrate_ack.lock().unwrap().take() {
abr.on_ack(acked);
@@ -1055,6 +1071,12 @@ mod tests {
resolved_bitrate_kbps: 20_000,
negotiated_codec: crate::quic::CODEC_HEVC,
stream_cap_kbps: 100_000,
refresh_hz: 60,
mode_slot: Arc::new(Mutex::new(crate::config::Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
})),
};
let started = Instant::now();
let pump_thread = std::thread::spawn(move || pump.run());
@@ -821,6 +821,7 @@ fn on_receive(
// session without the GAMEPAD grant never creates a uinput node or a pad-audio streamer,
// because the creating event never arrives.
if let Some(gp) = super::gamepad::decode(&pt) {
crate::sleep_inhibit::note_input();
if permitted(grants, GrantClass::Gamepad, drops) {
pads.handle(&gp);
}
@@ -831,6 +832,7 @@ fn on_receive(
// pen drives this session's virtual tablet; touch forwards as ordinary wire touches.
// Pointer-class by construction (the plane tag decides, like the native pen plane).
if let Some(p) = super::input::decode_pointer(&pt) {
crate::sleep_inhibit::note_input();
if permitted(grants, GrantClass::Pointer, drops) {
pointer.apply(&p, |ev| {
let _ = inj_tx.send(ev);
@@ -862,6 +864,10 @@ fn on_receive(
if events.is_empty() {
return; // keepalive / QoS / unhandled input kind
}
// A Moonlight guest is driving the box — drop any standing suspend veto so their own "Sleep"
// reaches logind (see `sleep_inhibit`). Past the `is_empty` gate on purpose: a keepalive is
// the one thing a passive viewer DOES send, and it must not read as someone being there.
crate::sleep_inhibit::note_input();
// Forward to the dedicated injector thread (it opens the backend on the first event and
// coalesces redundant motion) — each event past one mask test against the exhaustive
+9 -1
View File
@@ -962,7 +962,15 @@ pub(super) fn input_thread(
} else {
pads.feedback_poll_interval()
};
match rx.recv_timeout(poll) {
let arrived = rx.recv_timeout(poll);
// Every plane's input funnels through here, so this is where the box learns someone is
// driving it: any arrival drops a standing suspend veto, so the next press being "Sleep"
// in Steam's power menu reaches logind instead of being refused (see `sleep_inhibit`).
// Stamped before the grant tests below — a denied event still means a person is there.
if arrived.is_ok() {
crate::sleep_inhibit::note_input();
}
match arrived {
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
// Guarded on the pad grant like every gamepad arm below — see the `grants` parameter.
+195 -14
View File
@@ -1,19 +1,56 @@
//! Session-scoped suspend/idle inhibition: while at least one client is streaming, the host
//! holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't auto-suspend out from under a
//! passive viewer. Remote INPUT resets the compositor's idle timers, but a video-only viewer
//! sends none — observed live on a SteamOS Game-Mode host, which s2idled mid-stream-day and
//! dropped off the network (and, in a VM with GPU passthrough, never woke again). Refcounted
//! across planes (native sessions + GameStream media): the first hold acquires, the last drop
//! releases. Best-effort — no logind (containers, non-systemd boxes) logs once and streams on.
//! Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions.
//! Session-scoped suspend/idle inhibition: while at least one client is streaming **and is not
//! sending input**, the host holds a logind `sleep:idle` BLOCK inhibitor so the box doesn't
//! auto-suspend out from under a passive viewer. Remote INPUT resets the compositor's idle timers,
//! but a video-only viewer sends none — observed live on a SteamOS Game-Mode host, which s2idled
//! mid-stream-day and dropped off the network (and, in a VM with GPU passthrough, never woke
//! again). Refcounted across planes (native sessions + GameStream media): the first hold acquires,
//! the last drop releases. Best-effort — no logind (containers, non-systemd boxes) logs once and
//! streams on. Off Linux this is a no-op: macOS/Windows hosts manage their own power assertions.
//!
//! **The quiet gate is the point, and it is not an optimisation.** A `block` lock on `sleep`
//! refuses EVERY suspend, not just the idle timer's: "Sleep" in Steam's Big Picture power menu
//! reaches logind as exactly the same `Suspend()` call, and logind answers the person who pressed
//! it with `Operation inhibited by "Punktfunk" (…), reason is "a client is streaming"` — silently,
//! because nothing in that UI surfaces a D-Bus error. Held unconditionally for the length of a
//! stream (as it was from 2026-07-22 to this commit), the lock made a host impossible to put to
//! sleep from the machine's own screen for as long as anyone was watching it. Reproduced verbatim
//! on a Bazzite box, 2026-08-24.
//!
//! So the veto is held only while the stream is QUIET. Any client input ([`note_input`]) drops it
//! **synchronously** — releasing is a `close(2)` on the inhibitor fd, no round trip, so a Sleep
//! press cannot race it — and it is re-taken only after [`QUIET_BEFORE_VETO`] of silence. That is
//! the same line the original justification already drew ("a video-only viewer sends none"): a
//! person choosing Sleep is, by definition, sending input, and a passive viewer never does.
//!
//! What this deliberately does NOT cover is a local suspend request typed at a box that a passive
//! viewer is streaming from — the veto is still standing, so it is still refused. That case wants
//! a person-vs-timer signal we do not have, and the remote viewer's claim on the box is at least
//! arguable. `ponytail:` if it turns up in the field, the lever is a config knob, not a heuristic.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// How long a stream must go without client input before the host vetoes suspend on its behalf.
/// Comfortably under every idle-suspend timer worth catching (Steam's shortest offer is 5 min,
/// KDE's default 10), and long enough that a menu press followed by a slow "are you sure?" cannot
/// re-arm the veto mid-decision.
const QUIET_BEFORE_VETO: Duration = Duration::from_secs(30);
/// How often [`watch`] re-checks the quiet time. Only the RE-ARM edge waits for a tick — the
/// release edge is synchronous in [`note_input`] — so this bounds nothing a user can feel.
#[cfg(target_os = "linux")]
const WATCH_TICK: Duration = Duration::from_secs(5);
/// RAII share of the host-wide inhibitor — hold one per live session/stream.
pub struct StreamHold(());
struct State {
count: u32,
/// Whether [`watch`] is running. Its exit is the 1→0 edge, so without this flag a session that
/// ends and restarts inside one tick would leave two watchers racing for the same fd slot.
#[cfg(target_os = "linux")]
watching: bool,
/// The logind inhibitor pipe fd — inhibition lasts exactly as long as it stays open.
#[cfg(target_os = "linux")]
fd: Option<ashpd::zbus::zvariant::OwnedFd>,
@@ -25,18 +62,65 @@ fn state() -> &'static Mutex<State> {
Mutex::new(State {
count: 0,
#[cfg(target_os = "linux")]
watching: false,
#[cfg(target_os = "linux")]
fd: None,
})
})
}
/// Take a share; the underlying inhibitor is acquired on the 0→1 edge.
/// Monotonic ms since first use — a plain `AtomicU64` clock the input path can stamp with one
/// relaxed store, which `Instant` itself is too fat to be.
fn now_ms() -> u64 {
static EPOCH: OnceLock<Instant> = OnceLock::new();
EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64
}
static LAST_INPUT_MS: AtomicU64 = AtomicU64::new(0);
/// Whether a veto is standing right now. Read once per input event, so it is what keeps
/// [`note_input`] off the mutex on the hot path.
static VETOING: AtomicBool = AtomicBool::new(false);
/// Whether the stream has been quiet long enough to veto suspend on the viewer's behalf.
fn quiet_for(last_input_ms: u64, now_ms: u64) -> bool {
now_ms.saturating_sub(last_input_ms) >= QUIET_BEFORE_VETO.as_millis() as u64
}
/// Client input arrived on any plane — the person at the other end is driving this box, so no
/// suspend veto may be standing when their next button press is "Sleep".
///
/// Called per decoded input event (keyboard, pointer, pad, pen, motion): one relaxed store, plus a
/// relaxed load that only ever takes the lock on the rare edge where a veto is actually standing.
pub fn note_input() {
LAST_INPUT_MS.store(now_ms(), Ordering::Relaxed);
if VETOING.load(Ordering::Relaxed) {
release("the client is sending input again — a deliberate suspend now reaches logind");
}
}
/// Take a share. The underlying inhibitor is NOT acquired here: the `watch` thread takes it once
/// the stream has been quiet for [`QUIET_BEFORE_VETO`], and never while someone is driving the box.
pub fn hold() -> StreamHold {
// A fresh stream gets the full quiet window before anything is vetoed, so an ordinary connect
// costs zero D-Bus round trips.
LAST_INPUT_MS.store(now_ms(), Ordering::Relaxed);
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
st.count += 1;
#[cfg(target_os = "linux")]
if st.count == 1 && st.fd.is_none() {
st.fd = acquire();
if !st.watching {
st.watching = true;
drop(st);
if let Err(e) = std::thread::Builder::new()
.name("punktfunk-sleep-veto".into())
.spawn(watch)
{
state().lock().unwrap_or_else(|e| e.into_inner()).watching = false;
tracing::warn!(
error = %e,
"could not start the sleep-veto watcher — the box may auto-suspend under a \
passive (video-only) viewer"
);
}
}
StreamHold(())
}
@@ -46,12 +130,72 @@ impl Drop for StreamHold {
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
st.count = st.count.saturating_sub(1);
#[cfg(target_os = "linux")]
if st.count == 0 && st.fd.take().is_some() {
tracing::info!("released the sleep/idle inhibitor (no live sessions)");
if st.count == 0 {
release_locked(&mut st, "no live sessions");
}
}
}
/// Drop any standing veto. Closing the fd is all it takes — no D-Bus, so this is safe to call from
/// the input path.
#[cfg(target_os = "linux")]
fn release(why: &str) {
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
release_locked(&mut st, why);
}
#[cfg(not(target_os = "linux"))]
fn release(_why: &str) {}
#[cfg(target_os = "linux")]
fn release_locked(st: &mut State, why: &str) {
if st.fd.take().is_some() {
VETOING.store(false, Ordering::Relaxed);
tracing::info!(why, "released the sleep/idle inhibitor");
}
}
/// Own the veto's arm/disarm edges for as long as any session lives.
///
/// Acquiring is the only expensive edge (a thread spawn + a D-Bus round trip), so it happens here
/// rather than on the input path, and outside the lock — a `note_input` on a hot input stream must
/// never queue behind a logind call.
#[cfg(target_os = "linux")]
fn watch() {
loop {
std::thread::sleep(WATCH_TICK);
{
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
if st.count == 0 {
st.watching = false; // the last `Drop` already released the fd
return;
}
if st.fd.is_some() {
// Belt for the nanosecond window in which input lands after the re-check below but
// before `VETOING` is published: that press releases nothing, so catch it here
// rather than leave a veto standing over a live viewer.
if !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
release_locked(&mut st, "the client is sending input again");
}
continue;
}
if !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
continue;
}
}
let Some(fd) = acquire() else {
continue; // no logind / refused — `acquire` said so once, don't spin on it
};
let mut st = state().lock().unwrap_or_else(|e| e.into_inner());
if st.count == 0 || !quiet_for(LAST_INPUT_MS.load(Ordering::Relaxed), now_ms()) {
drop(fd); // raced: the stream ended, or the viewer came back — never veto for those
continue;
}
st.fd = Some(fd);
VETOING.store(true, Ordering::Relaxed);
}
}
/// One logind `Inhibit` call on a dedicated plain thread — zbus's blocking API must not run on
/// a tokio worker (its internal `block_on` panics there), and callers of [`hold`] may be either.
/// The join blocks the caller for the D-Bus round-trip (~ms), which every call site tolerates.
@@ -94,7 +238,44 @@ fn acquire() -> Option<ashpd::zbus::zvariant::OwnedFd> {
.ok()
.flatten();
if fd.is_some() {
tracing::info!("holding a logind sleep/idle inhibitor while clients stream");
tracing::info!(
quiet_s = QUIET_BEFORE_VETO.as_secs(),
"holding a logind sleep/idle inhibitor — this stream has gone quiet"
);
}
fd
}
#[cfg(test)]
mod tests {
use super::*;
/// The whole fix in one assertion: a stream that is being driven must never be vetoing, or the
/// "Sleep" the viewer just picked in Steam's power menu is refused with no visible reason.
#[test]
fn a_driven_stream_is_never_vetoed_but_a_quiet_one_is() {
let quiet_ms = QUIET_BEFORE_VETO.as_millis() as u64;
assert!(!quiet_for(1_000, 1_000), "input this instant is not quiet");
assert!(
!quiet_for(1_000, 1_000 + quiet_ms - 1),
"one ms short of the window still counts as driven"
);
assert!(
quiet_for(1_000, 1_000 + quiet_ms),
"the window elapsed — a passive viewer gets the veto"
);
// The clock starts at zero, so an un-stamped stream would look infinitely quiet: `hold`
// seeds it precisely so a connect never vetoes before anyone could have pressed anything.
assert!(quiet_for(0, quiet_ms), "an unseeded clock reads as quiet");
}
#[test]
fn note_input_stamps_the_clock() {
note_input();
let stamped = LAST_INPUT_MS.load(Ordering::Relaxed);
assert!(
!quiet_for(stamped, now_ms()),
"input just arrived — the veto must not be armable"
);
}
}
+9
View File
@@ -79,6 +79,15 @@ punktfunk launch <host-ref> # start a session, waking the host firs
punktfunk-client --connect <host>:9777 # the older flag — still supported for existing scripts
```
**Couch mode.** The same client also has a **gamepad console UI**: a full-screen, controller-driven
front end with the host list, PIN pairing, settings, Wake-on-LAN and the game library, streaming in
its own window. Three ways in — the **gamepad button** in the shell's header bar (also *Main menu →
Console UI*), the separate **Punktfunk Console** launcher the packages install alongside the app, or:
```sh
punktfunk-client --browse --fullscreen
```
The client also updates itself (`punktfunk-client --check-update` / `--apply-update`) — see
[Keeping a client up to date](/docs/install-client#keeping-a-client-up-to-date).
+8
View File
@@ -235,6 +235,11 @@ package_punktfunk-host() {
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# ...and the other half of stopping a display manager: with it stopped the box has no active
# local session, so logind's power actions fall to auth_admin_keep and Steam's power menu goes
# quiet mid-stream. No path annotation to rewrite — a .rules file names a group, not a binary.
install -Dm0644 "$R/packaging/linux/49-punktfunk-power.rules" \
"$pkgdir/usr/share/polkit-1/rules.d/49-punktfunk-power.rules"
# Web-console-triggered updates (host-update-from-web-console.md §7): root helper + oneshot
# unit + group-scoped polkit rule. Same no-libexec relocation as pf-dm-helper, with the
# unit's ExecStart rewritten to match. On pacman the helper additionally requires the
@@ -360,6 +365,9 @@ package_punktfunk-client() {
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg"
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.desktop" \
"$pkgdir/usr/share/applications/io.unom.Punktfunk.desktop"
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry point.
install -Dm0644 "$R/packaging/linux/io.unom.Punktfunk.Console.desktop" \
"$pkgdir/usr/share/applications/io.unom.Punktfunk.Console.desktop"
# DualSense hidraw access (full pad fidelity through SDL's HIDAPI driver).
install -Dm0644 "$R/scripts/70-punktfunk-client.rules" \
"$pkgdir/usr/lib/udev/rules.d/70-punktfunk-client.rules"
+4
View File
@@ -75,6 +75,10 @@ install -Dm0755 "$SESSION_BIN" "$STAGE/usr/bin/punktfu
install -Dm0755 "$CLI_BIN" "$STAGE/usr/bin/punktfunk"
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
"$STAGE/usr/share/applications/io.unom.Punktfunk.desktop"
# Second launcher, straight into the gamepad console (`--browse`): the couch entry point a
# TV/HTPC user picks from the app grid, and what gets added to Steam as a non-Steam game.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop \
"$STAGE/usr/share/applications/io.unom.Punktfunk.Console.desktop"
# The app icon the desktop entry (and the About dialog) name. Without it the launcher falls
# back to a generic monitor glyph, which is what shipped until now.
install -Dm0644 packaging/linux/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg \
+4
View File
@@ -100,6 +100,10 @@ done
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \
"$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# ...and the other half of stopping one: with the DM stopped the box has no active local session,
# so logind's power actions fall to auth_admin_keep and Steam's power menu goes quiet mid-stream.
install -Dm0644 packaging/linux/49-punktfunk-power.rules \
"$STAGE/usr/share/polkit-1/rules.d/49-punktfunk-power.rules"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
+5
View File
@@ -350,6 +350,11 @@ modules:
# Desktop entry (renamed to the app id; Exec is the in-sandbox binary).
- install -Dm0644 packaging/flatpak/io.unom.Punktfunk.desktop
${FLATPAK_DEST}/share/applications/io.unom.Punktfunk.desktop
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry
# point. Shared with the deb/rpm/arch (there is nothing sandbox-specific about it);
# flatpak's export rewrites Exec into `flatpak run --command=punktfunk-client …`.
- install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop
${FLATPAK_DEST}/share/applications/io.unom.Punktfunk.Console.desktop
# AppStream metainfo (required for a well-formed flatpak / Software listings).
- install -Dm0644 packaging/flatpak/io.unom.Punktfunk.metainfo.xml
${FLATPAK_DEST}/share/metainfo/io.unom.Punktfunk.metainfo.xml
+41
View File
@@ -0,0 +1,41 @@
// Members of the `punktfunk` group may power the box off, restart it, or put it to sleep even when
// the box has no ACTIVE LOCAL SESSION — which is exactly the state a display-manager takeover
// leaves it in for the length of a stream.
//
// Why this is needed at all: logind ships `power-off`/`reboot`/`suspend` as `allow_active: yes`,
// and polkit decides "active" from the caller's own logind session — falling back to the user's
// elected DISPLAY session when the caller has none, which is every `systemd --user` unit including
// the managed gamescope session. A takeover that STOPS the display manager removes that session
// (logind elects a display session only from `user`/`greeter` class ones, never from the user
// manager's), the fallback then finds nothing, and all three actions become `auth_admin_keep`: an
// interactive password prompt, asked of a non-interactive caller, on a screen that is switched off.
// Nothing surfaces the refusal, so the symptom is a power menu that does nothing at all.
//
// That menu is the reason this file exists. On SteamOS-like boxes Steam does not call logind for
// "Shut Down" — it writes `$STEAMOS_STEAM_SHUTDOWN_SENTINEL` and exits, and `gamescope-session-plus`
// runs a plain `poweroff` once Steam is gone. During a stream that wrapper is OURS, running in the
// session-less transient unit, so its `poweroff` is the call polkit refuses. Measured on Bazzite,
// 2026-08-24: the identical `pkcheck --action-id org.freedesktop.login1.power-off` from a
// `systemd --user` unit answers authorized with the display manager up, and `auth_admin_keep` with
// it stopped.
//
// The group ships EMPTY and joining it is a deliberate act. It is the same group the takeover's own
// root helper (`io.unom.punktfunk.dm-helper`) authorizes on, and a takeover that stops a display
// manager cannot work without that helper — so this grants to exactly the population the fault
// reaches, and to nobody else.
//
// Scope notes: the three actions are the three entries in Steam's power menu, so a grant that stops
// there authorizes "use the power menu on the box you are streaming from" and nothing else. The
// `-multiple-sessions` variants are deliberately NOT granted — a box with a second USER logged in
// still asks before it powers off under them — and neither are the `-ignore-inhibit` ones, which
// would let this override somebody else's block inhibitor rather than just our absent session.
polkit.addRule(function (action, subject) {
if (
(action.id == "org.freedesktop.login1.power-off" ||
action.id == "org.freedesktop.login1.reboot" ||
action.id == "org.freedesktop.login1.suspend") &&
subject.isInGroup("punktfunk")
) {
return polkit.Result.YES;
}
});
@@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Name=Punktfunk Console
Comment=Controller-driven couch interface browse hosts and stream with a gamepad
# The shell already execs `punktfunk-session --browse` for this argv, so the shortcut goes
# through the same binary the main entry uses (which is also what flatpak's Exec rewrite
# expects). A couch UI is fullscreen; the session fullscreens itself on the Deck and under
# gamescope anyway.
Exec=punktfunk-client --browse --fullscreen
Icon=io.unom.Punktfunk
Terminal=false
Categories=Network;Game;
Keywords=streaming;remote;game;gamepad;controller;couch;console;bigpicture;
StartupNotify=true
+4 -1
View File
@@ -410,7 +410,10 @@ The shell exports an
session binary's *optional* on-glass stats overlay is absent, and the **GTK shell
(`punktfunk-client`) is skia-free and fully featured.** Re-adding it means teaching skia-bindings
to consume a prebuilt Skia offline (a fixed-output derivation of the rust-skia tarball) or a
vendored from-source Skia build — a tracked follow-up.
vendored from-source Skia build — a tracked follow-up. For the same reason this build does **not** install
`io.unom.Punktfunk.Console.desktop` (the deb/rpm/arch/flatpak couch launcher): it runs
`--browse`, which needs that feature, so the entry would be a launcher that only prints an
error.
- **`nix flake check` does NOT check the NixOS module — that is why `module-check.nix` exists.**
For `nixosModules`, nix forces the value and asserts it is a lambda taking an open attribute set,
+9
View File
@@ -350,6 +350,10 @@ install -Dm0644 scripts/alsa-ucm2/USB-Audio/Punktfunk/DualSense-PS5-Haptic-HiFi.
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
# ...and the other half of stopping a display manager: with it stopped the box has no active local
# session, so logind's own power actions fall to auth_admin_keep and Steam's power menu goes quiet
# mid-stream. Scoped to the same (shipped-empty) punktfunk group the helper above gates on.
install -Dm0644 packaging/linux/49-punktfunk-power.rules %{buildroot}%{_datadir}/polkit-1/rules.d/49-punktfunk-power.rules
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
@@ -423,6 +427,9 @@ install -Dm0755 target/release/punktfunk-session %{buildroot}%{_bindir}/punktfun
install -Dm0755 target/release/punktfunk %{buildroot}%{_bindir}/punktfunk
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.desktop
# Second launcher, straight into the gamepad console (`--browse`) — the couch entry point.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Console.desktop \
%{buildroot}%{_datadir}/applications/io.unom.Punktfunk.Console.desktop
# The app icon the desktop entry (and the About dialog) name. Without it the launcher falls
# back to a generic monitor glyph, which is what shipped until now.
install -Dm0644 packaging/linux/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg \
@@ -612,6 +619,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_libexecdir}/punktfunk/pf-update
%{_unitdir}/punktfunk-update.service
%{_datadir}/polkit-1/rules.d/49-punktfunk-update.rules
%{_datadir}/polkit-1/rules.d/49-punktfunk-power.rules
%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
%{_prefix}/lib/modules-load.d/punktfunk.conf
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
@@ -640,6 +648,7 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_bindir}/punktfunk-session
%{_bindir}/punktfunk
%{_datadir}/applications/io.unom.Punktfunk.desktop
%{_datadir}/applications/io.unom.Punktfunk.Console.desktop
%{_datadir}/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg
%{_udevrulesdir}/70-punktfunk-client.rules
%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf