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.
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 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.
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 level — apply() 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 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.