diff --git a/src/sessiond/lockhint.rs b/src/sessiond/lockhint.rs index d2e70b7..538618e 100644 --- a/src/sessiond/lockhint.rs +++ b/src/sessiond/lockhint.rs @@ -60,6 +60,45 @@ pub fn resolve_session_path() -> Result { .map(|m| m.uid()) .context("reading our own uid")?; + // The seat's active session first — the one on the glass. + // + // `User.Display` is not that, and the difference is not academic. Measured + // 2026-08-02: an ssh login made `Display` resolve to a *remote* session + // with no seat, while the compositor sat on `seat0`/tty1. The hint was then + // written to one session and read from another, `LockedHint` never moved, + // and every blank took `request_blank`'s fail-open branch and darkened an + // unlocked panel. Display also went stale the moment that ssh session + // ended, so the resolver's answer depended on who happened to be logged in. + // + // A phone has one seat and the lock is about the panel on it, so ask the + // seat. `Session.qml` resolves the same way for the write side; the two + // must agree or this is a hint nobody reads. + // + // `(so) "76" "/org/freedesktop/login1/session/_376"` — the object path is + // the second quoted field, same shape as `Display` below. + if let Ok(out) = Command::new("busctl") + .args([ + "--system", + "get-property", + "org.freedesktop.login1", + "/org/freedesktop/login1/seat/seat0", + "org.freedesktop.login1.Seat", + "ActiveSession", + ]) + .output() + { + if out.status.success() { + let text = String::from_utf8_lossy(&out.stdout); + if let Some(p) = text + .split('"') + .nth(3) + .filter(|p| p.starts_with("/org/freedesktop/login1/session/")) + { + return Ok(p.to_string()); + } + } + } + // `(so) "1" "/org/freedesktop/login1/session/_31"` — the object path is the // second quoted field. let user_obj = format!("/org/freedesktop/login1/user/_{uid}"); diff --git a/surfaces/quickshell/modules/common/functions/Session.qml b/surfaces/quickshell/modules/common/functions/Session.qml index 5724f14..3bf9211 100644 --- a/surfaces/quickshell/modules/common/functions/Session.qml +++ b/surfaces/quickshell/modules/common/functions/Session.qml @@ -90,6 +90,30 @@ Singleton { if (root.hasLoginctl) lockedHintProc.report(root.locked); } + + // Replay the hint once the capability probe lands. + // + // `hasLoginctl` starts false and only becomes true when `capabilityProbe` + // returns, which is a `Process` round trip. The lock goes secure long + // before that: measured 2026-08-02, `secure=true` at 15:21:07 and + // `loginctl=true` at 15:21:37 — thirty seconds later. The one edge that + // mattered was therefore dropped by the guard above and never retried, + // because the shell locks once at boot and `locked` never changes again. + // + // The cost was the whole lock-before-blank invariant. `LockedHint` stayed + // `no`, so sessiond's `locked` (which comes from logind, doctrine §4) was + // permanently false, `request_blank()` timed out its `LOCK_ACK_BUDGET` + // every time, and the panel blanked on a session nobody could confirm was + // locked — `blank-without-lock` on every single blank. + // + // This is the fourth edge-vs-level bug in this system after `locked_ack`, + // `ChargeRate` and `bootBloomActive`. A guard that drops a report must + // replay it when the guard opens, or the report is only ever delivered by + // luck of ordering. + onHasLoginctlChanged: { + if (root.hasLoginctl) + lockedHintProc.report(root.locked); + } Process { id: lockedHintProc property bool pending: false @@ -101,10 +125,49 @@ Singleton { pendingValue = value; return; } - command = ["busctl", "call", "org.freedesktop.login1", - "/org/freedesktop/login1/session/auto", - "org.freedesktop.login1.Session", - "SetLockedHint", "b", value ? "true" : "false"]; + // Resolve the session the way `lockhint.rs` does — the user's + // Display session — and never through the `/session/auto` alias. + // + // `auto` means *the caller's own* session, and the shell is not in + // the session that owns the seat. Measured 2026-08-02: viewtop in + // logind session 66 (seat0, tty1, the one `User.Display` names and + // the one sessiond watches), `qs -c souveraine` in session 70. So + // this call was setting the hint on a session nobody reads, while + // the graphical session's `LockedHint` stayed `no` forever. + // + // The consequence was not cosmetic. sessiond takes `locked` from + // `LockedHint` (doctrine §4), so `locked` was permanently false; + // `request_blank()` therefore timed out its `LOCK_ACK_BUDGET` on + // every single blank and took the fail-open branch, darkening the + // panel on a session it could not confirm was locked and writing + // `blank-without-lock` each time. LOCK-DPMS-LESSONS §1's ordering + // held in the code and not on the device. + // + // One shell round trip rather than two Processes: the path has to + // be resolved at report time, because the session id changes across + // a greetd restart and a cached one would be stale exactly when it + // matters. `$(…)` is fine here — the value is a busctl-printed + // object path, not user input. + // The seat's active session — the one on the glass — with + // `User.Display` only as a fallback. `lockhint.rs` resolves the + // same way on the read side, and the two must agree or this writes + // a hint nobody reads. An ssh login is enough to make `Display` + // name a seatless remote session, which is exactly how this was + // found. + command = ["sh", "-c", + "p=$(busctl get-property org.freedesktop.login1 " + + "/org/freedesktop/login1/seat/seat0 " + + "org.freedesktop.login1.Seat ActiveSession " + + "| grep -o '/org/freedesktop/login1/session/[^\"]*'); " + + "[ -n \"$p\" ] || p=$(busctl get-property " + + "org.freedesktop.login1 " + + "/org/freedesktop/login1/user/_$(id -u) " + + "org.freedesktop.login1.User Display " + + "| grep -o '/org/freedesktop/login1/session/[^\"]*'); " + + "[ -n \"$p\" ] || exit 1; " + + "exec busctl call org.freedesktop.login1 \"$p\" " + + "org.freedesktop.login1.Session SetLockedHint b " + + (value ? "true" : "false")]; running = true; } onExited: {