A stopped display manager left the box unable to switch sessions — idle its autologin instead #342

Merged
enricobuehler merged 2 commits from worktree-kwin-vout-failed-permanent into main 2026-08-19 17:40:11 +00:00
Owner

Field report: on a Bazzite box streaming Game Mode, Steam's own Switch to Desktop hangs on its modal forever. Reproduced and fixed on .41.

What happens

The managed takeover frees Steam by stopping the box's gaming session — and because a display-manager autologin puts that session straight back, it stopped the display manager too, for the length of the stream:

freed Steam: stopped the display manager for this stream … dm=sddm.service
Created symlink '/run/user/1000/systemd/user/gamescope-session-plus@ogui-steam.service' → '/dev/null'

That works until the user asks for a desktop session. With no DM there is nothing on the box able to start one, so the switch never completes and the box sits there until a reboot.

Why detection can't fix it

Every trace a steamos-manager switch leaves is written by the display manager we had just stopped. Measured on .41:

Candidate signal Result
~/.config/steamos-session-select never written — the ChimeraOS/Nobara layout; Bazzite execs steamosctl (a D-Bus call into steamos-manager)
/var/lib/sddm/state.conf only advances when sddm actually starts a session — unchanged for 16 s under a takeover
steamosctl get-default-login-mode stays game for a non-persistent switch
graphical-session.target → inactive fires at takeover time too; timing-dependent on our own session start
box's gamescope-session-plus@* → inactive already inactive under a takeover

The existing honor_session_select_switch path keys on the first of those, so on Bazzite it can never arm — and it is only reachable from create_managed_session, which a Bazzite switch never re-enters because our own gamescope keeps delivering frames.

The fix

