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:
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 300–500 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user