fix(pf-vdisplay): a managed launch blocked the shutdown restore that was meant to rescue it, and re-moding could flip the operator's own screen

The gamescope subsystem — the crate's largest and fastest-churning area, and the one the 2026-07-28
sweep predates most of.

* **`MANAGED_SESSION` was held across the ~90 s managed launch**, and the shutdown/idle restore
  blocks on that same lock — *after* it has already stopped our unit. So the display-manager restore
  never ran and the box was left with no session at all. `create_managed_session` now decides under
  the guard and acts outside it, re-acquiring only to store the result; `do_restore_tv_session`
  consumes the record in a short scope at the top. Same shape the SteamOS twin already used.

* **The physical-display guard was bypassed whenever no gamescope node happened to be published.**
  `if physical_display_connected() { if let Some(node) = find_gamescope_node() { … } }` fell through
  to `set-environment SCREEN_WIDTH/HEIGHT/CUSTOM_REFRESH_RATES` + `restart` when the node was
  momentarily absent — gamescope restarting between titles, or built without PipeWire — flipping the
  operator's own screen to the client's resolution and bouncing a DM-driven login session. The guard
  now refuses instead of falling through, and the forced `SCREEN_*` values (which were never unset,
  so every later session on the box inherited them) are tracked and `unset-environment`ed on restore.

* **`current_gamescope_output_size()` reported an arbitrary gamescope's `-W`/`-H`** — whichever
  `/proc` enumerated first — and four consumers treated it as this session's output size. It now
  answers only when every gamescope on the box agrees, and `None` ("cannot tell") when they differ.
  `heads.rs` no longer takes it at all: it reads the size off the DRM-backed argv it already
  selected. Its test previously passed `None`, which is why the hazard was invisible.

Resource and honesty fixes: the ATTACH path armed the box's own session-unit bind drop-in and no
in-process path ever removed it (now tracked and disarmed on both restore arms); `wait_for_node`
never called `try_wait`, so a gamescope that died at `vkCreateDevice` was polled for the full 15 s
and the error then blamed headless capture support; `do_restore_tv_session` deleted its crash-recovery
state *before* the unbounded work that state records, so a grace-period expiry in that window left
the DM down with nothing on disk to heal it; the SteamOS takeover's two failure arms never armed the
TV restore though the session-plus twin does; the TV-session restore logged success with the
`systemctl` status discarded; the `steam -shutdown` child was dropped un-reaped; and a managed
session that took nothing over was never persisted, so a host crash orphaned the transient unit.

