The takeover's own mask was the relogin storm that starved pad input #312

Merged
enricobuehler merged 1 commits from worktree-sddm-relogin-storm-fix into main 2026-08-18 21:14:14 +00:00
Owner

Closes the sddm-relogin-storm-starves-input-handoff investigation.

The storm was ours

A managed takeover on the .41 SDDM-autologin box entered a ~4–5 logins/s relogin storm. It presented as "my DualSense is not detected in the game" — the pad enumerated perfectly and then delivered input at ~1.4 Hz instead of 250 Hz, because winebus re-enumerates udev on every event and therefore stops reading hidraw. An evening went into disproving the pad stack, the ALSA UCM, PipeWire and GE-Proton before the display manager was suspected at all.

Measured on the box (2026-08-18):

/usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop
  Exec=gamescope-session-plus ogui-steam
/usr/share/gamescope-session-plus/gamescope-session-plus, last act:
  systemctl --user --wait start gamescope-session-plus@${CLIENT}.service

The runtime mask this takeover lays sits directly in sddm's relogin path. Every autologin then fails in milliseconds instead of taking the seconds a real gamescope + Steam start costs, and sddm's Relogin=true has no backoff — a slow, survivable relogin loop becomes a fork storm.

The 2026-07-31 reading of the same box recorded the storm but concluded the sddm helper "execs the session script directly, so the masked unit never enters the picture". It does, one systemctl call further down. That is why masking looked inert, and why it was left as the degraded takeover for SDDM when the DM stop could not be achieved. Masking without the stop is not a weaker defense — it is the storm's engine.

The fix: the mask never substitutes for the DM stop

  • dm_plan loses its mask input, and with it dm_survives_masked_unit. The SDDM/plasmalogin split existed only to pick a degraded mode, and there is no longer one to pick: a planned DM stop that does not land fails the takeover, and the caller degrades to ATTACH — a fully working stream at the session's own mode. Fighting an autologin we cannot stop is strictly worse than not taking over.
  • The mask is laid only after the stop has landed. Both failure arms bail, so reaching the mask proves no DM is up to relogin through it. That is also what keeps mask-fragile flavors safe: a stopped plasmalogin cannot trip its own start limit, and every restore path unmasks before restarting.
  • skip is now !any_live on every flavor — killing loaded-but-inactive leftovers frees no Steam, and masking them under a running DM is the storm.

§3's open question, decided on the box

Not (b). pkexec /usr/libexec/punktfunk/pf-dm-helper linger run from a sessionless systemd --user context — the host's own context — exits 0. Lingering is on, bazzite is in group punktfunk (965), helper and polkit action are installed and correctly annotated. The privileged path works there.

It is (a) — but the fix is the invariant above rather than a better any_live, because the DM ending up alive next to our mask is the fault whatever put it there. (The one other way that happens — a restore resurrecting sddm underneath a fresh mask — was already closed by RESTORE_FLIGHT.)

any_live is tightened anyway, stated as the negative: systemd has exactly two not-running ACTIVE states and the other four all mean the unit still owns Steam. The old list missed deactivating/reloading, so a unit caught mid-teardown read as a dead leftover.

Never spending that evening again

  • DmHelperError::shape() — the four shapes need four different fixes, so they ride along as a greppable shape field, and a planned-but-unachieved DM stop logs at ERROR naming it instead of vanishing.
  • watch_for_relogin_storm() — logind names its session files after a monotonic id in /run/systemd/sessions, so the max is a free login counter. Two read_dirs five seconds apart on a detached thread; above 1/s it says so at ERROR, and says what it means: no audio, input or PipeWire measurement taken during a storm is valid. Detect-and-report only.
  • systemctl_system captures its stderr and logs it at DEBUG. On an unprivileged host that verb is expected to fail — it is the cheap probe before the pkexec helper — so systemctl's own "Access denied … requires interactive authentication" went to the journal on the normal, successful path, twice, immediately before INFO restored the display manager. That shape cost two sessions on its own.

Verification

In the CI image (punktfunk-rust-ci, linux/amd64): cargo fmt --check clean, cargo clippy -p pf-vdisplay --all-targets -- -D warnings clean, cargo test -p pf-vdisplay --lib -- gamescope52 passed, 0 failed, 1 ignored (needs a live systemd --user manager).

Not verified on-glass: the .41 box could be inspected read-only from here, but the client at 192.168.1.235 refuses ssh, so no connect could be driven to exercise a real takeover. Done when a managed takeover on .41 (ATTACH back off) runs a full session with session churn ≈ 0 and the pad reads at ~250 Hz — or, if the DM genuinely cannot be stopped, the host says so at ERROR and attaches instead.

🤖 Generated with Claude Code

