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.
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 lockIPC → 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()indevice_state.rs, which locks first, waitsLOCK_ACK_BUDGET(2s) for the compositor ack, and blanks anyway on timeout while recording anerror-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:
- greetd ran the desktop as a greeter.
config.tomlpoints bothinitial_sessionanddefault_sessionatsouveraine-sessionon purpose (there is no greeter here). Butdefault_sessionis greetd's greeter slot — it stampsXDG_SESSION_CLASS=greeter, and logind then refusesSetLockedHintoutright: "Session does not support lock screen."initial_sessiononly runs while/run/greetd.runis absent, so the first start after a cold boot gotClass=userand every restart after that got a greeter session. Fixed by clearing the runfile before each start (Pixel3Archd37ab4d); asystemctl restart greetdno longer silently downgrades the phone into "can never lock". - The hint was written to a different session than it was read from.
Session.qmlused/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),qsin 70.lockhint.rsread viaUser.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 resolveseat0'sActiveSession. - The one report that mattered was dropped.
onLockedChangedis guarded byhasLoginctl, which starts false and only flips when aProcessprobe returns — measured 30 s aftersecure=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 afterlocked_ack,ChargeRateandbootBloomActive; 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 carrierin the journal during suspend entry is normal, not a crash. Do not chase gadget "flaps" without first checkingjournalctlforPM: 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_delaydefault (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) andscreenLockSecure(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 resyncsscreenLockedwhen that happens (LockScreen.qml onLockStateChanged) — before that fix, a stalelockRequested:trueboth 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.surfaceComponentcannot 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_cmdfires thelockFocusglobal 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.