Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/LOCK-DPMS-LESSONS.md
Fimeg 0f2f1a96c3 lock-dpms: the ordering invariant held in the code and not on the device
Three independent faults, any one enough: greetd ran the desktop in its
greeter slot so logind refused SetLockedHint outright; the hint was
written to the caller's session and read from User.Display, which an ssh
login redirects; and the one report that mattered was swallowed by a
guard that never replayed it. Every blank went out unlocked and recorded
blank-without-lock.

Also records the check that catches it in one line, and why verifying the
actuator directly proves nothing about the ordering.
2026-08-02 15:43:16 -04:00

10 KiB

Lock / DPMS / wake — the lessons doc

The hard-won rules for everything between "screen goes dark" and "user is back in." Doctrine lives in SESSION-AUTHORITY-DOCTRINE.md; the boot choreography lives in session-authority-boot-order.md; the raw archaeology lives in DUMP-power-idle-lock-2026-07-15.md. This page is the distilled operating knowledge — what we know is true on blueline, with the receipts.

1. Ordering: lock, then off

Blanking an unlocked session leaks the desktop for a frame on wake, and a wake that races the lock can land input on an unlocked surface.

  • Power button (blueline-power-button): screen on → session lock IPC → wait ≤2s for the compositor-acked "locked": true → blank. If the lock IPC fails, blank anyway — dark-but-unlocked is recoverable, lit-and- unlocked in a pocket is not.
  • Suspend: PrepareForSleep → lock secure → release the delay inhibitor. Fail-closed, ~5s budget.
  • Idle: sessiond enforces it. Every path to a dark panel routes through request_blank() in device_state.rs, which locks first, waits LOCK_ACK_BUDGET (2s) for the compositor ack, and blanks anyway on timeout while recording an error-security — the panel fails open, the claim never does (doctrine §8).

This used to be a coincidence and is now an invariant (2026-07-25). The line here previously read "the native IdleCoordinator locks (300s) well before hypridle's dpms-off (600s), so idle blanking always finds the session already locked." That was true only because 300 < 600 — two numbers in a config file. Anything that skipped the 300s lock (an idle inhibitor, the native coordinator disabled, a dead shell) still met the 600s blank, and the screen went dark on an unlocked session with nothing said about it. hypridle's listeners are now deleted (Pixel3Arch cadaec9); do not re-add one. A timer in that file cannot see the lock, and that blindness is the bug.

The invariant held in the code and not on the device (2026-08-02)

request_blank() was correct the whole time and the panel still went dark on unlocked sessions — every single blank, each one recorded as blank-without-lock. It was invisible for weeks because under viewtop the screen could not blank at all; the moment DPMS started working, the hole was load-bearing.

locked comes from logind's LockedHint (doctrine §4). Nothing was setting it, for three independent reasons, and any one of them was enough:

  1. greetd ran the desktop as a greeter. config.toml points both initial_session and default_session at souveraine-session on purpose (there is no greeter here). But default_session is greetd's greeter slot — it stamps XDG_SESSION_CLASS=greeter, and logind then refuses SetLockedHint outright: "Session does not support lock screen." initial_session only runs while /run/greetd.run is absent, so the first start after a cold boot got Class=user and every restart after that got a greeter session. Fixed by clearing the runfile before each start (Pixel3Arch d37ab4d); a systemctl restart greetd no longer silently downgrades the phone into "can never lock".
  2. The hint was written to a different session than it was read from. Session.qml used /org/freedesktop/login1/session/auto, which is the caller's session, and the shell is not in the session that owns the seat — measured: viewtop in logind 66 (seat0/tty1), qs in 70. lockhint.rs read via User.Display, which an ssh login is enough to point at a seatless remote session, and which then goes stale when that session ends. Both sides now resolve seat0's ActiveSession.
  3. The one report that mattered was dropped. onLockedChanged is guarded by hasLoginctl, which starts false and only flips when a Process probe returns — measured 30 s after secure=true. The shell locks once at boot and never changes again, so the guard swallowed the only edge there would ever be. Fourth edge-vs-level bug here after locked_ack, ChargeRate and bootBloomActive; the rule is that a guard which drops a report must replay it when the guard opens.

How to check this in one line, and do check it:

loginctl show-session $(busctl get-property org.freedesktop.login1 \
  /org/freedesktop/login1/seat/seat0 org.freedesktop.login1.Seat ActiveSession \
  | grep -oE '"[0-9]+"' | tr -d '"') -p Class -p LockedHint

Class must be user, and LockedHint must go yes while the lock surface is up. If Class=greeter, nothing below it can work and the phone will blank unlocked forever without a single error outside the forensic trail.

The testing lesson, which cost the discovery: the actuator was verified by calling blueline-screen-toggle off directly. That bypasses request_blank() entirely, so it proved DPMS worked and proved nothing about the ordering. A blank must be triggered the way the machine triggers one — a power tap or a real idle — and the trail read afterwards, or the invariant is untested.

2. One DPMS authority