Closes the `sddm-relogin-storm-starves-input-handoff` investigation. ## The storm was ours A managed takeover on the `.41` SDDM-autologin box entered a ~4–5 logins/s relogin storm. It presented as **"my DualSense is not detected in the game"** — the pad enumerated perfectly and then delivered input at ~1.4 Hz instead of 250 Hz, because `winebus` re-enumerates udev on every event and therefore stops reading `hidraw`. An evening went into disproving the pad stack, the ALSA UCM, PipeWire and GE-Proton before the display manager was suspected at all. Measured on the box (2026-08-18): ``` /usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop Exec=gamescope-session-plus ogui-steam /usr/share/gamescope-session-plus/gamescope-session-plus, last act: systemctl --user --wait start gamescope-session-plus@${CLIENT}.service ``` The runtime mask this takeover lays sits **directly in sddm's relogin path**. Every autologin then fails in milliseconds instead of taking the seconds a real gamescope + Steam start costs, and sddm's `Relogin=true` has no backoff — a slow, survivable relogin loop becomes a fork storm. The 2026-07-31 reading of the same box recorded the storm but concluded the sddm helper "execs the session script directly, so the masked unit never enters the picture". It does, one `systemctl` call further down. That is why masking looked inert, and why it was left as the **degraded** takeover for SDDM when the DM stop could not be achieved. Masking without the stop is not a weaker defense — it is the storm's engine. ## The fix: the mask never substitutes for the DM stop - `dm_plan` loses its `mask` input, and with it `dm_survives_masked_unit`. The SDDM/plasmalogin split existed only to pick a degraded mode, and there is no longer one to pick: a planned DM stop that does not land **fails the takeover**, and the caller degrades to ATTACH — a fully working stream at the session's own mode. Fighting an autologin we cannot stop is strictly worse than not taking over. - The mask is laid **only after the stop has landed**. Both failure arms bail, so reaching the mask proves no DM is up to relogin through it. That is also what keeps mask-fragile flavors safe: a stopped plasmalogin cannot trip its own start limit, and every restore path unmasks before restarting. - `skip` is now `!any_live` on every flavor — killing loaded-but-inactive leftovers frees no Steam, and masking them under a running DM is the storm. ## §3's open question, decided on the box **Not (b).** `pkexec /usr/libexec/punktfunk/pf-dm-helper linger` run from a sessionless `systemd --user` context — the host's own context — exits **0**. Lingering is on, `bazzite` is in group `punktfunk` (965), helper and polkit action are installed and correctly annotated. The privileged path works there. It is **(a)** — but the fix is the invariant above rather than a better `any_live`, because the DM ending up alive next to our mask is the fault whatever put it there. (The one other way that happens — a restore resurrecting sddm underneath a fresh mask — was already closed by `RESTORE_FLIGHT`.) `any_live` is tightened anyway, stated as the negative: systemd has exactly two not-running ACTIVE states and the other four all mean the unit still owns Steam. The old list missed `deactivating`/`reloading`, so a unit caught mid-teardown read as a dead leftover. ## Never spending that evening again - **`DmHelperError::shape()`** — the four shapes need four different fixes, so they ride along as a greppable `shape` field, and a planned-but-unachieved DM stop logs at ERROR naming it instead of vanishing. - **`watch_for_relogin_storm()`** — logind names its session files after a monotonic id in `/run/systemd/sessions`, so the max is a free login counter. Two `read_dir`s five seconds apart on a detached thread; above 1/s it says so at ERROR, and says what it means: no audio, input or PipeWire measurement taken during a storm is valid. Detect-and-report only. - **`systemctl_system`** captures its stderr and logs it at DEBUG. On an unprivileged host that verb is *expected* to fail — it is the cheap probe before the pkexec helper — so systemctl's own "Access denied … requires interactive authentication" went to the journal on the normal, successful path, twice, immediately before `INFO restored the display manager`. That shape cost two sessions on its own. ## Verification In the CI image (`punktfunk-rust-ci`, linux/amd64): `cargo fmt --check` clean, `cargo clippy -p pf-vdisplay --all-targets -- -D warnings` clean, `cargo test -p pf-vdisplay --lib -- gamescope` → **52 passed, 0 failed, 1 ignored** (needs a live `systemd --user` manager). Not verified on-glass: the `.41` box could be inspected read-only from here, but the client at `192.168.1.235` refuses ssh, so no connect could be driven to exercise a real takeover. **Done when** a managed takeover on `.41` (ATTACH back off) runs a full session with session churn ≈ 0 and the pad reads at ~250 Hz — or, if the DM genuinely cannot be stopped, the host says so at ERROR and attaches instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
enricobuehler added 1 commit 2026-08-18 20:29:45 +00:00
fix(gamescope): the takeover's own mask was the relogin storm that starved pad input
ci / bun-nix (pull_request) Successful in 21s
ci / rust-arm64 (pull_request) Failing after 32s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 2m47s
ci / rust (pull_request) Successful in 5m56s
android / android (pull_request) Successful in 6m25s
3717466594
A managed takeover on the .41 SDDM-autologin box entered a ~4-5 logins/s
relogin storm and every udev consumer drowned in the fallout. It presented
as "my DualSense is not detected in the game, or only with an insane delay":
the pad enumerated perfectly and then delivered input at ~1.4 Hz instead of
250 Hz, because `winebus` re-enumerates udev on every event and therefore
stops reading `hidraw`. An evening went into disproving the pad stack, the
ALSA UCM, PipeWire and GE-Proton before the display manager was suspected.