Item 8.1: the unbounded `pw-dump`, `systemctl`, `loginctl` and `pkexec` calls in this subsystem now
go through `proc::{status_within, output_within}` with per-call budgets. `pw-dump` is polled from
three separate 45 s loops against the very daemon this file documents gamescope as head-blocking,
and until now a hang there pinned the session's stream thread forever.
This commit is contained in:
2026-08-11 09:22:09 +02:00
parent db65980979
commit b2c03f1904
4 changed files with 897 additions and 242 deletions
File diff suppressed because it is too large Load Diff
@@ -5,10 +5,18 @@
use super::*;
/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
/// — the adapter and the inner stream — and only the advertised id is the correct capture
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
/// Budget for a `pw-dump` snapshot. Two facts make an unbounded one the worst call in this file:
/// it is polled every 300500 ms from three separate 45 s loops, and it talks to the very daemon
/// this module documents gamescope as head-blocking below [`MIN_GAMESCOPE`] — so the failure mode
/// is not "slow", it is "never returns", on the session's own stream thread. Two seconds is far
/// above a populated graph's real cost; every caller already has a "couldn't ask" path.
const PW_DUMP_BUDGET: Duration = Duration::from_secs(2);
/// Budget for a `gamescope --version` probe. It loads the binary and prints a banner — no Vulkan
/// device, no daemon — so anything approaching this bound is a binary that cannot run at all,
/// which is exactly what a `None`/`false` answer means to each caller.
const VERSION_PROBE_BUDGET: Duration = Duration::from_secs(2);
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
@@ -159,16 +167,51 @@ pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
}
}
/// Wait for a freshly spawned gamescope to report its PipeWire node. Authoritative source:
/// gamescope's own log line `stream available on node ID: N` (its node carries
/// `node.name=gamescope` on TWO objects — the adapter and the inner stream — and only the
/// advertised id is the correct capture target). Falls back, at the deadline, to `pw-dump`
/// discovery SCOPED to this spawn's process tree (`child`'s pid, A5), so a coexisting gamescope's
/// node is never mistaken for ours.
///
/// Takes the `Child` rather than a bare pid so it can **stop early when gamescope is already
/// dead**. A gamescope that fails `vkCreateDevice` exits in under a second, and polling its corpse
/// for the full 15 s bought nothing except a caller error that blamed the wrong thing ("headless
/// capture is unsupported on this GPU/driver"). `try_wait` turns that into an immediate `None`
/// while the log — which the caller names in the same error — still holds the real reason.
pub(super) fn wait_for_node(
timeout: Duration,
log: &std::path::Path,
child_pid: u32,
child: &mut Child,
) -> Option<u32> {
let child_pid = child.id();
let deadline = Instant::now() + timeout;
loop {
if let Some(id) = node_from_log(log) {
return Some(id);
}
// Check for a node FIRST, then for death: a gamescope that published its node and then
// exited in the same tick still gives us the id, and the caller's own liveness handling
// (the keepalive `Child`, `kept_display_alive`) owns what happens next.
match child.try_wait() {
// Still running — keep waiting.
Ok(None) => {}
// Exited. One last scoped look (the node line may have been written between the two
// reads above), then give up rather than poll a corpse to the deadline.
Ok(Some(status)) => {
tracing::warn!(
pid = child_pid,
%status,
log = %log.display(),
"gamescope: the spawned process exited before publishing a PipeWire node — \
not waiting out the rest of the budget"
);
return node_from_log(log).or_else(|| find_gamescope_node_scoped(Some(child_pid)));
}
// `try_wait` itself failed (the child was reaped elsewhere, ECHILD): fall back to the
// old behaviour rather than inventing a death.
Err(_) => {}
}
if Instant::now() >= deadline {
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
// node isn't picked by mistake.
@@ -197,7 +240,10 @@ fn node_from_log(log: &std::path::Path) -> Option<u32> {
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
pub(super) fn gamescope_node_present(node_id: u32) -> bool {
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
let Ok(out) = crate::proc::output_within(
Command::new("pw-dump").arg(node_id.to_string()),
PW_DUMP_BUDGET,
) else {
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
return true;
};
@@ -229,7 +275,7 @@ pub(super) fn find_gamescope_node() -> Option<u32> {
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
let out = Command::new("pw-dump").output().ok()?;
let out = crate::proc::output_within(&mut Command::new("pw-dump"), PW_DUMP_BUDGET).ok()?;
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let nodes = dump.as_array()?;
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
@@ -302,7 +348,12 @@ fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
/// most recently created (the live session). Returns the bare socket *name* (the injector
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
pub(super) fn find_gamescope_eis_socket() -> Option<String> {
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
// Under the shared env lock: `session::apply_session_env` `set_var`s XDG_RUNTIME_DIR from the
// connect thread, and glibc's setenv/getenv pair is a data race the crate's own `lib.rs`
// documents as UB. The lock is not reentrant, so this must stay a read taken HERE and not
// hoisted into a caller — the only caller, `point_injector_at_eis`, holds nothing (its
// `ei_socket_file()` takes and releases the same lock separately).
let runtime = crate::with_env_lock(|| std::env::var("XDG_RUNTIME_DIR").ok())?;
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
@@ -328,11 +379,12 @@ pub(super) fn find_gamescope_eis_socket() -> Option<String> {
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
/// create path); just checks the binary executes.
pub(crate) fn is_available() -> bool {
std::process::Command::new(gamescope_bin())
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
crate::proc::output_within(
Command::new(gamescope_bin()).arg("--version"),
VERSION_PROBE_BUDGET,
)
.map(|o| o.status.success())
.unwrap_or(false)
}
/// The gamescope binary this host spawns, resolved ONCE per process:
@@ -400,14 +452,20 @@ fn which_in_path(name: &str) -> Option<String> {
///
/// Monotonic, so one probe answers every capability:
/// * `1` — 10-bit BT.2020/PQ capture formats ([`gamescope_hdr_capable`]);
/// * `2` — …and `--pipewire-composite-cursor` ([`gamescope_can_composite_cursor`]).
/// * `2` — …and `--pipewire-composite-cursor` ([`gamescope_can_composite_cursor`]);
/// * `3` — …and `--custom-refresh-rates` ([`gamescope_can_offer_refresh_rates`]);
/// * `4` — …and `--pipewire-composite-external-overlay`
/// ([`gamescope_can_composite_external_overlay`]).
///
/// When upstream takes the functional patches this becomes a plain version floor, exactly like
/// [`MIN_GAMESCOPE_OVERLAY`].
fn gamescope_patch_level() -> u32 {
static LEVEL: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
*LEVEL.get_or_init(|| {
let Ok(out) = Command::new(gamescope_bin()).arg("--version").output() else {
let Ok(out) = crate::proc::output_within(
Command::new(gamescope_bin()).arg("--version"),
VERSION_PROBE_BUDGET,
) else {
return 0;
};
// The banner goes to stderr on some builds, stdout on others (same as the version gate).
@@ -530,7 +588,8 @@ fn parse_patch_level(banner: &str) -> u32 {
/// WSI-layer check has to compare TWO binaries — ours and the distro's — and a `None` there means
/// "leave the layer alone", not "assume old".
pub(super) fn gamescope_version_of(bin: &std::path::Path) -> Option<(u32, u32, u32)> {
let out = Command::new(bin).arg("--version").output().ok()?;
let out = crate::proc::output_within(Command::new(bin).arg("--version"), VERSION_PROBE_BUDGET)
.ok()?;
// Same stdout/stderr split as the version gate: builds disagree on where the banner goes.
let text = format!(
"{}{}",
@@ -549,8 +608,15 @@ const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
/// the overlay-window paint (gated on the consumer negotiating `gamescope_focus_appid == 0`, which
/// we do by never advertising that property — see the capturer's EnumFormat builders) first ships
/// in 3.16.23 (gamescope commits `ccd62074` + `f8b33d38`). Below this the overlay is *never* in the
/// node, so it cannot appear in the stream no matter what the host does. The cursor and
/// external-overlay / notification layers are excluded on *every* version (handled host-side).
/// node, so it cannot appear in the stream no matter what the host does.
///
/// On a **stock** gamescope the cursor and external-overlay / notification layers are excluded from
/// `paint_pipewire` on every version, and the host handles the cursor itself. punktfunk's own build
/// puts both back: `--pipewire-composite-cursor` at patch level 2+
/// ([`gamescope_can_composite_cursor`], which is what suppresses the host-side blend) and
/// `--pipewire-composite-external-overlay` at 4+ ([`gamescope_can_composite_external_overlay`]) —
/// see [`gamescope_patch_level`]. So "the overlay is missing from the stream" is a question about
/// which flags reached the running compositor, not about host-side compositing.
const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
/// Best-effort: warn if the installed gamescope is older than [`MIN_GAMESCOPE`] (capture is
@@ -558,10 +624,11 @@ const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
/// diagnostic, not a gate. Returns the parsed version when it could read one.
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
let out = Command::new(gamescope_bin())
.arg("--version")
.output()
.ok()?;
let out = crate::proc::output_within(
Command::new(gamescope_bin()).arg("--version"),
VERSION_PROBE_BUDGET,
)
.ok()?;
// gamescope prints the version banner to stderr on some builds, stdout on others.
let text = format!(
"{}{}",
@@ -34,24 +34,28 @@ pub(crate) fn list_monitors() -> anyhow::Result<Vec<PhysicalMonitor>> {
Ok(heads_under(
Path::new("/sys/class/drm"),
&super::gamescope_argvs(),
super::current_gamescope_output_size(),
))
}
/// [`list_monitors`] against an arbitrary sysfs root and a supplied argv set — the unit-testable
/// core. `output_size` is gamescope's own `-W`/`-H`, which OUTRANKS the EDID's preferred timing
/// because it is the size the capture node actually produces.
fn heads_under(
base: &Path,
argvs: &[Vec<String>],
output_size: Option<(u32, u32)>,
) -> Vec<PhysicalMonitor> {
/// core.
///
/// The head's size comes from the `-W`/`-H` of the argv selected HERE, which OUTRANKS the EDID's
/// preferred timing because it is the size the capture node actually produces. It used to arrive as
/// a parameter filled by a scan over ALL gamescopes on the box — including the nested child this
/// function had just deliberately rejected, and any headless one the crate spawned itself. On a
/// Deck driving eDP-1 at 1280x800 with a game nested at `-W 1920 -H 1080`, the panel was listed as
/// 1920x1080, and `mirror::create` publishes that row verbatim as the `preferred_mode` the stream
/// negotiates against — a mode the composited node never produces, and one `check_mirrorable` waves
/// through because it only rejects `0x0`.
fn heads_under(base: &Path, argvs: &[Vec<String>]) -> Vec<PhysicalMonitor> {
// A gamescope that isn't on DRM has no head of its own. Any DRM-backed one qualifies the box:
// a Deck streaming from Game Mode often has a second, nested gamescope running the game inside
// the session one, and that child must not disqualify its parent.
let Some(argv) = argvs.iter().find(|a| drives_drm(a)) else {
return Vec::new();
};
let output_size = super::gamescope_output_size(argv);
let connected = connected_connectors(base);
if connected.is_empty() {
return Vec::new();
@@ -342,7 +346,6 @@ mod tests {
let heads = heads_under(
&base,
&[argv("/usr/bin/gamescope --prefer-output HDMI-A-1 --steam")],
None,
);
assert_eq!(heads.len(), 1);
assert_eq!(heads[0].connector, "HDMI-A-1");
@@ -366,12 +369,12 @@ mod tests {
"gamescope --backend sdl",
] {
assert!(
heads_under(&base, &[argv(a)], None).is_empty(),
heads_under(&base, &[argv(a)]).is_empty(),
"expected no heads for {a:?}"
);
}
// No gamescope at all is the same answer, not an error.
assert!(heads_under(&base, &[], None).is_empty());
assert!(heads_under(&base, &[]).is_empty());
std::fs::remove_dir_all(&base).unwrap();
}
@@ -384,12 +387,15 @@ mod tests {
&base,
&[
argv("gamescope --backend wayland -W 1280 -H 800"),
argv("/usr/bin/gamescope --prefer-output *,eDP-1 --steam"),
argv("/usr/bin/gamescope --prefer-output *,eDP-1 -W 2560 -H 1440 --steam"),
],
None,
);
assert_eq!(heads.len(), 1);
assert_eq!(heads[0].connector, "eDP-1");
// …and the size comes from the DRM PARENT, not from the nested child listed first. Reading
// it off any-gamescope-on-the-box is what published a 1280x800 panel as the mirror's
// preferred mode on a box where the game happened to be nested at a different size.
assert_eq!((heads[0].width, heads[0].height), (2560, 1440));
std::fs::remove_dir_all(&base).unwrap();
}
@@ -404,7 +410,7 @@ mod tests {
("card1-HDMI-A-1", "connected\n", "enabled\n"),
],
);
let heads = heads_under(&base, &[argv("gamescope --prefer-output *,eDP-1")], None);
let heads = heads_under(&base, &[argv("gamescope --prefer-output *,eDP-1")]);
assert_eq!(heads.len(), 1);
assert_eq!(heads[0].connector, "eDP-1");
std::fs::remove_dir_all(&base).unwrap();
@@ -421,7 +427,7 @@ mod tests {
("card1-HDMI-A-1", "connected\n", "enabled\n"),
],
);
let heads = heads_under(&base, &[argv("gamescope --steam")], None);
let heads = heads_under(&base, &[argv("gamescope --steam")]);
assert_eq!(
heads
.iter()
@@ -440,7 +446,7 @@ mod tests {
"unplugged",
&[("card1-HDMI-A-1", "disconnected\n", "disabled\n")],
);
assert!(heads_under(&base, &[argv("gamescope --steam")], None).is_empty());
assert!(heads_under(&base, &[argv("gamescope --steam")]).is_empty());
std::fs::remove_dir_all(&base).unwrap();
}
@@ -452,7 +458,6 @@ mod tests {
let heads = heads_under(
&base,
&[argv("gamescope -W 2560 -H 1440 --prefer-output HDMI-A-1")],
Some((2560, 1440)),
);
assert_eq!((heads[0].width, heads[0].height), (2560, 1440));
std::fs::remove_dir_all(&base).unwrap();
@@ -511,7 +516,6 @@ mod tests {
&[argv(
"gamescope --nested-refresh 30 --prefer-output HDMI-A-1",
)],
None,
);
assert_eq!(heads[0].refresh_mhz, 60_000);
assert_eq!(heads[0].mode_label(), "1920x1080@60");
@@ -539,7 +543,7 @@ mod tests {
"3840x2160\n1920x1080\n",
)
.unwrap();
let heads = heads_under(&base, &[argv("gamescope --steam")], None);
let heads = heads_under(&base, &[argv("gamescope --steam")]);
assert_eq!((heads[0].width, heads[0].height), (3840, 2160));
std::fs::remove_dir_all(&base).unwrap();
}
@@ -143,17 +143,57 @@ pub(crate) fn run() -> Result<()> {
}
}
/// How long the splash waits for the session's X server before giving up.
const CONNECT_BUDGET: Duration = Duration::from_secs(10);
/// Connect to the session's `DISPLAY`, retrying briefly — gamescope sets the variable before
/// exec'ing the nested command, but a slow Xwayland under cold driver init gets a grace window.
///
/// The retry runs on a worker thread and the budget is enforced by `recv_timeout` rather than by
/// re-checking a deadline between attempts. The difference is the whole point: `x11rb::connect`
/// has no timeout of its own, so against an Xwayland that ACCEPTED the socket and then never
/// answered the setup handshake it blocks indefinitely — and a deadline consulted only in the
/// `Err` arm is never reached at all. That is the failure this module exists to prevent, from the
/// inside: no painting client, no composite, no PipeWire buffers, and the capture dies on its 10 s
/// first-frame timeout having never logged "gamescope splash: mapped", so the diagnosis points
/// anywhere but here.
///
/// A worker still stuck in `connect` is abandoned rather than joined; it is one thread in a
/// process whose whole job is this window, and the alternative is the hang.
fn connect_with_retry() -> Result<(RustConnection, usize)> {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
match x11rb::connect(None) {
Ok(ok) => return Ok(ok),
Err(e) if std::time::Instant::now() >= deadline => {
return Err(e).context("gamescope splash: could not connect to the session DISPLAY")
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("pf-splash-x11-connect".into())
.spawn(move || {
let deadline = std::time::Instant::now() + CONNECT_BUDGET;
loop {
match x11rb::connect(None) {
Ok(ok) => {
let _ = tx.send(Ok(ok));
return;
}
Err(e) if std::time::Instant::now() >= deadline => {
let _ = tx.send(Err(e));
return;
}
Err(_) => std::thread::sleep(Duration::from_millis(200)),
}
}
Err(_) => std::thread::sleep(Duration::from_millis(200)),
})
.context("gamescope splash: could not start the X connect thread")?;
// A little past the worker's own deadline, so a connect that merely finished slowly still wins
// and only a genuinely blocked one trips this.
match rx.recv_timeout(CONNECT_BUDGET + Duration::from_secs(1)) {
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => Err(e).context("gamescope splash: could not connect to the session DISPLAY"),
Err(_) => {
tracing::warn!(
secs = CONNECT_BUDGET.as_secs(),
"gamescope splash: the session's X server accepted no connection and never \
answered giving up. Nothing will paint in this gamescope, so it will composite \
nothing and the capture will starve; the gamescope log is where the reason is."
);
anyhow::bail!("gamescope splash: connecting to the session DISPLAY did not return")
}
}
}