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.
254 lines
14 KiB
Markdown
254 lines
14 KiB
Markdown
# 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 **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:
|
|
|
|
```text
|
|
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.
|