Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/LOCK-DPMS-LESSONS.md
Fimeg b2ce5d7cb0 lock-dpms: waking is the machine's, and the compositor must not do it itself
The panel had no way back: apply_gesture answers a tap on a dark panel
with Restore, which is brightness, and there was no unblank in the action
table. Invisible while hyprland bound the keys straight to the toggle;
under viewtop it became a phone that could sleep and never wake.

Records the wake loop too, because building it wrong is the obvious move:
viewtop waking directly worked, then sessiond resolved the same press,
found the panel lit, and blanked it again. One press woke the phone and
put it back to sleep. Plus why dt2w is a wake and not a button, and the
deploy order that has to hold between the two halves.
2026-08-02 17:37:51 -04:00

14 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.

Who sets the hint now: the compositor. All three faults above were consequences of reporting it from the shell, and a fourth followed from the same place — Session.qml fired on a QML edge, so a hint cleared by anything else was never re-asserted while the session stayed locked. viewtop reports it instead (souveraine-viewtop 284fbcc), because it is the only thing that is simultaneously in the seat's session, authoritative about the protocol fact (LockEffect::SendLocked is the moment ext_session_lock_v1.locked goes out), and able to publish a levelapply() re-asserts on every lock transition, so a hint cleared behind our back corrects itself. This is not the compositor owning locked; sessiond still decides and logind still holds truth. It is the opposite of an internal secure_session_locked() bool: it publishes the attestation into the place doctrine calls truth.

Verified on hardware 2026-08-02, which is the part that had never been done:

lock-before-blank   locked=False
lock-directive      locked=False
transition          locked=True     ← the lock landed inside LOCK_ACK_BUDGET
panel-off           locked=True     ← the blank went out with the lock held

No blank-without-lock. Before this the same test produced one on every attempt.

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.

Waking is the machine's too, and finding that out cost a wake loop

The panel had no way back. apply_gesture answers a power tap on a dark panel with Restore — which is brightness — and there was no unblank in the action table at all. That was invisible for as long as the wake never came through the daemon: hyprland.lua bound XF86PowerOff and XF86WakeUp straight to blueline-power-button / blueline-screen-toggle on, so the compositor woke the panel and sessiond only heard about it afterwards. viewtop consumes those keys on purpose — the control that locks the phone must not be interceptable by whatever is fullscreen — and the gap became a phone that could sleep and never wake. Measured 2026-08-02: panel_on: false, presses arriving and logged, no way back short of ssh.

Action::Unblank closes it, a power tap now returns [Unblank, Restore] in that order (the brightness a panel comes back at means nothing until the panel is back), and there is a screen verb so waking is something the agent can reach rather than a keybinding in a compositor config she has no say over. It is not a bypass: on is immediate, off routes through request_blank() like every other path.

The wake must not be performed by the compositor, and this is the part that is counter-intuitive enough to have been built wrong first. Waking directly from viewtop's input path worked — the log says "power button on a dark panel: waking" and the screen came back. Three seconds later sessiond resolved the same press into a Tap, looked at the panel, found it lit (because viewtop had just lit it), and did what a tap on a lit panel means: locked the session and blanked it again. One press woke the phone and put it back to sleep. That is §1's seven blind actors with the serial numbers filed off, reintroduced by the fix for a different bug. The compositor reports the edge; the authority decides; the executor acts.

dt2w is a wake and not a button, and the distinction is load-bearing: a double tap is the one wake a pocket can produce by itself, so it carries the proximity veto (suppress_wake) that a power button never gets — §4's table, "can it lie? no — hardware signal" for the button and yes for the tap. Reporting both through one verb would hand a pocket the authority of a deliberate press.

Deploy order is not optional. sessiond's Unblank must be on the device before a viewtop that no longer wakes the panel itself, or the window between them is a phone that cannot wake at all.

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.