Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e2b956de6 | ||
|
|
49b5ffa2d8 | ||
|
|
d65b9f3b1b | ||
|
|
cd0b53f8fe | ||
|
|
00a9d16201 | ||
|
|
540e282e60 | ||
|
|
7246f0fe60 | ||
|
|
e0a822016f | ||
|
|
b6938a9890 | ||
|
|
faf94087c5 | ||
|
|
46d9e0d20f | ||
|
|
8e8451ca0c | ||
|
|
0e1bab019c | ||
|
|
7951d12b06 | ||
|
|
4690a166ca | ||
|
|
8c628b4e6c | ||
|
|
f60b6e30e2 | ||
|
|
4d155f4985 | ||
|
|
4b5f0dac6b |
@@ -370,9 +370,11 @@ jobs:
|
||||
run: bun run build
|
||||
- name: Typecheck
|
||||
run: bun run lint
|
||||
# Scoped to server/: the console's browser code has no test runner, but the gate that keeps a
|
||||
# plugin's origin apart from the console's does — and its failure mode is a well-formed header
|
||||
# that only a browser rejects, which nothing else here would catch.
|
||||
# Scoped to server/ and nitro-entry/: the console's browser code has no test runner, but two
|
||||
# gates here do — the one keeping a plugin's origin apart from the console's, whose failure
|
||||
# mode is a well-formed header that only a browser rejects, and the one picking which of the
|
||||
# host's two identities the console serves, whose failure mode is a cert no browser accepts.
|
||||
# Neither would be caught anywhere else.
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -40,7 +40,7 @@ const SCROLL_HORIZONTAL: u32 = 1;
|
||||
/// `wl_output.name` — the connector name we match the streamed head on — arrived in v4. Nothing
|
||||
/// else we ask of an output needs more than v1, so a lower advert only costs us the names (and
|
||||
/// with them the ability to aim absolute input; see [`index_named`]). Same constant, same reason,
|
||||
/// as `pf_vdisplay`'s `kwin_dpms`.
|
||||
/// as `pf_vdisplay`'s `panel_dpms`.
|
||||
const WL_OUTPUT_MAX: u32 = 4;
|
||||
|
||||
/// One `wl_output` the compositor has advertised.
|
||||
|
||||
@@ -867,14 +867,25 @@ mod kwin;
|
||||
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
|
||||
mod kwin_output_mgmt;
|
||||
|
||||
// DPMS control of the box's live KDE desktop (org_kde_kwin_dpms) — how a bare-spawn gamescope
|
||||
// session honors `Topology::Exclusive`: the spawn is its own headless compositor, so the desktop's
|
||||
// physical outputs can't be *disabled* (KWin refuses zero enabled outputs and no output there is
|
||||
// ours) — they are put to DPMS-off for the stream instead, refcounted across concurrent spawns.
|
||||
// Consumed by `gamescope` (best-effort, with kscreen fallback).
|
||||
// DPMS control of the box's own physical panels — how a gamescope session (which owns no output on
|
||||
// the box's desktop) honors `Topology::Exclusive`. Dispatches per desktop: KDE over
|
||||
// org_kde_kwin_dpms, sway and Hyprland over their own IPC, and `drm_dpms` for a box with no
|
||||
// desktop at all. GNOME is the one it cannot serve — Mutter exposes no DPMS to clients.
|
||||
// The desktop's outputs can't be *disabled* the way the desktop backends do it (KWin refuses zero
|
||||
// enabled outputs, and no output there is ours to keep), so DPMS-off is the honest translation:
|
||||
// the desk is untouched, the panels just go dark. Refcounted across concurrent spawns; consumed by
|
||||
// `gamescope` on both its owning routes, best-effort throughout.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/kwin_dpms.rs"]
|
||||
mod kwin_dpms;
|
||||
#[path = "vdisplay/linux/panel_dpms.rs"]
|
||||
mod panel_dpms;
|
||||
|
||||
// The compositor-independent half of the same policy: turn the CRTCs off over DRM directly, for a
|
||||
// box with no desktop to ask (Game Mode runs gamescope and no KWin, and is exactly where the
|
||||
// operator's TV is lit by the box itself). Reached from `panel_dpms`'s "not KDE" arm, which is what
|
||||
// owns the refcount and the hold.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "vdisplay/linux/drm_dpms.rs"]
|
||||
mod drm_dpms;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "vdisplay/windows/manager.rs"]
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Compositor-independent panel darkening over DRM — how a box with **no desktop compositor**
|
||||
//! honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! [`crate::panel_dpms`] asks KWin to turn the panels off, which is the right answer whenever there
|
||||
//! is a KDE desktop to ask. There often isn't. A box sitting in **Game Mode** runs gamescope and no
|
||||
//! KWin at all, so that path declines — and Game Mode is precisely the deployment where the
|
||||
//! operator's TV is lit by the box itself. Measured on the Nobara VM (2026-08-24): after the
|
||||
//! takeover idles the box's gaming session, `card0-HDMI-A-1` sits at `enabled=enabled dpms=On`
|
||||
//! indefinitely. Nothing blanks on its own — when no client holds DRM master the kernel simply
|
||||
//! keeps the CRTC configured, and fbcon owns it.
|
||||
//!
|
||||
//! So ask the kernel directly. The sequence, all of it measured on that box:
|
||||
//!
|
||||
//! 1. `open("/dev/dri/cardN")` — permitted for the ordinary session user, because logind puts a
|
||||
//! **uaccess ACL** on the node for whoever holds the active seat (`crw-rw----+`). No root, no
|
||||
//! polkit, no group: this is the same access every local compositor gets.
|
||||
//! 2. `DRM_IOCTL_SET_MASTER` — succeeds while no one else is master, which is exactly the state the
|
||||
//! takeover has just produced by idling the box's session. If it FAILS, someone else is driving
|
||||
//! that card (a live compositor, a foreign gamescope) and we decline: darkening a panel out from
|
||||
//! under its owner is not ours to do, and on the Attach route it would darken the very picture
|
||||
//! being streamed.
|
||||
//! 3. `DRM_IOCTL_MODE_GETRESOURCES` (count pass, then data pass) for the CRTC ids, and
|
||||
//! `DRM_IOCTL_MODE_SETCRTC` with `fb_id = 0, mode_valid = 0, count_connectors = 0` on each one
|
||||
//! that is actually driving something. That is a modeset to "off": the connector goes
|
||||
//! `enabled=disabled dpms=Off`, which is the same end state `kscreen-doctor --dpms off` reaches
|
||||
//! through KWin.
|
||||
//! 4. `DRM_IOCTL_DROP_MASTER`, and **keep the fd open**.
|
||||
//!
|
||||
//! Step 4 is the part worth reading twice. The darkness **survives dropping master** (measured), so
|
||||
//! we hand mastering rights straight back — the box's own gamescope must be able to take the card
|
||||
//! when the restore relaunches its session, and a host still holding master would starve it. What
|
||||
//! holds the panel dark is the open fd, not the mastership.
|
||||
//!
|
||||
//! **The re-light is `close(fd)`, and that is the whole of it.** The kernel's last-close handling
|
||||
//! restores the console and the panel comes back lit (measured: `enabled=enabled dpms=On` within
|
||||
//! 2 s of the close). There is no saved mode to replay and no restore that can half-fail — which
|
||||
//! also means **crash safety comes free**, the same property [`crate::panel_dpms`] gets from DPMS
|
||||
//! being non-persistent: a host that dies holding this has its fds closed by the kernel, and the
|
||||
//! box lights up. Nothing to journal, nothing to sweep at startup. (Contrast the Windows
|
||||
//! `pnp_disable_monitors` path, which needs a recovery journal precisely because its disable
|
||||
//! survives everything.)
|
||||
//!
|
||||
//! Best-effort throughout, like every other arm of this policy: a box with no `/dev/dri` at all, a
|
||||
//! card whose master is held by someone else, or a card with nothing lit simply contributes
|
||||
//! nothing and the stream proceeds.
|
||||
|
||||
use std::fs::File;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::Path;
|
||||
|
||||
// ---------------------------------------------------------------- the kernel ABI
|
||||
//
|
||||
// `include/uapi/drm/drm.h` and `drm_mode.h`. Hand-declared rather than pulled from a crate: this is
|
||||
// four ioctls and three plain-old-data structs, and the const asserts below pin every layout that
|
||||
// could drift. `_IO('d', nr)` / `_IOWR('d', nr, T)` encoded by hand — the sizes are in the names.
|
||||
|
||||
/// `DRM_IOCTL_SET_MASTER` — `_IO('d', 0x1e)`.
|
||||
const DRM_IOCTL_SET_MASTER: libc::c_ulong = 0x641e;
|
||||
/// `DRM_IOCTL_DROP_MASTER` — `_IO('d', 0x1f)`.
|
||||
const DRM_IOCTL_DROP_MASTER: libc::c_ulong = 0x641f;
|
||||
/// `DRM_IOCTL_MODE_GETRESOURCES` — `_IOWR('d', 0xA0, drm_mode_card_res)`, 64-byte payload.
|
||||
const DRM_IOCTL_MODE_GETRESOURCES: libc::c_ulong = 0xC040_64A0;
|
||||
/// `DRM_IOCTL_MODE_GETCRTC` — `_IOWR('d', 0xA1, drm_mode_crtc)`, 104-byte payload.
|
||||
const DRM_IOCTL_MODE_GETCRTC: libc::c_ulong = 0xC068_64A1;
|
||||
/// `DRM_IOCTL_MODE_SETCRTC` — `_IOWR('d', 0xA2, drm_mode_crtc)`, 104-byte payload.
|
||||
const DRM_IOCTL_MODE_SETCRTC: libc::c_ulong = 0xC068_64A2;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct DrmModeCardRes {
|
||||
fb_id_ptr: u64,
|
||||
crtc_id_ptr: u64,
|
||||
connector_id_ptr: u64,
|
||||
encoder_id_ptr: u64,
|
||||
count_fbs: u32,
|
||||
count_crtcs: u32,
|
||||
count_connectors: u32,
|
||||
count_encoders: u32,
|
||||
min_width: u32,
|
||||
max_width: u32,
|
||||
min_height: u32,
|
||||
max_height: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct DrmModeModeinfo {
|
||||
clock: u32,
|
||||
hdisplay: u16,
|
||||
hsync_start: u16,
|
||||
hsync_end: u16,
|
||||
htotal: u16,
|
||||
hskew: u16,
|
||||
vdisplay: u16,
|
||||
vsync_start: u16,
|
||||
vsync_end: u16,
|
||||
vtotal: u16,
|
||||
vscan: u16,
|
||||
vrefresh: u32,
|
||||
flags: u32,
|
||||
type_: u32,
|
||||
name: [u8; 32],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct DrmModeCrtc {
|
||||
set_connectors_ptr: u64,
|
||||
count_connectors: u32,
|
||||
crtc_id: u32,
|
||||
fb_id: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
gamma_size: u32,
|
||||
mode_valid: u32,
|
||||
mode: DrmModeModeinfo,
|
||||
}
|
||||
|
||||
// The ioctl numbers above encode their payload size (0x40 = 64, 0x68 = 104). If a struct here ever
|
||||
// disagrees with that, the kernel reads or writes the wrong number of bytes — so pin it at compile
|
||||
// time rather than discovering it as a corrupted modeset on someone's TV.
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeCardRes>() == 0x40);
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeModeinfo>() == 68);
|
||||
const _: () = assert!(std::mem::size_of::<DrmModeCrtc>() == 0x68);
|
||||
|
||||
impl Default for DrmModeCrtc {
|
||||
fn default() -> Self {
|
||||
// SAFETY: both structs are `repr(C)` plain old data — integers and a `[u8; 32]`, no
|
||||
// padding invariants, no pointers that must be valid, and no `Drop`. An all-zero value is
|
||||
// a legal instance, and is exactly what the ioctls want for "no connectors, no mode".
|
||||
unsafe { std::mem::zeroed() }
|
||||
}
|
||||
}
|
||||
|
||||
/// One card we have darkened: the open fd is the hold. Dropping this closes it, and the kernel
|
||||
/// re-lights — see the module docs.
|
||||
pub struct DrmDarken {
|
||||
/// Kept solely for its `Drop`. The panel stays dark exactly as long as these are open.
|
||||
_cards: Vec<File>,
|
||||
/// Which `/dev/dri/cardN` we actually turned something off on — logging only.
|
||||
pub darkened: Vec<String>,
|
||||
}
|
||||
|
||||
/// `ioctl(fd, req, &mut arg)` for the modeset structs, returning the raw `errno` on failure.
|
||||
///
|
||||
/// Split out so each call site is one line and there is exactly one `unsafe` block to justify
|
||||
/// instead of five near-identical ones.
|
||||
fn ioctl<T>(fd: libc::c_int, req: libc::c_ulong, arg: &mut T) -> std::io::Result<()> {
|
||||
// SAFETY: `fd` is an open DRM node owned by the caller for the whole call; `req` is one of the
|
||||
// five `_IO`/`_IOWR` codes declared above, each paired with the `T` its size field names (the
|
||||
// const asserts pin that); and `arg` is a live, uniquely-borrowed, `repr(C)` value of that
|
||||
// exact type, so the kernel's read/write of `size_of::<T>()` bytes stays inside it.
|
||||
let rc = unsafe { libc::ioctl(fd, req, arg as *mut T) };
|
||||
if rc < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn every lit CRTC on every DRM card off, and hold them off. `None` when nothing was darkened
|
||||
/// — no cards, none masterable, or none lit — and therefore nothing to restore.
|
||||
pub fn darken() -> Option<DrmDarken> {
|
||||
let mut cards = Vec::new();
|
||||
let mut darkened = Vec::new();
|
||||
for entry in std::fs::read_dir("/dev/dri").ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
// `cardN` only: `renderD*` is the render node (no modesetting at all) and `by-path/` is a
|
||||
// directory of symlinks to the same nodes.
|
||||
if !name.starts_with("card") {
|
||||
continue;
|
||||
}
|
||||
match darken_card(&path) {
|
||||
// Masterable, but nothing on this card was lit. Its fd is dropped here, which is
|
||||
// correct: we changed nothing, so there is nothing to hold.
|
||||
Ok((_, 0)) => {}
|
||||
Ok((card, n)) => {
|
||||
tracing::debug!(card = name, crtcs = n, "DRM: CRTCs off");
|
||||
darkened.push(name.to_string());
|
||||
// ⚠ HOLD THE FD THAT DID THE WORK. Closing it and re-opening does not survive the
|
||||
// round trip: the close is the kernel's LAST close on that device, which restores
|
||||
// the console and re-lights the panel — the fresh fd then holds nothing. Measured
|
||||
// on the Nobara VM 2026-08-24, where exactly that shape reported `darkened
|
||||
// cards: ["card0"]` while the connector sat at `enabled=enabled dpms=On`.
|
||||
cards.push(card);
|
||||
}
|
||||
Err(why) => tracing::debug!(card = name, %why, "DRM: not ours to darken"),
|
||||
}
|
||||
}
|
||||
if darkened.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(DrmDarken {
|
||||
_cards: cards,
|
||||
darkened,
|
||||
})
|
||||
}
|
||||
|
||||
/// Darken one card, returning the open fd **and** how many CRTCs were actually turned off.
|
||||
///
|
||||
/// The fd comes back with the count because the caller MUST keep this exact one to hold the panel
|
||||
/// dark: closing it is the kernel's last close on the device, which restores the console. A card
|
||||
/// that reports 0 can have its fd dropped freely — nothing was changed to undo.
|
||||
fn darken_card(path: &Path) -> std::io::Result<(File, usize)> {
|
||||
let card = File::options().read(true).write(true).open(path)?;
|
||||
let fd = card.as_raw_fd();
|
||||
// Someone else driving this card (a live compositor, a foreign gamescope) ⇒ not ours. This is
|
||||
// also what keeps the Attach route honest without needing to know about it here.
|
||||
ioctl(fd, DRM_IOCTL_SET_MASTER, &mut 0u64)?;
|
||||
|
||||
// Count pass: every pointer NULL, the kernel fills in the counts.
|
||||
let mut res = DrmModeCardRes::default();
|
||||
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
|
||||
let n = res.count_crtcs as usize;
|
||||
if n == 0 {
|
||||
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
|
||||
return Ok((card, 0));
|
||||
}
|
||||
// Data pass: hand back a buffer sized by that count and ask again.
|
||||
let mut ids = vec![0u32; n];
|
||||
let mut res = DrmModeCardRes {
|
||||
crtc_id_ptr: ids.as_mut_ptr() as u64,
|
||||
count_crtcs: n as u32,
|
||||
..Default::default()
|
||||
};
|
||||
ioctl(fd, DRM_IOCTL_MODE_GETRESOURCES, &mut res)?;
|
||||
// The kernel may report FEWER than the count pass promised (a hotplug between the two); it
|
||||
// never reports more than the buffer we sized, so trust the second count.
|
||||
ids.truncate(res.count_crtcs as usize);
|
||||
|
||||
let mut off = 0usize;
|
||||
for id in ids {
|
||||
let mut crtc = DrmModeCrtc {
|
||||
crtc_id: id,
|
||||
..Default::default()
|
||||
};
|
||||
if ioctl(fd, DRM_IOCTL_MODE_GETCRTC, &mut crtc).is_err() {
|
||||
continue;
|
||||
}
|
||||
// Only touch a CRTC that is actually driving a display. Disabling an already-dark one is a
|
||||
// harmless no-op, but counting it would make the log claim a panel went off that never was
|
||||
// on — and that verdict is the whole point of reporting a count at all.
|
||||
if crtc.mode_valid == 0 && crtc.fb_id == 0 {
|
||||
continue;
|
||||
}
|
||||
// The modeset to "off": no framebuffer, no mode, no connectors.
|
||||
let mut disable = DrmModeCrtc {
|
||||
crtc_id: id,
|
||||
..Default::default()
|
||||
};
|
||||
if ioctl(fd, DRM_IOCTL_MODE_SETCRTC, &mut disable).is_ok() {
|
||||
off += 1;
|
||||
}
|
||||
}
|
||||
// Hand mastering back immediately: the darkness does not depend on holding it (measured), and
|
||||
// the box's own gamescope needs to be able to take this card when the restore relaunches its
|
||||
// session. Keeping it would turn a dark panel into a session that cannot start.
|
||||
let _ = ioctl(fd, DRM_IOCTL_DROP_MASTER, &mut 0u64);
|
||||
Ok((card, off))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DrmModeCardRes, DrmModeCrtc, DrmModeModeinfo};
|
||||
|
||||
/// The layouts the ioctl numbers encode. The `const` asserts above already fail the BUILD on
|
||||
/// drift; this restates them as a test so the reason is greppable from a failure, and pins the
|
||||
/// two field offsets the count/data-pass dance actually depends on.
|
||||
#[test]
|
||||
fn the_abi_structs_match_the_ioctl_payload_sizes() {
|
||||
assert_eq!(std::mem::size_of::<DrmModeCardRes>(), 0x40, "_IOWR 0x40");
|
||||
assert_eq!(std::mem::size_of::<DrmModeModeinfo>(), 68);
|
||||
assert_eq!(std::mem::size_of::<DrmModeCrtc>(), 0x68, "_IOWR 0x68");
|
||||
// `crtc_id_ptr` is the second u64 — the field the data pass points at its id buffer. A
|
||||
// reorder here would hand the kernel the framebuffer-id pointer instead.
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCardRes, crtc_id_ptr), 8);
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCardRes, count_crtcs), 36);
|
||||
// `mode` must sit right after the seven u32s, or SETCRTC reads a mode we never wrote.
|
||||
assert_eq!(std::mem::offset_of!(DrmModeCrtc, mode), 36);
|
||||
}
|
||||
|
||||
/// ON GLASS. Darken this box's panels for real and read the verdict back out of sysfs.
|
||||
///
|
||||
/// Run it on a box with a **connected head and no compositor holding the card** — i.e. exactly
|
||||
/// the takeover state this module exists for. On the Nobara VM:
|
||||
///
|
||||
/// ```sh
|
||||
/// # idle the box's gaming session first (what stop_autologin_sessions does), then:
|
||||
/// ./pf_vdisplay-<hash> --ignored --nocapture drm_dpms
|
||||
/// ```
|
||||
///
|
||||
/// Skips itself (rather than failing) when nothing was ours to darken, because that is the
|
||||
/// honest outcome on a dev box with a live desktop — the card is already mastered.
|
||||
#[test]
|
||||
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
|
||||
fn live_the_panels_go_dark_and_come_back() {
|
||||
fn connectors() -> Vec<(String, String, String)> {
|
||||
let mut v = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
|
||||
return v;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let rd = |f: &str| {
|
||||
std::fs::read_to_string(p.join(f))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if rd("status") == "connected" {
|
||||
v.push((
|
||||
e.file_name().to_string_lossy().into_owned(),
|
||||
rd("enabled"),
|
||||
rd("dpms"),
|
||||
));
|
||||
}
|
||||
}
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
let before = connectors();
|
||||
println!("before: {before:?}");
|
||||
assert!(
|
||||
!before.is_empty(),
|
||||
"no connected head — this test needs one to mean anything"
|
||||
);
|
||||
|
||||
let Some(hold) = super::darken() else {
|
||||
println!("nothing was ours to darken (card already mastered?) — skipping");
|
||||
return;
|
||||
};
|
||||
println!("darkened cards: {:?}", hold.darkened);
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let during = connectors();
|
||||
println!("during: {during:?}");
|
||||
|
||||
drop(hold);
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let after = connectors();
|
||||
println!("after: {after:?}");
|
||||
|
||||
// The claim: every head that was lit went dark, and every one of them came back.
|
||||
for (name, en, dpms) in &during {
|
||||
assert_eq!(dpms, "Off", "{name} should be DPMS-off while held ({en})");
|
||||
}
|
||||
assert_eq!(
|
||||
after, before,
|
||||
"dropping the hold must restore exactly the state we found"
|
||||
);
|
||||
}
|
||||
|
||||
/// A zeroed `DrmModeCrtc` IS the disable request — that is the only thing `Default` is for
|
||||
/// here, so a change that made it non-zero would silently stop disabling anything.
|
||||
#[test]
|
||||
fn the_default_crtc_is_the_disable_request() {
|
||||
let c = DrmModeCrtc::default();
|
||||
assert_eq!(c.fb_id, 0, "a framebuffer would keep the CRTC lit");
|
||||
assert_eq!(
|
||||
c.mode_valid, 0,
|
||||
"a valid mode would re-modeset, not disable"
|
||||
);
|
||||
assert_eq!(c.count_connectors, 0);
|
||||
assert_eq!(c.set_connectors_ptr, 0);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ pub struct GamescopeDisplay {
|
||||
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
|
||||
route: Option<crate::GamescopeRoute>,
|
||||
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
|
||||
/// the release of this display's [`crate::kwin_dpms`] darken hold — pending pickup by the
|
||||
/// the release of this display's [`crate::panel_dpms`] darken hold — pending pickup by the
|
||||
/// registry via [`VirtualDisplay::take_topology_restore`], so it runs at the display's
|
||||
/// teardown (§6.1) and never before.
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
@@ -176,6 +176,63 @@ const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
|
||||
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
|
||||
static IDLE_DROPIN_ARMED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// Whether the MANAGED route currently holds a [`crate::panel_dpms`] darken hold for
|
||||
/// `Topology::Exclusive`.
|
||||
///
|
||||
/// The managed route cannot register its release the way a bare spawn does. A spawn reports
|
||||
/// `DisplayOwnership::Owned`, so `registry::acquire` picks its `take_topology_restore()` up and
|
||||
/// runs it at teardown; managed reports `SessionManaged`, and that function returns for anything
|
||||
/// not `Owned` **above** the pickup — deliberately, because this module owns the managed
|
||||
/// lifecycle instead. So this module owns the release too: [`do_restore_tv_session`], the one
|
||||
/// teardown every managed path funnels through.
|
||||
///
|
||||
/// A plain bool rather than a count because the managed SESSION is what is darkened, not each
|
||||
/// connect: it survives client disconnects (that is the whole point of [`MANAGED_SESSION`]), and a
|
||||
/// same-mode reconnect reuses it warm without a relaunch. Acquiring per connect would ratchet
|
||||
/// `panel_dpms`'s refcount up with no matching releases and pin the panel dark for the host's life.
|
||||
static MANAGED_DARKEN_HELD: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
|
||||
|
||||
/// The 0→1 edge: should this call actually take a `panel_dpms` hold? Pure, and split from
|
||||
/// [`managed_darken_acquire`] so the balance rule is testable without a live compositor — the same
|
||||
/// shape as `panel_dpms::Holds::acquire_edge`, and for the same reason.
|
||||
fn managed_darken_acquire_edge(held: &mut bool, exclusive: bool) -> bool {
|
||||
if !exclusive || *held {
|
||||
return false;
|
||||
}
|
||||
*held = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// The 1→0 edge: should this call actually release one?
|
||||
fn managed_darken_release_edge(held: &mut bool) -> bool {
|
||||
if !*held {
|
||||
return false;
|
||||
}
|
||||
*held = false;
|
||||
true
|
||||
}
|
||||
|
||||
/// Take the managed route's darken hold, once, if `exclusive` and we don't already hold one.
|
||||
fn managed_darken_acquire(exclusive: bool) {
|
||||
let mut held = MANAGED_DARKEN_HELD
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if managed_darken_acquire_edge(&mut held, exclusive) {
|
||||
crate::panel_dpms::acquire_stream_darken();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop it, if held. Idempotent, because it is called unconditionally from the restore — which is
|
||||
/// exactly what makes it safe to put above every early return there.
|
||||
fn managed_darken_release() {
|
||||
let mut held = MANAGED_DARKEN_HELD
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if managed_darken_release_edge(&mut held) {
|
||||
crate::panel_dpms::release_stream_darken();
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
|
||||
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
|
||||
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
|
||||
@@ -537,7 +594,7 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// The DPMS darken-hold release the bare-spawn `create` registered (Exclusive topology
|
||||
// only). The registry stores it on this display's entry and runs it at teardown — which,
|
||||
// for gamescope, is the display's OWN teardown: every spawn is its own group, and the
|
||||
// cross-session ordering lives in `kwin_dpms`'s refcount, not in the group float.
|
||||
// cross-session ordering lives in `panel_dpms`'s refcount, not in the group float.
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
@@ -583,8 +640,32 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// also what the ladder's own default arm picks.
|
||||
None => (None, None),
|
||||
};
|
||||
// `Topology::Exclusive` means the operator asked for the box's own screens to go dark for
|
||||
// the stream. Resolved ONCE, above every route's return, so the managed hold below and the
|
||||
// "free the box's session" decision and the bare spawn's darken at the end of this
|
||||
// function can never disagree within a single create.
|
||||
let exclusive = crate::effective_topology() == crate::policy::Topology::Exclusive;
|
||||
if let Some(client) = session_env {
|
||||
return create_managed_session(&client, mode, self.hdr);
|
||||
let out = create_managed_session(&client, mode, self.hdr)?;
|
||||
// Managed is the route that matters most here: it is the recommended one, it gives the
|
||||
// best experience, and it is the ONLY way to serve a client its own virtual output at
|
||||
// its own mode. So `exclusive` has to mean something on it.
|
||||
//
|
||||
// Its takeover idles the box's autologin session, which stops that session DRIVING the
|
||||
// panel — but measured on the Nobara VM (2026-08-24), that alone leaves the connector
|
||||
// at `enabled=enabled dpms=On` indefinitely: with no DRM master the kernel just keeps
|
||||
// the CRTC configured. Turning it off is [`crate::panel_dpms`]'s job, and on a Game Mode
|
||||
// box (no KWin) that lands in its DRM arm — which needs no compositor and no privilege.
|
||||
//
|
||||
// The hold canNOT ride `self.pending_restore` the way the bare spawn's does: this
|
||||
// route reports `DisplayOwnership::SessionManaged`, and `registry::acquire` returns for
|
||||
// anything not `Owned` ABOVE its `take_topology_restore()` pickup, so that hold would
|
||||
// never be released — and a panel dark after every stream is worse than one left lit.
|
||||
// Hence [`managed_darken_acquire`] / [`managed_darken_release`], balanced against
|
||||
// [`do_restore_tv_session`] instead: the one teardown every managed path funnels
|
||||
// through, and the same place the drop-in sweep lives for the same reason.
|
||||
managed_darken_acquire(exclusive);
|
||||
return Ok(out);
|
||||
}
|
||||
// Attach to an already-running gamescope (a foreign / externally-launched session) instead
|
||||
// of spawning our own: capture its node AND inject into its EIS socket.
|
||||
@@ -601,6 +682,10 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
|
||||
};
|
||||
point_injector_at_eis();
|
||||
// ⚠ NO darken hold here either, and this one is policy: attach MIRRORS a gamescope
|
||||
// that may itself be lighting the physical panel, so honoring `exclusive` by
|
||||
// darkening it would darken the very picture being streamed. `exclusive` cannot be
|
||||
// served on this route; the operator's lever is to pick a model that owns a display.
|
||||
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
|
||||
// ATTACH = mirror a foreign gamescope we don't own → External (no keep-alive/reuse).
|
||||
return Ok(VirtualOutput {
|
||||
@@ -631,7 +716,8 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// NO instance free — and then collided with the box's own autologin/desktop Steam, which
|
||||
// is precisely the collision this block exists to prevent.
|
||||
let app = resolved_spawn_app(self.cmd.as_deref());
|
||||
if app.as_deref().is_some_and(is_steam_launch) {
|
||||
let steam = app.as_deref().is_some_and(is_steam_launch);
|
||||
if steam {
|
||||
// A dedicated launch NEEDS Steam's single instance — no attach degrade exists here, so
|
||||
// a mask-fragile-DM box without takeover privilege fails with the actionable error.
|
||||
stop_autologin_sessions()
|
||||
@@ -639,6 +725,24 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
|
||||
// the same, and the autologin stop above can't see it — free it too, or fail loudly.
|
||||
free_desktop_steam()?;
|
||||
} else if free_box_session_for_exclusive(steam, exclusive) {
|
||||
// B1c: a NON-Steam launch has no single instance to free, and used to leave the box's
|
||||
// gaming session completely untouched. On a Game Mode box that session IS the DRM
|
||||
// master of the TV (`gamescope/heads.rs`), so under Exclusive it went on lighting the
|
||||
// panel with live Game Mode for the whole stream — the loudest half of the Nobara field
|
||||
// report, and never a 0.31.0 regression: this path has always been Steam-gated.
|
||||
//
|
||||
// Best-effort, unlike the Steam arm above: freeing the session is what MAKES the panel
|
||||
// dark here, not what makes the launch possible, so a box that refuses costs the
|
||||
// operator their dark screen and not their game. The restore is the same machinery
|
||||
// either way (`STOPPED_AUTOLOGIN` → `schedule_restore_tv_session`).
|
||||
if let Err(why) = stop_autologin_sessions() {
|
||||
tracing::warn!(
|
||||
%why,
|
||||
"exclusive topology: could not free the box's gaming session, so its own \
|
||||
display keeps whatever it is showing for this stream"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A5: a per-spawn instance id addresses this spawn's log + node discovery, so two coexisting
|
||||
// bare-spawns (a kept lingering one + a fresh one) never parse each other's node id from a
|
||||
@@ -685,16 +789,19 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
// the physicals outright, but that door is closed here (KWin refuses zero enabled outputs,
|
||||
// and no output on that desktop is ours to leave enabled) — so the desktop's panels go to
|
||||
// DPMS-off instead, best-effort and self-gating (a box with no KDE desktop declines
|
||||
// quietly inside `kwin_dpms`). Placed AFTER the spawn succeeded, so a failed create never
|
||||
// blanks the user's screen. The hold is refcounted in `kwin_dpms` rather than floated
|
||||
// quietly inside `panel_dpms`). Placed AFTER the spawn succeeded, so a failed create never
|
||||
// blanks the user's screen. The hold is refcounted in `panel_dpms` rather than floated
|
||||
// through the registry's group restore, because every gamescope spawn is its own group
|
||||
// (`registry::group_key`) — the float alone would re-light the panel when the FIRST of two
|
||||
// concurrent spawns ends, under the second's still-live stream. Skipped for Managed (its
|
||||
// takeover already stopped the desktop) and Attach (it mirrors a gamescope that may itself
|
||||
// be driving the physical panel) — both returned earlier in this function.
|
||||
if crate::effective_topology() == crate::policy::Topology::Exclusive {
|
||||
crate::kwin_dpms::acquire_stream_darken();
|
||||
self.pending_restore = Some(Box::new(crate::kwin_dpms::release_stream_darken));
|
||||
// concurrent spawns ends, under the second's still-live stream. Managed takes the same
|
||||
// hold at its own return above, through [`managed_darken_acquire`] rather than this field
|
||||
// (its display is not registry-owned, so there is no `take_topology_restore` pickup to
|
||||
// ride). Only Attach still skips, and for a reason that survives: it mirrors a gamescope
|
||||
// that may itself be driving the physical panel, so darkening it would darken the very
|
||||
// picture being streamed.
|
||||
if exclusive {
|
||||
crate::panel_dpms::acquire_stream_darken();
|
||||
self.pending_restore = Some(Box::new(crate::panel_dpms::release_stream_darken));
|
||||
}
|
||||
// Bare SPAWN: we own the nested gamescope process → registry-poolable (keep-alive-able).
|
||||
Ok(VirtualOutput::owned(
|
||||
@@ -3728,6 +3835,17 @@ fn handback_watch(units: &[String]) {
|
||||
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
||||
/// cancelled+reconnected window keeps the list for a later real restore.
|
||||
fn do_restore_tv_session(verify: bool) {
|
||||
// Give the box its screens back FIRST, above every early return below — including the SteamOS
|
||||
// ones, which is why this sits at the very top rather than beside the drop-in sweep that
|
||||
// follows the same "must not leak past a return" rule. The managed route's `exclusive` darken
|
||||
// has no registry restore to ride (`DisplayOwnership::SessionManaged` returns above
|
||||
// `take_topology_restore`), so this call is its ONLY release — leaking it would leave the
|
||||
// operator's panel dark for the rest of the host's life.
|
||||
//
|
||||
// Safe this early: releasing re-lights, and every path below either hands the box back or
|
||||
// deliberately keeps a headless session on a box with no connected display (nothing lit to
|
||||
// darken there anyway). Idempotent, so the paths that reach the restore twice cost nothing.
|
||||
managed_darken_release();
|
||||
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
|
||||
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
|
||||
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
|
||||
@@ -5192,6 +5310,20 @@ fn is_steam_launch(cmd: &str) -> bool {
|
||||
cmd.split_whitespace().next() == Some("steam")
|
||||
}
|
||||
|
||||
/// Should a bare-spawn launch free the box's own gaming session when it is NOT a Steam launch?
|
||||
///
|
||||
/// Two different requirements reach the same call. A **Steam** launch frees it because it must —
|
||||
/// the single instance is not shareable — and that arm fails the create when it can't. **Exclusive
|
||||
/// topology** frees it because the operator asked for the box's screens to go dark, and on a Game
|
||||
/// Mode box that session is the DRM master of the physical panel; that arm is best-effort.
|
||||
///
|
||||
/// Pure so the gate is testable without systemd: the bug it closes was a policy question
|
||||
/// (`is_steam_launch` standing in for "does the box's session need to get out of the way"), not a
|
||||
/// systemd one.
|
||||
fn free_box_session_for_exclusive(steam: bool, exclusive: bool) -> bool {
|
||||
!steam && exclusive
|
||||
}
|
||||
|
||||
/// Shape a resolved launch command for a bare-spawn gamescope session. A Steam URI launch
|
||||
/// (`steam steam://rungameid/<id>`, produced by `library::command_for`) gets `-gamepadui` inserted
|
||||
/// so the nested Steam is Big Picture — the identity gamescope's `--steam` integration is built
|
||||
@@ -5564,8 +5696,9 @@ mod tests {
|
||||
use super::{
|
||||
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
|
||||
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
|
||||
free_box_session_for_exclusive, game_hz, gamescope_output_size, hdr_args, idle_dropin_body,
|
||||
idle_dropin_path, install_idle_dropin, is_steam_launch, managed_darken_acquire_edge,
|
||||
managed_darken_release_edge, mask_unit, missing_flags, mode_mismatch,
|
||||
nested_wrapper_script, our_wsi_layer_dir, parse_listed_units, plan_bind,
|
||||
release_autologin_mask, remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced,
|
||||
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
|
||||
@@ -5899,6 +6032,126 @@ mod tests {
|
||||
assert!(!ran.contains("reinstall"), "{ran}");
|
||||
}
|
||||
|
||||
/// ON GLASS. The MANAGED route's hold, driven against the real `panel_dpms`/`drm_dpms` stack —
|
||||
/// the wiring the pure edge test above cannot see. Run it in the takeover state (the box's
|
||||
/// gaming session idled, so nothing holds DRM master), on a box with a connected head:
|
||||
///
|
||||
/// ```sh
|
||||
/// ./pf_vdisplay-<hash> --ignored --nocapture the_managed_hold_darkens_a_real_panel
|
||||
/// ```
|
||||
#[test]
|
||||
#[ignore = "on glass: needs a connected head and no compositor holding /dev/dri/card*"]
|
||||
fn live_the_managed_hold_darkens_a_real_panel() {
|
||||
fn lit() -> Vec<(String, String)> {
|
||||
let mut v = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir("/sys/class/drm") else {
|
||||
return v;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let f = |n: &str| {
|
||||
std::fs::read_to_string(p.join(n))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if f("status") == "connected" {
|
||||
v.push((e.file_name().to_string_lossy().into_owned(), f("dpms")));
|
||||
}
|
||||
}
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
let before = lit();
|
||||
println!("before: {before:?}");
|
||||
assert!(
|
||||
!before.is_empty(),
|
||||
"needs a connected head to mean anything"
|
||||
);
|
||||
|
||||
super::managed_darken_acquire(true);
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let during = lit();
|
||||
println!("during: {during:?}");
|
||||
|
||||
// A reconnect must not take a second hold — if it did, the release below would leave the
|
||||
// panel dark. This is the failure the pure test models; here it is against the real
|
||||
// refcount.
|
||||
super::managed_darken_acquire(true);
|
||||
|
||||
super::managed_darken_release();
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let after = lit();
|
||||
println!("after: {after:?}");
|
||||
|
||||
let went_dark: Vec<&String> = during
|
||||
.iter()
|
||||
.zip(&before)
|
||||
.filter(|((_, now), (_, was))| was == "On" && now == "Off")
|
||||
.map(|((n, _), _)| n)
|
||||
.collect();
|
||||
if went_dark.is_empty() {
|
||||
println!("nothing was ours to darken (card already mastered?) — skipping");
|
||||
return;
|
||||
}
|
||||
// Deliberately "at least one went dark", not "all did": a box can carry a connected head
|
||||
// the live compositor does not manage. The Hyprland VM has a virtio `Virtual-1` beside the
|
||||
// real `HDMI-A-1`, and only the latter is Hyprland's to darken — asserting all of them
|
||||
// would fail on a difference that is not a defect. What must hold is that the mechanism
|
||||
// darkened something real, and that the release put every head back exactly as found.
|
||||
println!("went dark: {went_dark:?}");
|
||||
assert_eq!(after, before, "the release must restore what we found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_managed_darken_hold_is_taken_once_and_released_once() {
|
||||
// The managed SESSION is what gets darkened, not each connect — it outlives client
|
||||
// disconnects and a same-mode reconnect reuses it warm. So a reconnect must NOT take a
|
||||
// second hold: `panel_dpms`'s refcount would ratchet up with no matching release and pin
|
||||
// the operator's panel dark for the rest of the host's life.
|
||||
let mut held = false;
|
||||
assert!(managed_darken_acquire_edge(&mut held, true), "0→1 darkens");
|
||||
assert!(!managed_darken_acquire_edge(&mut held, true), "reconnect");
|
||||
assert!(!managed_darken_acquire_edge(&mut held, true));
|
||||
|
||||
// The restore calls the release unconditionally, above every early return — so it has to
|
||||
// be idempotent, or a path that reaches the restore twice would release a hold it does
|
||||
// not have and drop someone else's.
|
||||
assert!(managed_darken_release_edge(&mut held), "1→0 re-lights");
|
||||
assert!(!managed_darken_release_edge(&mut held), "already released");
|
||||
assert!(!managed_darken_release_edge(&mut held));
|
||||
|
||||
// And it re-arms: a later stream on the same host lifetime darkens again.
|
||||
assert!(managed_darken_acquire_edge(&mut held, true));
|
||||
assert!(managed_darken_release_edge(&mut held));
|
||||
|
||||
// Not exclusive ⇒ never a hold, so the restore's unconditional release stays a no-op.
|
||||
// This is what makes `extend` / `SharedDesktop` ("never blank the real monitors") mean
|
||||
// what they say on the managed route.
|
||||
let mut held = false;
|
||||
assert!(!managed_darken_acquire_edge(&mut held, false));
|
||||
assert!(!held);
|
||||
assert!(!managed_darken_release_edge(&mut held));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_frees_the_box_session_for_a_non_steam_launch_too() {
|
||||
// The bug: `is_steam_launch` was standing in for "does the box's own session need to get
|
||||
// out of the way", and those are two different questions. A non-Steam library game under
|
||||
// `exclusive` left the box's Game Mode gamescope holding DRM master on the TV, so the
|
||||
// operator's screen showed live Game Mode for the whole stream (Nobara, 2026-08-24).
|
||||
assert!(free_box_session_for_exclusive(false, true));
|
||||
// A Steam launch is already handled by the arm above this one — and that arm is the
|
||||
// FAILING one (the single instance is not optional), so this gate must not also fire and
|
||||
// free the session a second time.
|
||||
assert!(!free_box_session_for_exclusive(true, true));
|
||||
// Not exclusive: the operator did not ask for their screens to go dark, so a non-Steam
|
||||
// launch must keep leaving the box's session strictly alone. This is what makes `extend`
|
||||
// and the `SharedDesktop` preset ("never blank the real monitors") mean what they say.
|
||||
assert!(!free_box_session_for_exclusive(false, false));
|
||||
assert!(!free_box_session_for_exclusive(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dm_plan_idles_any_dm_that_drove_a_live_session() {
|
||||
// A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two
|
||||
|
||||
@@ -539,6 +539,123 @@ fn heads_to_disable(heads: &[crate::monitors::PhysicalMonitor], ours: &str) -> V
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
|
||||
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
|
||||
///
|
||||
/// Distinct from [`disable_other_heads`], which is what the *Hyprland backend's own* exclusive
|
||||
/// topology does, and deliberately so on this compositor above all: disabling a Hyprland head is
|
||||
/// the operation whose only known undo is re-reading the operator's whole config
|
||||
/// ([`restore_heads`]), dropping every runtime override they set by hand. DPMS is a separate axis
|
||||
/// — this module's own notes record `dispatch dpms on <name>` failing to re-enable a *disabled*
|
||||
/// head for exactly that reason — so off/on round-trips cleanly and touches nothing else.
|
||||
///
|
||||
/// A gamescope spawn owns no Hyprland output, hence the empty `ours`; a concurrent session's
|
||||
/// `HEADLESS-*` is still spared by [`heads_to_disable`]'s `managed` filter.
|
||||
///
|
||||
/// Returns the heads actually changed, so the re-light undoes exactly those.
|
||||
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
|
||||
let Ok(heads) = list_monitors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut changed = Vec::new();
|
||||
for name in heads_to_disable(&heads, "") {
|
||||
match dpms_one(&name, on) {
|
||||
// Only a head THIS call moved is recorded: one already in the wanted state was left
|
||||
// alone (the dispatcher toggles, so "fixing" it would break it), and reporting it as
|
||||
// changed would have the re-light toggle a head we never darkened.
|
||||
Ok(true) => changed.push(name),
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"hyprland: could not DPMS this monitor for `topology: exclusive`"
|
||||
),
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The DPMS state Hyprland reports for `name` right now — `hyprctl -j monitors all`'s
|
||||
/// `dpmsStatus`. `None` when the monitor is not listed or the field is missing.
|
||||
///
|
||||
/// Measured on 0.55.4: this tracks the hardware exactly (`dpmsStatus:true` ⇔ the connector's sysfs
|
||||
/// `dpms=On`), in both states, and a DPMS-off monitor stays listed. It is the readback
|
||||
/// [`dpms_one`] is built around.
|
||||
fn monitor_dpms(name: &str) -> Option<bool> {
|
||||
let raw = hyprctl(&["-j", "monitors", "all"]).ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
parsed
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|m| m.get("name").and_then(|v| v.as_str()) == Some(name))?
|
||||
.get("dpmsStatus")?
|
||||
.as_bool()
|
||||
}
|
||||
|
||||
/// Put ONE monitor into `want_on`, reporting whether this call actually changed it.
|
||||
///
|
||||
/// ⚠ **The dispatcher is a TOGGLE, not a set** — measured on 0.55.4 (Lua) 2026-08-24, and the
|
||||
/// single most important fact in this function. It ignores the state word entirely:
|
||||
///
|
||||
/// ```text
|
||||
/// On ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> Off <- asked for ON, got OFF
|
||||
/// Off ==[ hl.dsp.dpms("on", "HDMI-A-1") ]==> On
|
||||
/// Off ==[ hl.dsp.dpms{state="off", ...} ]==> On <- asked for OFF, got ON
|
||||
/// ```
|
||||
///
|
||||
/// So a blind "off" LIGHTS an already-dark head, and a blind "on" at teardown DARKENS a lit one —
|
||||
/// the operator's screen left off after the stream, which is the failure this whole policy exists
|
||||
/// to avoid. Hence read → act only if it differs → verify. That shape is also correct on a
|
||||
/// config manager where the call really is a set, so it is not conditional on detecting which.
|
||||
///
|
||||
/// The SPELLING differs too. The classic `hyprctl dispatch dpms off <name>` does not work on the
|
||||
/// Lua manager at all: `dispatch` is shorthand for `hl.dispatch(...)`, so the bare words parse as
|
||||
/// a Lua expression and it dies with `')' expected near 'off'`. A hyprlang box (0.56.2 was probed
|
||||
/// as one) wants the classic form. There is no stable probe for which manager is loaded, and
|
||||
/// [`hyprctl_dispatch`] already catches the exit-0 rejections both produce — so try classic, then
|
||||
/// Lua, and report both failures if neither lands.
|
||||
///
|
||||
/// ⚠ **Never omit the monitor name.** `hl.dsp.dpms("on")` answers `ok` and toggles *something*;
|
||||
/// with a name it is at least addressed at the head we mean.
|
||||
fn dpms_one(name: &str, want_on: bool) -> Result<bool> {
|
||||
if monitor_dpms(name) == Some(want_on) {
|
||||
return Ok(false); // already where we want it — toggling would break it
|
||||
}
|
||||
let classic =
|
||||
match hyprctl_dispatch(&["dispatch", "dpms", if want_on { "on" } else { "off" }, name]) {
|
||||
Ok(()) => None,
|
||||
Err(e) => {
|
||||
let lua = lua_dpms_expr(name, want_on);
|
||||
match hyprctl_dispatch(&["dispatch", &lua]) {
|
||||
Ok(()) => None,
|
||||
Err(lua_err) => Some(format!("hyprlang: {e:#}; lua: {lua_err:#}")),
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(why) = classic {
|
||||
bail!("neither dispatch form was accepted for {name} — {why}");
|
||||
}
|
||||
// Verify, because a toggle that fired against a state we misread is worse than one that did
|
||||
// not fire at all.
|
||||
match monitor_dpms(name) {
|
||||
Some(now) if now == want_on => Ok(true),
|
||||
Some(now) => bail!(
|
||||
"hyprland accepted the dpms dispatch for {name} but it is now dpmsStatus={now}, \
|
||||
wanted {want_on} (the dispatcher toggles — the readback disagreed with reality)"
|
||||
),
|
||||
None => bail!("hyprland stopped listing {name} after its dpms dispatch"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Lua-config-manager spelling of a per-monitor DPMS. Pure, so a test pins the shape — the
|
||||
/// quoting is the whole trick, and an unquoted argument is exactly what the classic form gets
|
||||
/// wrong on that manager.
|
||||
fn lua_dpms_expr(name: &str, on: bool) -> String {
|
||||
format!(
|
||||
"hl.dsp.dpms(\"{}\", \"{name}\")",
|
||||
if on { "on" } else { "off" }
|
||||
)
|
||||
}
|
||||
|
||||
/// Disable every non-managed head for an `exclusive` session, returning the ones actually disabled
|
||||
/// (the input to [`restore_heads`]). Best-effort per head: one that refuses costs exclusivity on
|
||||
/// that screen, not the session.
|
||||
@@ -1388,6 +1505,22 @@ fn portal_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The Lua config manager parses a `dispatch` argument as a Lua expression, so the monitor
|
||||
/// name and the state must both be QUOTED — an unquoted `dpms off HDMI-A-1` is what dies with
|
||||
/// `')' expected near 'off'` on 0.55.4. Pinning the shape here because the quoting is the
|
||||
/// entire difference between working and silently doing nothing.
|
||||
#[test]
|
||||
fn the_lua_dpms_expression_quotes_both_arguments() {
|
||||
assert_eq!(
|
||||
lua_dpms_expr("HDMI-A-1", false),
|
||||
r#"hl.dsp.dpms("off", "HDMI-A-1")"#
|
||||
);
|
||||
assert_eq!(lua_dpms_expr("DP-2", true), r#"hl.dsp.dpms("on", "DP-2")"#);
|
||||
// The monitor name is never omitted: the no-name form answers `ok` and TOGGLES on 0.55.4,
|
||||
// which would flip a just-restored head back off.
|
||||
assert!(lua_dpms_expr("DP-2", true).contains("\"DP-2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tag_parses_release_and_dev_builds() {
|
||||
assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0)));
|
||||
|
||||
+172
-22
@@ -1,23 +1,54 @@
|
||||
//! DPMS control of the box's live KDE desktop (`org_kde_kwin_dpms`) — how a bare-spawn gamescope
|
||||
//! session honors [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//! Turning the box's OWN physical panels off — how a gamescope session honors
|
||||
//! [`Topology::Exclusive`](crate::policy::Topology::Exclusive).
|
||||
//!
|
||||
//! A bare spawn is its OWN headless compositor: nothing on that route touches the desktop the box
|
||||
//! is showing, so on a KDE machine the physical panel keeps displaying the (idle) desktop for the
|
||||
//! whole stream — while the same `exclusive` policy on the KWin route turns the physicals off
|
||||
//! outright. The KWin route's mechanism is closed to us here: KWin refuses an output configuration
|
||||
//! with ZERO enabled outputs, and a gamescope session has no KWin output of its own to leave
|
||||
//! enabled. DPMS is the honest translation of `exclusive` for this route — the desktop stays
|
||||
//! exactly where it is (no topology churn, no window re-homing), the panels go dark, and any
|
||||
//! LOCAL input wakes them, which is the right answer for a desktop someone can walk up to.
|
||||
//! Stream input never wakes them: it is injected into the nested gamescope's own EIS socket and
|
||||
//! does not pass through KWin.
|
||||
//! A gamescope session is its own compositor: nothing on either owning route (bare spawn, managed
|
||||
//! takeover) touches the desktop the box is showing, so the physical panel keeps displaying the
|
||||
//! (idle) desktop for the whole stream — while the same `exclusive` policy on a *desktop* backend
|
||||
//! turns the physicals off outright. That backend's mechanism is closed to us here: a compositor
|
||||
//! refuses an output configuration with ZERO enabled outputs, and a gamescope session has no
|
||||
//! output of its own on that desktop to leave enabled.
|
||||
//!
|
||||
//! Driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the same
|
||||
//! stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a separate
|
||||
//! libkscreen/KDED layer that can be wedged while KWin itself answers fine), with a
|
||||
//! `kscreen-doctor --dpms` shell-out fallback. Best-effort everywhere — a box with no Wayland
|
||||
//! session, or a non-KDE desktop, declines quietly and the stream proceeds with the panel lit,
|
||||
//! exactly as before this module existed.
|
||||
//! DPMS is the honest translation. The desk stays exactly where it is — no topology churn, no
|
||||
//! workspace moves, no window re-homing — the panels just go dark, and any LOCAL input wakes them,
|
||||
//! which is the right answer for a desktop someone can walk up to. Stream input never wakes them:
|
||||
//! it is injected into the nested gamescope's own EIS socket and never reaches the desktop.
|
||||
//!
|
||||
//! **There is no cross-compositor DPMS protocol**, so this module is a dispatcher. In order, each
|
||||
//! arm self-gating so a box only pays for the one that answers:
|
||||
//!
|
||||
//! | desktop | mechanism |
|
||||
//! |---|---|
|
||||
//! | KDE / KWin | in-process `org_kde_kwin_dpms`, then a `kscreen-doctor --dpms` shell-out |
|
||||
//! | sway (wlroots) | `swaymsg output <name> dpms off` ([`crate::wlroots::dpms_other_heads`]) |
|
||||
//! | Hyprland | its dpms dispatcher, read-modify-verify ([`crate::hyprland::dpms_other_heads`]) |
|
||||
//! | none at all | [`crate::drm_dpms`] — the CRTCs off over DRM, no compositor needed |
|
||||
//! | GNOME / Mutter | **cannot be served** — see below |
|
||||
//!
|
||||
//! KDE is driven in-process over the compositor's own Wayland (`Connection::connect_to_env`, the
|
||||
//! same stack as [`crate::kwin_output_mgmt`] and for the same reason: `kscreen-doctor` rides a
|
||||
//! separate libkscreen/KDED layer that can be wedged while KWin itself answers fine). sway and
|
||||
//! Hyprland are driven through their own native IPC, which is how [`crate::wlroots`] and
|
||||
//! [`crate::hyprland`] already drive them — no second layer to be wedged, so no in-process twin
|
||||
//! is warranted.
|
||||
//!
|
||||
//! Neither of those two is as simple as "send the off command", and the Hyprland one especially
|
||||
//! is not: its dpms dispatcher is a **toggle** that ignores the state word (measured on 0.55.4 —
|
||||
//! asking for `on` turned a lit head OFF), and the classic argv does not even parse under its Lua
|
||||
//! config manager. [`crate::hyprland::dpms_other_heads`] carries the full account; the contract
|
||||
//! this module depends on is only that each arm returns **the heads it actually changed**, so the
|
||||
//! re-light moves exactly those and never a head it did not darken.
|
||||
//!
|
||||
//! The DRM arm is not an afterthought: a box sitting in Game Mode runs gamescope and NO desktop
|
||||
//! compositor, and it is *exactly* the deployment whose TV the operator wants dark.
|
||||
//!
|
||||
//! ⚠ **GNOME is the one gap, and it is structural.** Mutter exposes no DPMS to clients at all, and
|
||||
//! its `exclusive` mechanism (an `ApplyMonitorsConfig` that omits the physicals) needs a virtual
|
||||
//! output of its own to keep enabled — which a gamescope session, being its own compositor, does
|
||||
//! not have. The DRM floor cannot cover it either: Mutter holds DRM master, so `SET_MASTER` is
|
||||
//! refused. [`darken`] says so at `warn!` rather than failing silently.
|
||||
//!
|
||||
//! This module owns the refcount and the hold for every arm — see [`Darkened`] for how each is
|
||||
//! undone.
|
||||
//!
|
||||
//! **The hold is refcounted here, NOT floated through the registry's per-group restore.** Every
|
||||
//! gamescope spawn is its own display group (`registry::group_key` — deliberately, they are
|
||||
@@ -433,6 +464,16 @@ enum Darkened {
|
||||
/// The `kscreen-doctor --dpms off` fallback ran (it takes no per-output address, so the
|
||||
/// re-light is the symmetric `--dpms on`).
|
||||
Kscreen,
|
||||
/// sway (wlroots) turned these outputs off — `swaymsg output <name> dpms off`. Addressed by
|
||||
/// connector name, so the re-light undoes exactly the heads we changed and never a sibling's.
|
||||
Sway(Vec<String>),
|
||||
/// Hyprland turned these monitors off — `hyprctl dispatch dpms off <name>`. Same per-name
|
||||
/// discipline as [`Darkened::Sway`], and the same reason.
|
||||
Hyprland(Vec<String>),
|
||||
/// No desktop to ask, so [`crate::drm_dpms`] turned the CRTCs off over DRM directly. The
|
||||
/// re-light is a `drop` — the hold IS a set of open `/dev/dri/cardN` fds, and the kernel
|
||||
/// re-lights on last close. Nothing to replay, and crash-safe for the same reason.
|
||||
Drm(crate::drm_dpms::DrmDarken),
|
||||
}
|
||||
|
||||
/// The host-wide darken hold — refcounted like `sleep_inhibit`: the 0→1 edge darkens, the 1→0
|
||||
@@ -505,9 +546,42 @@ pub fn release_stream_darken() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The non-KDE desktops we can ask, in preference order. Each self-gates on its own IPC being
|
||||
/// reachable — `wlroots::dpms_other_heads` shells out to `swaymsg`, which needs `SWAYSOCK`;
|
||||
/// Hyprland's needs `HYPRLAND_INSTANCE_SIGNATURE` — so a box only ever pays for the one that
|
||||
/// answers, and a box running neither falls straight through.
|
||||
///
|
||||
/// Both address heads BY NAME and report back the ones they actually changed, so the re-light
|
||||
/// undoes exactly those and never a concurrent session's headless output.
|
||||
///
|
||||
/// **GNOME is absent on purpose.** Mutter exposes no DPMS to clients at all, and its `exclusive`
|
||||
/// mechanism (`ApplyMonitorsConfig` omitting the physicals) needs a virtual output of its own to
|
||||
/// keep enabled — which a gamescope spawn, being its own compositor, does not have. There is
|
||||
/// nothing to call; the `warn!` at the end of [`darken`] names it rather than failing silently.
|
||||
fn non_kde_desktop_darken() -> Option<Darkened> {
|
||||
let sway = crate::wlroots::dpms_other_heads(false);
|
||||
if !sway.is_empty() {
|
||||
tracing::info!(
|
||||
outputs = ?sway,
|
||||
"sway: desktop outputs off for the exclusive gamescope stream"
|
||||
);
|
||||
return Some(Darkened::Sway(sway));
|
||||
}
|
||||
let hypr = crate::hyprland::dpms_other_heads(false);
|
||||
if !hypr.is_empty() {
|
||||
tracing::info!(
|
||||
outputs = ?hypr,
|
||||
"hyprland: desktop monitors off for the exclusive gamescope stream"
|
||||
);
|
||||
return Some(Darkened::Hyprland(hypr));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The 0→1 darken: in-process over `org_kde_kwin_dpms` first, `kscreen-doctor --dpms off` as the
|
||||
/// wedged-compositor fallback. `None` = nothing was darkened (no desktop, not KDE, panels already
|
||||
/// off, or every arm declined) — and therefore nothing to restore.
|
||||
/// wedged-compositor fallback, then the other desktops, then DRM. `None` = nothing was darkened
|
||||
/// (no desktop that answers, panels already off, or every arm declined) — and therefore nothing to
|
||||
/// restore.
|
||||
fn darken() -> Option<Darkened> {
|
||||
match Session::open("darken") {
|
||||
Ok(mut s) => {
|
||||
@@ -526,8 +600,49 @@ fn darken() -> Option<Darkened> {
|
||||
}
|
||||
}
|
||||
// Definitive "not KDE" / "no desktop": no fallback can do better (kscreen-doctor drives
|
||||
// the same KDE-only machinery), so decline quietly — already logged by `open`.
|
||||
Err(OpenFailure::NoDpmsGlobal) | Err(OpenFailure::Connect(_)) => None,
|
||||
// the same KDE-only machinery). Declining is still right — but NOT quietly. [`darken`] is
|
||||
// only ever reached because the operator selected `Topology::Exclusive`, so every decline
|
||||
// here is "you asked for your screens off and they stayed on", which is a verdict and not
|
||||
// a routine state. It sat at `debug!` in `open`, and that silence is what made the Nobara
|
||||
// field report (2026-08-24) undiagnosable: no line anywhere named the panel. Same
|
||||
// discipline as [`relight`], which has always said so when it gave up — a lit panel under
|
||||
// `exclusive` deserves the honesty a dark one already got.
|
||||
Err(e @ (OpenFailure::NoDpmsGlobal | OpenFailure::Connect(_))) => {
|
||||
// Not KDE. Try the other desktops we drive, then the compositor-independent floor.
|
||||
// Each arm self-gates on its own IPC being reachable, so the order is just preference
|
||||
// and a box only ever pays for the ones that answer.
|
||||
if let Some(d) = non_kde_desktop_darken() {
|
||||
return Some(d);
|
||||
}
|
||||
match crate::drm_dpms::darken() {
|
||||
Some(d) => {
|
||||
tracing::info!(
|
||||
cards = ?d.darkened,
|
||||
"DRM: the box's own CRTCs are off for the exclusive gamescope stream (no \
|
||||
desktop compositor to ask — a session in Game Mode has none)"
|
||||
);
|
||||
Some(Darkened::Drm(d))
|
||||
}
|
||||
// Nothing on this box was ours to darken: no desktop that answers, and then no
|
||||
// `/dev/dri` card that was ours either — every one already mastered by someone
|
||||
// else (a live compositor, including the gamescope an Attach route is mirroring,
|
||||
// which must NOT be darkened), or nothing lit. Say so: `darken` is only ever
|
||||
// reached because the operator selected `Topology::Exclusive`, so this is "you
|
||||
// asked for your screens off and they stayed on" — a verdict, not a routine
|
||||
// state. It sat at `debug!` in `open`, and that silence is what made the Nobara
|
||||
// field report (2026-08-24) undiagnosable: no line anywhere named the panel.
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%e,
|
||||
"exclusive topology asked for the box's own screens to go dark: no \
|
||||
desktop compositor on this box could be asked (GNOME/Mutter exposes no \
|
||||
DPMS to clients), and no DRM card was ours to turn off either — the \
|
||||
panel stays as it is for this stream"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
// A live session that stopped answering: the standalone tool rides a different stack
|
||||
// (libkscreen/KDED) and may still get through — the same rationale as `kwin.rs`'s
|
||||
// kscreen fallbacks, honest-verdict discipline included.
|
||||
@@ -594,6 +709,41 @@ fn relight(d: Darkened) {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Per-name, so exactly the heads we darkened come back and a sibling's headless output is
|
||||
// never switched on by us. A head the operator unplugged meanwhile just fails its one
|
||||
// command and says so — the others still re-light.
|
||||
Darkened::Sway(outputs) => {
|
||||
let back = crate::wlroots::dpms_other_heads(true);
|
||||
if back.is_empty() {
|
||||
tracing::error!(
|
||||
?outputs,
|
||||
"sway: could NOT re-light the desktop outputs — they stay dark until local \
|
||||
input or `swaymsg output '*' dpms on`"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(outputs = ?back, "sway: desktop outputs back on");
|
||||
}
|
||||
}
|
||||
Darkened::Hyprland(outputs) => {
|
||||
let back = crate::hyprland::dpms_other_heads(true);
|
||||
if back.is_empty() {
|
||||
tracing::error!(
|
||||
?outputs,
|
||||
"hyprland: could NOT re-light the desktop monitors — they stay dark until \
|
||||
local input or `hyprctl dispatch dpms on`"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(outputs = ?back, "hyprland: desktop monitors back on");
|
||||
}
|
||||
}
|
||||
// The one arm that cannot fail: the hold IS the open fds, so dropping it closes them and
|
||||
// the kernel's last-close restores the console. No ioctl to be refused, no saved mode to
|
||||
// replay — which is why this path needs no "could NOT re-light" line of its own.
|
||||
Darkened::Drm(d) => {
|
||||
let cards = d.darkened.clone();
|
||||
drop(d);
|
||||
tracing::info!(?cards, "DRM: the box's own CRTCs released — panel back on");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +491,45 @@ fn disable_argv(name: &str) -> [&str; 3] {
|
||||
["output", name, "disable"]
|
||||
}
|
||||
|
||||
/// The `swaymsg` argv that DPMS-es `name` off or on. Same noun-first shape as [`disable_argv`],
|
||||
/// and a different axis from it: `dpms off` leaves the output enabled and configured (its
|
||||
/// workspaces do not move, no window is re-homed) and merely stops driving the panel.
|
||||
fn dpms_argv(name: &str, on: bool) -> [&str; 4] {
|
||||
["output", name, "dpms", if on { "on" } else { "off" }]
|
||||
}
|
||||
|
||||
/// DPMS every head that is not ours and not a sibling's off (or back on), for a **gamescope**
|
||||
/// session honoring `Topology::Exclusive` — see [`crate::panel_dpms`].
|
||||
///
|
||||
/// Distinct from [`disable_other_heads`], which is what the *wlroots backend's own* exclusive
|
||||
/// topology does. A gamescope spawn is its own compositor and owns no sway output, so there is
|
||||
/// nothing here to promote to "the desk" and nothing to focus — and disabling the operator's
|
||||
/// outputs would move their workspaces around for a stream that is not even on this compositor.
|
||||
/// DPMS is the honest translation: the desk stays exactly as it is, the panels just go dark.
|
||||
///
|
||||
/// Reuses [`heads_to_disable`]'s filter with an empty `ours`, so a concurrent wlroots session's
|
||||
/// `HEADLESS-*` output is spared for the same reason it is there — blanking it would black out
|
||||
/// that client's stream.
|
||||
///
|
||||
/// Returns the heads actually changed, so the re-light can undo exactly those. Best-effort per
|
||||
/// head, like its neighbour: one that refuses costs a lit screen, not the stream.
|
||||
pub(crate) fn dpms_other_heads(on: bool) -> Vec<String> {
|
||||
let Ok(heads) = list_monitors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut changed = Vec::new();
|
||||
for name in heads_to_disable(&heads, "") {
|
||||
match swaymsg(&dpms_argv(&name, on)) {
|
||||
Ok(_) => changed.push(name),
|
||||
Err(e) => tracing::warn!(
|
||||
output = %name, error = %format!("{e:#}"),
|
||||
"wlroots: could not DPMS this output for `topology: exclusive`"
|
||||
),
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The `swaymsg` argv that re-enables `name`. sway keeps a disabled output's configuration, so a
|
||||
/// bare `enable` restores the mode/position/scale it had — there is no need to replay the rule the
|
||||
/// way the Hyprland twin's `reload` does.
|
||||
@@ -1060,6 +1099,30 @@ mod tests {
|
||||
assert_eq!(heads_to_disable(&heads, ours), vec!["DP-1", "HDMI-A-1"]);
|
||||
}
|
||||
|
||||
/// `dpms` is a different sway verb from `disable`, and the difference is the whole point of
|
||||
/// the gamescope arm: `disable` moves workspaces and re-homes windows on the operator's desk,
|
||||
/// `dpms off` leaves the desk alone and only stops driving the panel. Four tokens, not three —
|
||||
/// sway spells it `output <name> dpms on|off`.
|
||||
#[test]
|
||||
fn dpms_is_a_separate_verb_from_disable() {
|
||||
assert_eq!(dpms_argv("DP-1", false), ["output", "DP-1", "dpms", "off"]);
|
||||
assert_eq!(dpms_argv("DP-1", true), ["output", "DP-1", "dpms", "on"]);
|
||||
assert_eq!(disable_argv("DP-1"), ["output", "DP-1", "disable"]);
|
||||
}
|
||||
|
||||
/// The gamescope DPMS arm reuses the disable filter with an EMPTY `ours`: a gamescope spawn
|
||||
/// owns no sway output, so nothing of ours needs sparing — but a concurrent wlroots session's
|
||||
/// `HEADLESS-*` still must be, or darkening would black out that client's stream.
|
||||
#[test]
|
||||
fn the_gamescope_dpms_arm_still_spares_a_sibling_headless() {
|
||||
let heads = [
|
||||
head("DP-1", true),
|
||||
head("HEADLESS-1", true),
|
||||
head("DP-3", false),
|
||||
];
|
||||
assert_eq!(heads_to_disable(&heads, ""), vec!["DP-1"]);
|
||||
}
|
||||
|
||||
/// A box with no physical output (the CI/headless posture) has nothing to disable, so no
|
||||
/// restore is prepared and teardown touches nothing.
|
||||
#[test]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -419,6 +419,29 @@ pub fn serve(
|
||||
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
|
||||
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
|
||||
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
// and the management API) always exists.
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
//
|
||||
// Resolved BEFORE the legacy GameStream identity below, and that order is load-bearing twice
|
||||
// over. (1) The web console gates its start on `cert.pem` existing and then serves the native
|
||||
// pair sitting next to it (web/nitro-entry/tls-paths.mjs); minting the legacy pair first
|
||||
// leaves a first-run window where the console starts, finds no native pair, and serves the
|
||||
// SAN-less RSA cert no browser accepts — for the rest of that boot. Running first closes that
|
||||
// window: whenever this call WRITES a native pair, it has done so before `cert.pem` appears.
|
||||
// (It does not write one on an upgraded host whose native clients pinned the legacy cert —
|
||||
// there the console correctly falls back to that same legacy pair.) (2) In the degenerate case
|
||||
// (native clients paired, but the cert they pinned is gone from disk) the old order let
|
||||
// `load_or_create` mint a BRAND-NEW cert.pem that `load_or_adopt` then adopted while logging
|
||||
// that it was preserving their pins — stranding them silently. Reading the dir first means
|
||||
// that case reaches the branch written for it.
|
||||
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
|
||||
#[cfg(feature = "gamestream")]
|
||||
let state = {
|
||||
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
|
||||
@@ -426,20 +449,10 @@ pub fn serve(
|
||||
};
|
||||
#[cfg(not(feature = "gamestream"))]
|
||||
let state = Arc::new(AppState::new(host, stats.clone()));
|
||||
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
|
||||
// and the management API) always exists.
|
||||
let np = Arc::new(
|
||||
crate::native_pairing::NativePairing::load_with(None, None, false)
|
||||
.context("native pairing store")?,
|
||||
);
|
||||
// WP13: hand the GameStream planes the grants registry — the nvhttp launch surface and the
|
||||
// ENet control thread resolve a Moonlight fingerprint's mask against the same registry the
|
||||
// native plane enforces (design §8: it keys on fingerprint hex and serves both stores).
|
||||
let _ = state.access.set(np.clone());
|
||||
// The identity the native QUIC plane and the mgmt API present (the identity split): P-256 on
|
||||
// hosts no native client ever pinned, the legacy RSA cert otherwise — resolved ONCE here so
|
||||
// the two planes cannot race the first-run adoption. See `crate::identity`.
|
||||
let native_ident = crate::identity::load_or_adopt(&np).context("native host identity")?;
|
||||
tracing::info!(
|
||||
hostname = %state.host.hostname,
|
||||
uniqueid = %state.host.uniqueid,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,7 +1091,10 @@ fn spawn_web(cfg: &WebConfig, data: &Path, job: HANDLE) -> Result<Child> {
|
||||
// The /api proxy hop to the host's loopback HTTPS mgmt API. The host's self-signed cert is
|
||||
// accepted only inside the proxy code (per-request TLS), never process-wide.
|
||||
("PUNKTFUNK_MGMT_URL", mgmt_url),
|
||||
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure.
|
||||
// Serve HTTPS with the host's own identity cert; mark the session cookie Secure. Names the
|
||||
// LEGACY pair — the console prefers the native sibling when it exists
|
||||
// (web/nitro-entry/tls-paths.mjs), which is also what the gate above ends up waiting for:
|
||||
// `serve` resolves the native identity before minting this one.
|
||||
(
|
||||
"PUNKTFUNK_UI_TLS_CERT",
|
||||
data.join("cert.pem").to_string_lossy().into_owned(),
|
||||
|
||||
@@ -208,7 +208,24 @@ fn poll_loop(
|
||||
// that proves the server is answering, and the agent below refuses redirects so the probe is
|
||||
// exactly one round trip. (A 302 still counts as up via the `Status` arm in `probe_console`.)
|
||||
let console_url = format!("https://127.0.0.1:{web_port}/login");
|
||||
let agent = agent(load_pin());
|
||||
// Named, not `agent`: shadowing the fn (as this did while there was only one agent) would make
|
||||
// the second call below resolve to this binding instead.
|
||||
let mgmt_agent = agent(load_pin());
|
||||
// The console probe gets its OWN, UNPINNED agent. It is a different server from the mgmt API
|
||||
// and there is no rule that it presents the same certificate: it served the legacy `cert.pem`
|
||||
// while mgmt served the native one (the identity split), so the pinned agent refused the
|
||||
// handshake and every identity-split host showed "Open web console (not responding)" over a
|
||||
// perfectly healthy console — next to a tooltip reading "idle", because the same agent reached
|
||||
// mgmt fine (field report 2026-08-24). An operator fronting the console with their own LAN-CA
|
||||
// cert would have hit it just as squarely, so the coupling goes rather than the symptom.
|
||||
//
|
||||
// Nothing is lost by dropping the pin: this probe sends no credentials, reads no body, and
|
||||
// decides only presentation — the menu entry's label, plus whether a tray-icon click opens
|
||||
// the console or the menu (win.rs). A port-squatter could flip that, but the entry itself is
|
||||
// unconditional and opens the same URL either way, and no browser ever pinned this cert. On
|
||||
// Windows the probe was never pinned to begin with: `punktfunk_config_dir` returns None there,
|
||||
// so `load_pin` was already None.
|
||||
let console_agent = agent(None);
|
||||
let mut last: Option<(TrayStatus, bool)> = None;
|
||||
// When the summary became unreachable while the service was running (grace anchor).
|
||||
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
|
||||
@@ -220,7 +237,7 @@ fn poll_loop(
|
||||
loop {
|
||||
let svc = probe_service();
|
||||
let summary = if svc == ServiceState::Running {
|
||||
let s = fetch_summary(&agent, &summary_url());
|
||||
let s = fetch_summary(&mgmt_agent, &summary_url());
|
||||
match s {
|
||||
Some(_) => unreachable_since = None,
|
||||
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
|
||||
@@ -233,7 +250,7 @@ fn poll_loop(
|
||||
};
|
||||
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
|
||||
let status = map_status(&svc, summary, grace_expired);
|
||||
let console_up = if probe_console(&agent, &console_url) {
|
||||
let console_up = if probe_console(&console_agent, &console_url) {
|
||||
console_misses = 0;
|
||||
true
|
||||
} else {
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -136,7 +136,24 @@ PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
|
||||
|
||||
The host then attaches to the session's own composited output: nothing is stopped, nothing is
|
||||
relaunched, no mode is imposed, and what you see is exactly what is on the TV. That is the
|
||||
difference from **managed**, which deliberately takes the session over and blanks the panel.
|
||||
difference from **managed**, which deliberately takes the session over and relaunches it headless,
|
||||
so the box's own session stops driving that panel.
|
||||
|
||||
Whether the panel then goes *dark* is the **Topology** setting's job, not the model's — see
|
||||
[Virtual displays](/docs/virtual-displays#topology). What `exclusive` can do differs by model:
|
||||
|
||||
- **Managed** and **bare spawn** — the box's own gaming session is moved out of the way so it
|
||||
stops driving the panel, and then the panel is actually turned **off** for the stream and
|
||||
restored at teardown. On a KDE desktop that goes through KWin's DPMS; on a box already in Game
|
||||
Mode there is no KWin to ask, so the host turns the CRTCs off over DRM itself. Neither needs
|
||||
root — the DRM path rides the same seat access every local compositor gets.
|
||||
- **Attach** — nothing is darkened, and cannot be: this model streams the panel the box is
|
||||
driving, so turning it off would turn off the picture.
|
||||
|
||||
Under `extend` or `primary` none of this happens and your screens are left alone. If `exclusive`
|
||||
asked for a dark screen and the host could not deliver one — a box already in Game Mode has no
|
||||
KDE desktop to ask for DPMS — it says so in the log rather than leaving you guessing at a lit
|
||||
screen.
|
||||
|
||||
Only the one head the session drives is listed — a nested or headless gamescope (including the
|
||||
per-session ones the host spawns itself) has none of its own, so the picker is empty there. Full
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -667,6 +667,11 @@ in
|
||||
HOST = "0.0.0.0";
|
||||
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
|
||||
# mark the session cookie Secure. The host's `serve` writes these PEMs.
|
||||
#
|
||||
# These name the LEGACY pair; the server prefers the native sibling
|
||||
# (native-cert.pem/native-key.pem) when it exists, because a generated unit cannot express
|
||||
# "this file, else that one" any more than the hand-written one can. The choice is made in
|
||||
# web/nitro-entry/tls-paths.mjs — keep this in step with scripts/punktfunk-web.service.
|
||||
PUNKTFUNK_UI_TLS_CERT = "%h/.config/punktfunk/cert.pem";
|
||||
PUNKTFUNK_UI_TLS_KEY = "%h/.config/punktfunk/key.pem";
|
||||
PUNKTFUNK_UI_SECURE = "1";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
#
|
||||
# Installed by the punktfunk-web .deb to /usr/lib/systemd/user/. AUTO-WIRED — no env editing:
|
||||
# it sources the host's mgmt token + the generated login password, serves HTTPS (HTTP/1.1 over TLS)
|
||||
# with the host's own identity cert (~/.config/punktfunk/{cert,key}.pem), and points the /api proxy
|
||||
# with the host's own identity cert (~/.config/punktfunk/native-{cert,key}.pem, falling back to the
|
||||
# legacy {cert,key}.pem — see the PUNKTFUNK_UI_TLS_CERT note below), and points the /api proxy
|
||||
# at the host's loopback HTTPS mgmt API. The self-signed cert is accepted only for that loopback hop,
|
||||
# scoped inside the proxy code (Bun per-request TLS) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
|
||||
# Enable per user:
|
||||
@@ -39,6 +40,12 @@ Environment=HOST=0.0.0.0
|
||||
# Serve HTTPS (HTTP/1.1 over TLS) with the host's own identity cert; mark the
|
||||
# session cookie Secure. The host's `serve` writes these PEMs; if absent at start the unit fails and
|
||||
# Restart retries (same as the mgmt-token wait above) rather than silently serving plain HTTP.
|
||||
#
|
||||
# These name the LEGACY pair and the server prefers the native sibling
|
||||
# (native-cert.pem/native-key.pem) whenever it exists — `Environment=` cannot express "this file,
|
||||
# else that one", so the choice is made in web/nitro-entry/tls-paths.mjs, which is the one place
|
||||
# every launcher routes through. Don't "fix" these to the native names: a host that never took the
|
||||
# identity split has no native pair, and the fallback lives on the other side of this handoff.
|
||||
Environment=PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
|
||||
Environment=PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
|
||||
Environment=PUNKTFUNK_UI_SECURE=1
|
||||
|
||||
@@ -58,6 +58,8 @@ PORT=47992 HOST=0.0.0.0 \
|
||||
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
|
||||
bun run start # = bun run .output/server/index.mjs
|
||||
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
|
||||
# Naming cert.pem/key.pem serves native-cert.pem/native-key.pem instead when both sit beside them
|
||||
# (the identity split — nitro-entry/tls-paths.mjs); the legacy pair is the fallback, not the target.
|
||||
# The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code
|
||||
# (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
|
||||
# See .env.example.
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//
|
||||
// NOTE on HTTP/2 + HTTP/3: NOT offered here, on purpose. `Bun.serve` has no HTTP/2 server, and
|
||||
// HTTP/3 (which Bun *can* do) is useless to a browser against this cert: QUIC refuses any cert error,
|
||||
// and the host identity cert is a CN-only, no-SAN, self-signed cert (correct for native fingerprint
|
||||
// PINNING, rejected by browsers). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
|
||||
// and the host identity is SELF-SIGNED whichever pair we serve — the native one carries real SANs, so
|
||||
// a browser gets past the name check, but never past the untrusted issuer (and the legacy fallback is
|
||||
// CN-only with no SAN, which fails both). So browsers stay on HTTP/1.1 regardless — advertising h3 would just
|
||||
// dangle an `Alt-Svc` no browser can use. Real h2/h3 would need a browser-TRUSTED, SAN-matching cert
|
||||
// (a local CA installed per device) fronted by a server that speaks them (e.g. Caddy) — deliberately
|
||||
// out of scope for a LAN console; TLS (no cleartext login/session) is the win.
|
||||
@@ -17,14 +18,16 @@
|
||||
// TWO LISTENERS, on purpose — see `PLUGIN ORIGIN` below.
|
||||
//
|
||||
// Env (set by the launchers / the systemd unit — see web.env.example):
|
||||
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem). BOTH set ⇒ HTTPS.
|
||||
// Unset ⇒ plain HTTP (local dev only).
|
||||
// PUNKTFUNK_UI_TLS_CERT / _KEY PEM file paths (the host's cert.pem / key.pem — the native
|
||||
// sibling pair is preferred when present, see tls-paths.mjs).
|
||||
// BOTH set ⇒ HTTPS. Unset ⇒ plain HTTP (local dev only).
|
||||
// PORT / HOST standard Nitro bind (3000 / 0.0.0.0).
|
||||
// PUNKTFUNK_UI_PLUGIN_PORT the plugin-UI origin's port (default: console port + 1).
|
||||
import "#nitro-internal-pollyfills";
|
||||
import wsAdapter from "crossws/adapters/bun";
|
||||
import { useNitroApp } from "nitropack/runtime";
|
||||
import { startScheduleRunner } from "nitropack/runtime/internal";
|
||||
import { resolveUiTlsPaths } from "./tls-paths.mjs";
|
||||
|
||||
const nitroApp = useNitroApp();
|
||||
const ws = import.meta._websocket
|
||||
@@ -75,8 +78,15 @@ const PEER_IP_HEADER = "x-pf-peer-ip";
|
||||
const LISTENER_HEADER = "x-pf-listener";
|
||||
|
||||
// TLS from the host's identity cert (file PATHS → Bun.file, not PEM-in-env). Absent ⇒ plain HTTP.
|
||||
const certPath = process.env.PUNKTFUNK_UI_TLS_CERT;
|
||||
const keyPath = process.env.PUNKTFUNK_UI_TLS_KEY;
|
||||
//
|
||||
// The launchers all name the LEGACY cert.pem/key.pem pair and cannot express a fallback, so the
|
||||
// choice between the host's two identities is made here — see tls-paths.mjs for why the native
|
||||
// pair is the right one to serve (SANs a browser accepts; the cert the tray and native clients
|
||||
// already pin).
|
||||
const { cert: certPath, key: keyPath } = resolveUiTlsPaths(
|
||||
process.env.PUNKTFUNK_UI_TLS_CERT,
|
||||
process.env.PUNKTFUNK_UI_TLS_KEY,
|
||||
);
|
||||
const tls =
|
||||
certPath && keyPath
|
||||
? { cert: Bun.file(certPath), key: Bun.file(keyPath) }
|
||||
@@ -126,7 +136,8 @@ const listenerOptions = (lane) => ({
|
||||
// is a hooks/library JSON edit, kilobytes. 4 MiB leaves several orders of headroom and still
|
||||
// makes the memory cost of an unauthenticated request negligible.
|
||||
maxRequestBodySize:
|
||||
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) || 4 * 1024 * 1024,
|
||||
Number.parseInt(process.env.NITRO_BUN_MAX_BODY_BYTES, 10) ||
|
||||
4 * 1024 * 1024,
|
||||
// `tls: undefined` ⇒ plain HTTP (dev); otherwise HTTPS over HTTP/1.1.
|
||||
tls,
|
||||
websocket: import.meta._websocket ? ws.websocket : undefined,
|
||||
@@ -167,7 +178,9 @@ console.log(`punktfunk web console listening on ${server.url} (tls=${!!tls})`);
|
||||
// this exists to close, and a security boundary that disappears when a port is busy is not one. It
|
||||
// degrades to "plugin UIs unavailable": the console reads the state below and renders an
|
||||
// explanation instead of a frame, and everything else about the console keeps working.
|
||||
const pluginPort = Number(process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1);
|
||||
const pluginPort = Number(
|
||||
process.env.PUNKTFUNK_UI_PLUGIN_PORT || consolePort + 1,
|
||||
);
|
||||
let pluginServer;
|
||||
try {
|
||||
pluginServer = Bun.serve({ ...listenerOptions("plugin"), port: pluginPort });
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Which of the host's two identities the console serves — resolved HERE because this entry is the
|
||||
// one place every launcher routes through.
|
||||
//
|
||||
// The host keeps two identities side by side (crate::identity, the "identity split"):
|
||||
//
|
||||
// native-cert.pem / native-key.pem ECDSA P-256, with real SANs (the machine hostname,
|
||||
// localhost, 127.0.0.1, ::1). This is what the native QUIC
|
||||
// plane and the management API present, and what native
|
||||
// clients pin.
|
||||
// cert.pem / key.pem the legacy RSA GameStream identity: CN=punktfunk and NO SAN
|
||||
// at all (gamestream::cert::generate passes rcgen an empty SAN
|
||||
// list), kept byte-stable because Moonlight pins it and the
|
||||
// pairing hashes bind its X.509 signature bytes.
|
||||
//
|
||||
// Every launcher names the LEGACY pair — scripts/punktfunk-web.service, the NixOS module, the
|
||||
// Windows service supervisor, web-run.cmd, the Steam Deck installer — because they were written
|
||||
// before the split, and none of them CAN choose: systemd `Environment=` has no "this file, else
|
||||
// that one". Serving the legacy pair costs twice:
|
||||
//
|
||||
// * a CN-only, SAN-less cert is rejected outright by every current browser
|
||||
// (ERR_CERT_COMMON_NAME_INVALID / SSL_ERROR_BAD_CERT_DOMAIN), so the console the operator was
|
||||
// told to open does not load;
|
||||
// * the tray's loopback liveness probe pins whatever the mgmt API serves — the NATIVE cert — so
|
||||
// the handshake is refused and a perfectly healthy console is labelled "Open web console (not
|
||||
// responding)" while the host beside it reads "idle" (field report 2026-08-24).
|
||||
//
|
||||
// So prefer the native sibling. It is also the smaller secret to hand a bundled bun: on a default
|
||||
// build key.pem is the Moonlight PAIRING SIGNING key, native-key.pem is only a TLS key.
|
||||
//
|
||||
// Swapped as a PAIR or not at all — a native cert with the legacy key is a server that cannot
|
||||
// complete a handshake with anyone, so both halves must be present AND must come from the same
|
||||
// directory. A host that never took the split (upgraded, native clients still pinning the RSA cert,
|
||||
// so `load_or_adopt` keeps serving it) has no native pair on disk and falls through unchanged, as
|
||||
// does a cert an operator supplied under any other name.
|
||||
import { statSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* The directory prefix (separator included) of a path ending in `base`, or null if it does not.
|
||||
*
|
||||
* Deliberately NOT `node:path`: that resolves per-RUNTIME, so a POSIX build reads
|
||||
* `C:\ProgramData\punktfunk\cert.pem` as one long filename — and Windows, where the service
|
||||
* supervisor hands us exactly that (windows/service.rs), is the platform CI can never exercise.
|
||||
* A suffix test gives the same answer everywhere. It also leaves the prefix VERBATIM, where
|
||||
* `join(dirname(p), …)` would normalise `/a/b/../cert.pem` to a different directory than the one
|
||||
* the operator named — which matters the moment `b` is a symlink.
|
||||
*
|
||||
* @param {string} p
|
||||
* @param {string} base
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function dirPrefix(p, base) {
|
||||
if (p === base) return ""; // bare relative name
|
||||
if (!p.endsWith(base)) return null;
|
||||
const sep = p[p.length - base.length - 1];
|
||||
return sep === "/" || sep === "\\" ? p.slice(0, -base.length) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable, NON-EMPTY file. Emptiness matters: `pf_paths::write_secret_file` is
|
||||
* create+truncate+write rather than temp+rename, so a console starting mid-write could otherwise
|
||||
* adopt a 0-byte cert and leave `Bun.serve` throwing on every restart — and not every launcher
|
||||
* retries forever (the Steam Deck unit is `Restart=on-failure` under the default rate limit).
|
||||
*
|
||||
* @param {string} p
|
||||
*/
|
||||
function usable(p) {
|
||||
try {
|
||||
return statSync(p).size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | undefined} cert PUNKTFUNK_UI_TLS_CERT, verbatim.
|
||||
* @param {string | undefined} key PUNKTFUNK_UI_TLS_KEY, verbatim.
|
||||
* @param {(p: string) => boolean} [exists] injected by the test; defaults to a real stat.
|
||||
* @returns {{cert: string | undefined, key: string | undefined}}
|
||||
*/
|
||||
export function resolveUiTlsPaths(cert, key, exists = usable) {
|
||||
// Half-configured TLS is the caller's error to report (it refuses to start); don't mask it by
|
||||
// resolving one half of a pair that isn't there.
|
||||
if (!cert || !key) return { cert, key };
|
||||
const dir = dirPrefix(cert, "cert.pem");
|
||||
// Same directory, or we are not looking at a pair — see the PAIR note above.
|
||||
if (dir === null || dir !== dirPrefix(key, "key.pem")) return { cert, key };
|
||||
const nativeCert = `${dir}native-cert.pem`;
|
||||
const nativeKey = `${dir}native-key.pem`;
|
||||
return exists(nativeCert) && exists(nativeKey)
|
||||
? { cert: nativeCert, key: nativeKey }
|
||||
: { cert, key };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// The pair swap is all-or-nothing, and the fallbacks are what keep legacy and custom-cert hosts
|
||||
// serving. A native cert with the legacy key would be a console nobody can handshake with, so the
|
||||
// mixed cases are the ones worth pinning down — including the Windows shape, which the resolver
|
||||
// must get right without a win32 runtime to ask (see dirPrefix in tls-paths.mjs).
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { resolveUiTlsPaths } from "./tls-paths.mjs";
|
||||
|
||||
const DIR = "/home/you/.config/punktfunk";
|
||||
const legacy = [`${DIR}/cert.pem`, `${DIR}/key.pem`] as const;
|
||||
const native = [`${DIR}/native-cert.pem`, `${DIR}/native-key.pem`] as const;
|
||||
/** `exists` over a fixed set of usable files on disk. */
|
||||
const on =
|
||||
(...files: string[]) =>
|
||||
(p: string) =>
|
||||
files.includes(p);
|
||||
|
||||
describe("resolveUiTlsPaths", () => {
|
||||
it("prefers the native pair when both files are there", () => {
|
||||
expect(resolveUiTlsPaths(...legacy, on(...legacy, ...native))).toEqual({
|
||||
cert: native[0],
|
||||
key: native[1],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the legacy pair on a host that never took the identity split", () => {
|
||||
expect(resolveUiTlsPaths(...legacy, on(...legacy))).toEqual({
|
||||
cert: legacy[0],
|
||||
key: legacy[1],
|
||||
});
|
||||
});
|
||||
|
||||
it("never mixes halves when only one native file is usable", () => {
|
||||
for (const half of native) {
|
||||
expect(resolveUiTlsPaths(...legacy, on(...legacy, half))).toEqual({
|
||||
cert: legacy[0],
|
||||
key: legacy[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The Windows service supervisor hands us backslash paths (windows/service.rs); node:path on a
|
||||
// POSIX CI runner would read the whole thing as one filename and silently never swap.
|
||||
it("resolves Windows paths without a win32 runtime", () => {
|
||||
const win = ["C:\\ProgramData\\punktfunk", "D:\\pf"] as const;
|
||||
for (const d of win) {
|
||||
expect(
|
||||
resolveUiTlsPaths(`${d}\\cert.pem`, `${d}\\key.pem`, () => true),
|
||||
).toEqual({
|
||||
cert: `${d}\\native-cert.pem`,
|
||||
key: `${d}\\native-key.pem`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to pair halves from two different directories", () => {
|
||||
expect(resolveUiTlsPaths("/a/cert.pem", "/b/key.pem", () => true)).toEqual({
|
||||
cert: "/a/cert.pem",
|
||||
key: "/b/key.pem",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the prefix verbatim rather than normalising it away", () => {
|
||||
// `join(dirname(p), …)` would collapse this to /a/native-cert.pem — a different directory
|
||||
// the moment `b` is a symlink.
|
||||
expect(
|
||||
resolveUiTlsPaths("/a/b/../cert.pem", "/a/b/../key.pem", () => true),
|
||||
).toEqual({
|
||||
cert: "/a/b/../native-cert.pem",
|
||||
key: "/a/b/../native-key.pem",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an operator's own cert alone, native pair present or not", () => {
|
||||
// Also covers the endsWith trap: "mycert.pem" ends with "cert.pem" but is not one.
|
||||
for (const own of [
|
||||
[`${DIR}/lan-ca.pem`, `${DIR}/lan-ca.key`],
|
||||
[`${DIR}/mycert.pem`, `${DIR}/mykey.pem`],
|
||||
] as const) {
|
||||
expect(resolveUiTlsPaths(...own, on(...own, ...native))).toEqual({
|
||||
cert: own[0],
|
||||
key: own[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not re-swap a pair that already names the native files", () => {
|
||||
expect(resolveUiTlsPaths(...native, () => true)).toEqual({
|
||||
cert: native[0],
|
||||
key: native[1],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes a half-configured pair through for the entry to refuse", () => {
|
||||
expect(resolveUiTlsPaths(legacy[0], undefined, on(...native))).toEqual({
|
||||
cert: legacy[0],
|
||||
key: undefined,
|
||||
});
|
||||
expect(resolveUiTlsPaths(undefined, undefined, on(...native))).toEqual({
|
||||
cert: undefined,
|
||||
key: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"api:gen": "orval --config orval.config.ts",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "bun test server/",
|
||||
"test": "bun test server/ nitro-entry/",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"screenshots": "node tools/screenshots.mjs",
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ export default defineConfig({
|
||||
// stock self-listening entry for ours (`nitro-entry/bun-https.mjs`), which calls
|
||||
// `Bun.serve({ tls })` so the console is served over HTTPS (HTTP/1.1 over TLS) with the
|
||||
// host's own identity cert. (No HTTP/2 — Bun.serve has no h2 server — and no HTTP/3, which a
|
||||
// browser won't speak against this self-signed, no-SAN host cert.) Bun is the runtime
|
||||
// browser won't speak against a self-signed host cert.) Bun is the runtime
|
||||
// everywhere now — the Windows installer already bundles it, and the punktfunk-web .deb
|
||||
// vendors it (it can't be `node`: `Bun.serve` is a bun API). (dev `vite dev` is unaffected.)
|
||||
preset: "bun",
|
||||
|
||||
@@ -51,6 +51,9 @@ if exist "%ENDPOINTFILE%" for /f "usebackq tokens=1* delims==" %%A in ("%ENDPOIN
|
||||
rem No NODE_TLS_REJECT_UNAUTHORIZED: the host's self-signed cert is accepted only for the loopback
|
||||
rem proxy hop, scoped inside the proxy code (Bun per-request TLS), not process-wide.
|
||||
rem Serve HTTPS (HTTP/1.1 over TLS) with the host's identity cert; mark the session cookie Secure.
|
||||
rem These name the LEGACY pair; the server prefers native-cert.pem/native-key.pem beside them when
|
||||
rem both exist (the identity split - web\nitro-entry\tls-paths.mjs). Don't "fix" them to the native
|
||||
rem names: a host that never took the split has no native pair, and the fallback lives in there.
|
||||
set "PUNKTFUNK_UI_TLS_CERT=%CERTFILE%"
|
||||
set "PUNKTFUNK_UI_TLS_KEY=%KEYFILE%"
|
||||
set "PUNKTFUNK_UI_SECURE=1"
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ HOST=0.0.0.0
|
||||
|
||||
# Serve the console over HTTPS (HTTP/1.1 over TLS) with the host's own identity cert. BOTH paths
|
||||
# set ⇒ HTTPS. (No HTTP/2 or HTTP/3: Bun.serve has no HTTP/2 server, and a browser won't speak
|
||||
# HTTP/3/QUIC against this self-signed, no-SAN host cert — so HTTP/1.1 over TLS is what's offered.)
|
||||
# HTTP/3/QUIC against a self-signed host cert — so HTTP/1.1 over TLS is what's offered.)
|
||||
# Name the LEGACY pair below: the server prefers native-cert.pem/native-key.pem beside it when both
|
||||
# exist (nitro-entry/tls-paths.mjs), and falls back to these on a host that never took the split.
|
||||
PUNKTFUNK_UI_TLS_CERT=%h/.config/punktfunk/cert.pem
|
||||
PUNKTFUNK_UI_TLS_KEY=%h/.config/punktfunk/key.pem
|
||||
# Mark the session cookie Secure (required once served over TLS):
|
||||
|
||||
Reference in New Issue
Block a user