Stop creating the deadlock. The takeover now idles the box's autologin session for the stream's duration — a drop-in over the gamescope-session-plus@ template replacing ExecStart with a process that sleeps — and leaves the display manager running:

  • the autologin still succeeds, so there is no failed unit to relogin against (a masked unit fails in milliseconds, which is the storm's engine);
  • the session runs nothing, so Steam is free;
  • the DM is alive, so the box services the switch itself.

No privilege, no DM-flavor matrix, no detection. DmPlan::stop_dm becomes dm_relogins — same tested predicate, honest name.

Measured on .41, both directions:

result
takeover steam=down, sddm=active, unit active (running), NRestarts=0, held 20 s
the switch that used to hang plasma-workspace.target active in ~10 s
restore steam=up within 5 s

Load-bearing details: the drop-in lives under $XDG_RUNTIME_DIR (it replaces the box's game-mode ExecStart, so a copy outliving the host is a box whose Game Mode silently does nothing) and is swept unconditionally at startup; its removal sits above every early return in the restore, next to the bind drop-in's, because the desktop-active return is exactly the path that would leak it; the restore restarts rather than starts, since start on an active-but-idle unit is a no-op that logs success over it; and a reconnect is guarded, or it would re-kill an already-idled session every time.

Second commit: the dead chain

cargo check on Linux flagged six functions the first commit orphaned. The linger machinery existed solely because stopping the DM would take the host down with the login session, so the whole chain goes — try_stop_display_manager, ensure_host_survives_dm_stop, host_is_under_user_manager, cgroup_under_user_manager, linger_enabled, and the test covering only the cgroup predicate. 142 lines out, 17 in. mask_unit becomes #[cfg(test)]: its only caller is the mask-lift test, which still guards live code (lift_autologin_mask cleans up a takeover adopted from an older host).

pf-dm-helper keeps its now-callerless stop/linger verbs — removing them changes the shipped polkit action's surface, which is a packaging decision, not a cleanup.

Docs

gamescope.md, bazzite.md and the shipped packaging/bazzite/host.env all stated the takeover "has to stop the display manager" and needs the punktfunk group. Both now false — and host.env is the file the reporter was reading. The group advice narrows to the usbip nodes the virtual Steam Deck pad attaches through, which is what it still gates.

Verification

  • .41 (Bazzite, KWin 6.7.4): takeover, in-stream desktop switch and restore exercised end to end, numbers above.
  • cargo check -p pf-vdisplay in punktfunk-rust-ci (Linux — this backend is cfg(linux)): clean, no warnings.
  • dm_plan + its renamed test extracted and run standalone: passes.
  • cargo fmt clean.
Field report: on a Bazzite box streaming Game Mode, Steam's own **Switch to Desktop** hangs on its modal forever. Reproduced and fixed on `.41`. ## What happens The managed takeover frees Steam by stopping the box's gaming session — and because a display-manager autologin puts that session straight back, it stopped the display manager too, for the length of the stream: ``` freed Steam: stopped the display manager for this stream … dm=sddm.service Created symlink '/run/user/1000/systemd/user/gamescope-session-plus@ogui-steam.service' → '/dev/null' ``` That works until the user asks for a desktop session. With no DM there is nothing on the box able to start one, so the switch never completes and the box sits there until a reboot. ## Why detection can't fix it Every trace a `steamos-manager` switch leaves is written by the display manager we had just stopped. Measured on `.41`: | Candidate signal | Result | |---|---| | `~/.config/steamos-session-select` | never written — the ChimeraOS/Nobara layout; Bazzite execs `steamosctl` (a D-Bus call into `steamos-manager`) | | `/var/lib/sddm/state.conf` | only advances when sddm actually *starts* a session — unchanged for 16 s under a takeover | | `steamosctl get-default-login-mode` | stays `game` for a non-persistent switch | | `graphical-session.target` → inactive | fires at takeover time too; timing-dependent on our own session start | | box's `gamescope-session-plus@*` → inactive | already inactive under a takeover | The existing `honor_session_select_switch` path keys on the first of those, so on Bazzite it can never arm — and it is only reachable from `create_managed_session`, which a Bazzite switch never re-enters because our own gamescope keeps delivering frames. ## The fix Stop creating the deadlock. The takeover now **idles** the box's autologin session for the stream's duration — a drop-in over the `gamescope-session-plus@` template replacing `ExecStart` with a process that sleeps — and leaves the display manager running: - the autologin still **succeeds**, so there is no failed unit to relogin against (a *masked* unit fails in milliseconds, which is the storm's engine); - the session runs nothing, so Steam is free; - the DM is alive, so the box services the switch itself. No privilege, no DM-flavor matrix, no detection. `DmPlan::stop_dm` becomes `dm_relogins` — same tested predicate, honest name. **Measured on `.41`, both directions:** | | result | |---|---| | takeover | `steam=down`, `sddm=active`, unit `active (running)`, `NRestarts=0`, held 20 s | | the switch that used to hang | `plasma-workspace.target` active in ~10 s | | restore | `steam=up` within 5 s | Load-bearing details: the drop-in lives under `$XDG_RUNTIME_DIR` (it replaces the box's game-mode `ExecStart`, so a copy outliving the host is a box whose Game Mode silently does nothing) and is swept unconditionally at startup; its removal sits **above every early return** in the restore, next to the bind drop-in's, because the desktop-active return is exactly the path that would leak it; the restore `restart`s rather than `start`s, since `start` on an active-but-idle unit is a no-op that logs success over it; and a reconnect is guarded, or it would re-kill an already-idled session every time. ## Second commit: the dead chain `cargo check` on Linux flagged six functions the first commit orphaned. The linger machinery existed solely because stopping the DM would take the host down with the login session, so the whole chain goes — `try_stop_display_manager`, `ensure_host_survives_dm_stop`, `host_is_under_user_manager`, `cgroup_under_user_manager`, `linger_enabled`, and the test covering only the cgroup predicate. **142 lines out, 17 in.** `mask_unit` becomes `#[cfg(test)]`: its only caller is the mask-lift test, which still guards live code (`lift_autologin_mask` cleans up a takeover adopted from an older host). `pf-dm-helper` keeps its now-callerless `stop`/`linger` verbs — removing them changes the shipped polkit action's surface, which is a packaging decision, not a cleanup. ## Docs `gamescope.md`, `bazzite.md` and the shipped `packaging/bazzite/host.env` all stated the takeover "has to stop the display manager" and needs the `punktfunk` group. Both now false — and `host.env` is the file the reporter was reading. The group advice narrows to the usbip nodes the virtual Steam Deck pad attaches through, which is what it still gates. ## Verification - `.41` (Bazzite, KWin 6.7.4): takeover, in-stream desktop switch and restore exercised end to end, numbers above. - `cargo check -p pf-vdisplay` in `punktfunk-rust-ci` (Linux — this backend is `cfg(linux)`): clean, no warnings. - `dm_plan` + its renamed test extracted and run standalone: passes. - `cargo fmt` clean.
enricobuehler added 2 commits 2026-08-19 17:39:35 +00:00
The managed takeover freed Steam by stopping the box's gaming session and,
because a display-manager autologin puts that session straight back, by stopping
the display manager too. That worked until the user asked for a desktop session:
with no DM there is nothing on the box able to start one, so Steam's own "Switch
to Desktop" sat on its modal until a reboot (field report 2026-08-18, .41).

It also could not be detected and worked around. On a steamos-manager box the
switch is a D-Bus call whose every trace is written by the display manager we had
just stopped — measured on .41: the `~/.config/steamos-session-select` sentinel is
never written (that is the ChimeraOS/Nobara layout), `/var/lib/sddm/state.conf`
only advances when sddm actually STARTS a session, `get-default-login-mode` stays
`game` for a non-persistent switch, and `graphical-session.target` going inactive
fires at takeover time too. There is nothing to watch, because the component that
would produce the signal is the one we disabled.

So stop disabling it. The takeover now idles the box's autologin session for the
stream's duration — a drop-in over the `gamescope-session-plus@` template that
replaces `ExecStart` with a process that sleeps — and leaves the DM running. The
autologin still SUCCEEDS, so there is no failed unit to relogin against (a masked
one fails in milliseconds, which is the storm's engine); the session runs nothing,
so Steam is free; and the DM is alive, so the box can service the switch itself.
No privilege, no DM-flavor matrix, and no detection.

Measured on .41, both directions: takeover leaves `steam` down, `sddm` active and
the unit `active (running)` with NRestarts=0; the switch that used to hang brings
Plasma up in ~10 s; the restore puts Steam back within 5 s.

The drop-in lives under $XDG_RUNTIME_DIR for the same reason the bind one does —
it replaces the box's game-mode ExecStart, so a copy outliving the host would be a
box whose Game Mode silently does nothing — and is swept unconditionally at
startup. Its removal sits above every early return in the restore, next to the
bind drop-in's, because the desktop-active return is exactly the path that would
leak it. The restore restarts rather than starts: the unit is active-but-idle, and
`start` on an active unit is a no-op that would log success over it.

Docs and the shipped Bazzite host.env said the takeover has to stop the display
manager and needs the `punktfunk` group; both are now false. The group still gates
the usbip nodes the virtual Steam Deck pad attaches through, which is what the
advice narrows to.
refactor(gamescope): delete the display-manager stop chain the idled takeover retired
ci / bun-nix (pull_request) Successful in 37s
ci / web (pull_request) Successful in 1m14s
ci / docs-site (pull_request) Successful in 1m23s
android / android (pull_request) Successful in 6m52s
ci / rust-arm64 (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 9m30s
38a0f54b09
`cargo check` on Linux flagged six functions the previous commit orphaned. The
linger machinery existed for one reason — stopping the display manager ends the
user's last login session, and logind would take the host down with it about 10 s
later — and nothing stops a display manager any more, so the whole chain goes:
`try_stop_display_manager`, `ensure_host_survives_dm_stop`,
`host_is_under_user_manager`, `cgroup_under_user_manager`, `linger_enabled`, and
the one test that covered only the cgroup predicate. 142 lines out, 17 in.

`mask_unit` becomes `#[cfg(test)]` rather than going with them. Its only caller
now is the mask-lift test, and that test still guards live code:
`lift_autologin_mask` cleans up a takeover adopted from a host old enough to have
laid a mask, so the state it undoes has to stay constructible.

`pf-dm-helper` keeps its `stop` and `linger` verbs even though no caller is left —
only `restore` is still reached — because removing them changes the shipped polkit
action's surface, which is a packaging decision rather than a cleanup.
enricobuehler merged commit e12ef6633c into main 2026-08-19 17:40:11 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#342