fix(vdisplay/gamescope): DM-flavor-aware session takeover — stop masking Nobara's plasmalogin to death

Field report (Nobara 43 HTPC): switching the host to Steam Game Mode
mid-stream permanently black-screened the box. Live-proven root cause on
a Nobara repro VM: our takeover masks the box's gamescope-session-plus
unit, plasmalogin's Relogin=true then fails its session Exec repeatedly
and trips systemd's start limit within ~1 s — the display manager dies,
and our restore verb (unmask + user start) cannot bring a seatless
gamescope back. Only 'reset-failed + restart' of the DM recovers.

The takeover is now display-manager-flavor-aware:
- SDDM (Bazzite/SteamOS) keeps the proven mask+SIGKILL path unchanged.
- plasmalogin/unknown DMs never mask: with privilege (root or an
  operator polkit rule scoped to the DM unit — documented) the host
  stops the DM for the stream and restores it with reset-failed +
  restart (PUNKTFUNK_RECOVER_SESSION_CMD as fallback), recorded in the
  persisted takeover state so a host crash still restores; without
  privilege the managed takeover degrades to ATTACH and mirrors the
  box's live Game Mode instead of destabilizing the seat. Both legs of
  the privileged cycle live-verified on the repro VM (headless managed
  session works with zero login sessions; render nodes are 0666).
  A loaded-but-inactive leftover instance never triggers the DM stop.

Companion fixes from the same triage:
- ensure_box_gamescope_mode gains the attach-only rebuild-probe guard
  both managed paths already had (stale post-capture-loss detection
  restarted the box's unit), and no longer re-modes a box that drives a
  physical display — attach mirrors on-glass; re-mode is the
  headless-box model.
- Capture-loss rebuilds targeting gamescope get a 100 s budget: the
  40 s budget expired inside the first 45 s Steam-cold-start launch
  attempt, a guaranteed single-shot failure.
- A PUNKTFUNK_COMPOSITOR pin now WARNs once per capture loss when the
  live session no longer matches it (the pin disables
  session-following — the reporter's original stream-death trigger).
- A managed session that took nothing over (client gamescope pin beside
  a live desktop) is stopped on disconnect instead of being orphaned
  forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 11:49:24 +02:00
co-authored by Claude Fable 5
parent 9b241d9d7b
commit e35dad529b
6 changed files with 373 additions and 45 deletions
+33 -2
View File
@@ -1940,6 +1940,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// connected, frozen on the last frame, and the stream resumes when the new output
// appears — no reconnect.
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
// A managed/attach gamescope (re)launch legitimately takes up to 45 s — the Steam
// Big Picture cold start that `launch_session`/`ensure_box_gamescope_mode` poll
// for — so the 40 s budget used to expire INSIDE the first attempt (a single-shot
// failure ending the session even when a second, warm attempt would have
// succeeded). Give gamescope-targeted rebuilds room for two full launch attempts;
// desktop compositors keep the tighter budget. Checked per iteration because the
// loop retargets `compositor` as re-detection follows the box.
const GAMESCOPE_REBUILD_BUDGET: std::time::Duration =
std::time::Duration::from_secs(100);
// Attach-only holdoff: for the first seconds after a capture loss the session
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
@@ -1948,7 +1957,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
let loss_at = std::time::Instant::now();
let rebuild_deadline = loss_at + REBUILD_BUDGET;
// An explicit PUNKTFUNK_COMPOSITOR pin disables the re-detection below — the
// stream cannot follow a session switch. When the live session no longer matches
// the pin, say so loudly ONCE per loss: this rebuild can only retry the pinned
// backend and will die at the budget (the "mid-stream switch to game mode kills
// the stream" field reports all traced back to a stale pin).
if pf_host_config::config().compositor.is_some() {
let active = crate::vdisplay::detect_active_session();
if crate::vdisplay::compositor_for_kind(active.kind) != Some(compositor) {
tracing::warn!(
pinned = compositor.id(),
live = ?active.kind,
"capture lost while PUNKTFUNK_COMPOSITOR pins the backend and the \
live session no longer matches it — the pin disables \
session-following, so this rebuild can only retry the pinned \
backend; remove the pin to let the stream follow session switches"
);
}
}
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
// retargeting (then we stick to the pinned backend and just rebuild it).
@@ -2021,8 +2047,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
) {
Ok(p) => break p,
Err(e2) => {
let budget = if compositor == crate::vdisplay::Compositor::Gamescope {
GAMESCOPE_REBUILD_BUDGET
} else {
REBUILD_BUDGET
};
if stop.load(Ordering::SeqCst)
|| std::time::Instant::now() >= rebuild_deadline
|| std::time::Instant::now() >= loss_at + budget
{
return Err(e2)
.context("capture lost — no compositor came up within the rebuild budget");