blueline-screen-toggle serializes every panel power transition. Nothing else calls hyprctl dispatch dpms — when hypridle still had a raw dpms call after the toggle became authority, double-tap wake raced on-resume and the touch controller calibrated against a half-ramped panel (Pixel3Arch 73d0cd0). Any new wake source (power button, dt2w, proximity) goes through the toggle, or it will reintroduce the race.

Proximity landed 2026-07-20 (Pixel3Arch): blueline-screen-toggle itself gates DT2W wake on ProximityNear, and a new blueline-proximity-lock user service (same monitor-sensor watch pattern as blueline-autorotate) blanks an already-on locked screen the moment proximity goes near, via blueline-screen-toggle off — no second DPMS authority introduced. Gated on session state's .locked from the souveraine IPC; unlocked screens are untouched. iio-sensor-proxy is now always-on rather than start/stop on demand (the old wake-lockup hazard tied to it is confirmed gone — see Pixel3Arch PAF/slpi.md).

3. The FTS touch controller has opinions

  • On panel-unprepare it drops to gesture mode (that is what makes dt2w work while the screen is off).
  • On sense-on it self-calibrates against the panel. If the panel is still ramping, calibration is garbage and touch goes deaf or erratic. The kernel signature is a continuous spew of fts_status_event_handler: … invalid … Strength soft Force cal. A single line after a wake is noise; a stream that keeps flowing minutes later is a wedged controller.
  • The serialized DPMS path fixed this for screen-off/on cycles. The suspend-resume path still races (found 2026-07-17): s2idle exit re-runs panel-prepare → FTS reset → Sense ON with nobody pacing it, and a dt2w wake from suspend produced exactly the wedged-controller spew and a dead swipe-to-unlock on the lockscreen. Open gap; candidate fixes are kernel-side (pace sense-on after panel ramp — check what Android does, Android is the reference) or a post-resume rebind hook, which needs care because rebinding while the panel is off may drop the dt2w feature flags.
  • Recovery without reboot: echo 2-0049 > /sys/bus/i2c/drivers/fts/unbind && sleep 1 && echo 2-0049 > /sys/bus/i2c/drivers/fts/bind (root). Firmware re-verifies, sense restarts. Expect a line or two of invalid-cal immediately after; continuous spew means it did not take.

4. Suspend truths

  • Suspend is s2idle. The USB gadget (usb0 NCM/ACM) dies with it — an ssh session dropping and usb0: Lost carrier in the journal during suspend entry is normal, not a crash. Do not chase gadget "flaps" without first checking journalctl for PM: suspend entry.
  • dt2w wakes the phone from s2idle (gesture mode survives suspend).
  • NetworkManager/ModemManager sleep-monitor churn around suspend entry is choreography, not failure.
  • Warm reboots poison TZ/remoteproc state on blueline — recovery from a bad state is a true cold boot (unplugged power-off), never reboot.

5. Lock architecture (who holds what)

  • souveraine-sessiond takes ext-session-lock at boot before the shell exists; no desktop surface can precede the lock. The shell inherits the lock later over the bridge socket (misc.allow_session_lock_restore).
  • The sessiond↔shell handoff necessarily drops one lock client for ~1.05s. Hyprland's lockdead_screen_delay default (1000ms) painted a red "lockscreen crashed" flash in that gap; it is 4000ms in the tracked hyprland.lua. The session stays locked throughout — the flash was cosmetic.
  • Shell heartbeat loss → sessiond fail-closes: re-acquires the lock and presents its bare fallback PIN surface. Proven in anger 2026-07-17 when the shell died silently post-unlock. PAM at the fallback surface is a real unlock.
  • screenLocked (request) and screenLockSecure (compositor ack) are different facts. Personal-tier gates key off secure. The compositor can end a lock unilaterally (ext_session_lock_v1_finished); the shell resyncs screenLocked when that happens (LockScreen.qml onLockStateChanged) — before that fix, a stale lockRequested:true both lied to the gates and blocked re-lock.
  • Unlock is never an agent verb.

6. Shell surface rules

  • Lock-path QML deploys only while the phone is UNLOCKED. WlSessionLock.surfaceComponent cannot change while the lock is active; hot-deploying over an active lock wedges the lockscreen until reboot.
  • Two-stage lock: glance first, PIN pad on swipe-up (60px) / tap / any key; retreats on swipe-down (80px, empty PIN only) or 25s idle.
  • hypridle's after_sleep_cmd fires the lockFocus global shortcut on EVERY wake. It must only refocus the (hidden) PIN field — refocus must never imply PIN reveal, or every wake would skip the glance stage.
  • The shell runs under souveraine-shell.service (journal keeps stderr, restart on failure, non-clean exits append to ~/.local/state/souveraine/crashes.log, gives up after 8 tries and leaves the fail-closed lock in charge). It was a raw exec once; the crash reason died with it and unlock landed on a black desktop.

7. Open gaps

  • Suspend-resume FTS calibration race (§3) — the one live regression.
  • Crash reporting (queue item 10): crashes.log + coredumps + failed units surfaced as banner/sensoria, not just survived.
  • Proximity wake must never touch lock state (DUMP-power-idle-lock-2026-07-15.md §2) — same doctrine as dt2w: wake sources move the panel, only sessiond moves the lock.