The storm was ours. Measured on .41 (2026-08-18):

    /usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop
      Exec=gamescope-session-plus ogui-steam
    /usr/share/gamescope-session-plus/gamescope-session-plus, last act:
      systemctl --user --wait start gamescope-session-plus@${CLIENT}.service

so the runtime mask this takeover lays sits *directly in sddm's relogin
path*. Every autologin then fails in milliseconds instead of taking the
seconds a real gamescope + Steam start costs, and sddm's `Relogin=true` has
no backoff: a slow, survivable relogin loop becomes a fork storm. 962 logind
sessions in 3.7 min, `Watching system buttons` re-scanned 5,688 times, a
box-wide udev `change` storm at ~20/s, iio-sensor-proxy crash-looping at ~16
starts/s as a udev-activated amplifier, load 26 on 12 cores.

The 2026-07-31 reading of the same box recorded the storm but concluded the
sddm helper "execs the session script directly, so the masked unit never
enters the picture". It does — one `systemctl` call further down. That is
why masking looked inert, and why it was left as the *degraded* takeover for
SDDM when the DM stop could not be achieved. Masking without the stop is not
a weaker defense. It is the storm's engine.

So the mask no longer substitutes for the DM stop, on any flavor:

* `dm_plan` loses its `mask` input, and with it `dm_survives_masked_unit` —
  the SDDM/plasmalogin split existed only to pick a degraded mode, and there
  is no longer one to pick. A planned DM stop that does not land now fails
  the takeover and the caller degrades to ATTACH, which is a fully working
  stream at the session's own mode. Fighting an autologin we cannot stop is
  strictly worse than not taking over: it costs the user their input plane.
* The mask is laid only after the stop has LANDED. Both failure arms bail,
  so reaching the mask proves no DM is up to relogin through it. That is
  also what keeps mask-fragile flavors safe — a stopped plasmalogin cannot
  trip its own start limit, and every restore path unmasks before restarting.
* `skip` is now `!any_live` on every flavor: killing loaded-but-inactive
  leftovers frees no Steam, and masking them under a running DM is the storm.

Which of the two candidates in the handoff this was is now settled, on the
box: not (b). `pkexec /usr/libexec/punktfunk/pf-dm-helper linger` run from a
sessionless `systemd --user` context — the host's own context — exits 0;
lingering is on, bazzite is in group `punktfunk` (965), helper and polkit
action are installed and correctly annotated. The privileged path works
there. It is (a), and the fix is the invariant above rather than a better
`any_live`, because the DM ending up alive next to our mask is the fault
whatever put it there.

`any_live` is tightened anyway, stated as the negative: systemd has exactly
two not-running ACTIVE states and the other four all mean the unit still
owns Steam. The old list missed `deactivating` (and `reloading`), so a unit
caught mid-teardown read as a dead leftover — a box that IS in gaming mode
sampled as idle, its Steam left holding the single instance our own launch
then collides with. Small window on an idle box, wide open on a churning
one, which is exactly when this is sampled.

Two things the handoff asked for that are about never spending that evening
again:

* `DmHelperError::shape()` — the four shapes need four different fixes
  (package it / install polkit / fix the action / join the group), so they
  ride along as a greppable `shape` field, and a planned-but-unachieved DM
  stop now logs at ERROR naming it instead of vanishing into a WARN.
* `watch_for_relogin_storm()` — logind names its session files after a
  monotonic id in `/run/systemd/sessions`, so the max is a free login
  counter. Two `read_dir`s five seconds apart on a detached thread; above
  1/s it says so at ERROR, and says what it means: no audio, input or
  PipeWire measurement taken during a storm is valid. Detect-and-report
  only — self-mitigation would tear down a live stream on a detector that
  has never been wrong in the field because it has never run there.

Finally, `systemctl_system` captures its stderr and logs it at DEBUG. On an
unprivileged host that verb is *expected* to fail — it is the cheap probe
before the pkexec helper — so systemctl's own "Access denied ... requires
interactive authentication" went to the journal on the normal, successful
path: two of them immediately before `INFO restored the display manager`.
That shape cost two sessions on its own, each spent explaining a failure
that had already succeeded one line later.

Checked in the CI image (linux/amd64): `cargo fmt --check`, `cargo clippy
--all-targets -D warnings`, and `cargo test -p pf-vdisplay --lib gamescope`
— 52 passed, 1 ignored (needs a live `systemd --user` manager).

Refs: punktfunk-planning design/sddm-relogin-storm-starves-input-handoff.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
enricobuehler merged commit 83f6164027 into main 2026-08-18 21:14:14 +00:00
enricobuehler deleted branch worktree-sddm-relogin-storm-fix 2026-08-18 21:14:18 +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#312