Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/services/SessiondBridge.qml
Fimeg 870d88210b shell: wait longer for shell_ready than sessiond takes to answer it
`shell_ready` blocks in the daemon for up to 5s waiting for its
lock-session thread to drop its Wayland connection, because the
compositor refuses a second locker while the first is alive
(server.rs `wait_timeout_while`). The shell's timer was 3s, so it gave
up on a handshake sessiond was still answering, assumed locked, and
asked for a lock that had not been released — straight into TASK-48's
"Tried to show lockscreen surfaces without active lock".

Under Hyprland the release usually lands in milliseconds and the race is
won. Measured against viewtop on blueline today it loses every time: the
shell crash-looped every 11 seconds and the session never came up.
Same latent bug, a compositor that exposes it.

7s, so the daemon's own deadline expires first. Its answer for that case
is a refusal (`lock session did not release in time`), and hearing a real
refusal beats inventing a verdict — "assuming locked" is the shell
holding state the authority owns.

The 5s retry timer only arms after this one fires, so nothing overlaps.
2026-08-02 11:15:44 -04:00

320 lines
15 KiB
QML

// Shell side of the souveraine-sessiond handoff protocol.
//
// sessiond takes ext-session-lock before the shell exists; this bridge is
// how the shell (a) announces itself and takes the lock over, (b) keeps the
// heartbeat connection open so sessiond can retake the lock the moment the
// shell dies, and (c) confirms the compositor-acked lock (locked_ack).
//
// The session is never unlocked during the handoff: sessiond abandons its
// lock (connection drop) and misc:allow_session_lock_restore lets our
// WlSessionLock inherit the locked session. See
// souveraine/src/sessiond/protocol.rs — that file is the contract.
//
// No sessiond on the socket (laptop, or bring-up) = everything no-ops and
// the legacy launchOnStartup path decides alone.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.modules.common.functions
Singleton {
id: root
// Only shell.qml calls claimAuthority(). Importing this singleton from a
// utility window must never create a session-authority connection.
property bool authorityScope: false
// Registered = shell_ready was answered ok on the CURRENT connection.
property bool registered: false
// Latched copy of `registered` taken when the connection drops. The
// disconnect branch clears `registered` before the reconnect branch runs,
// so reconnect cannot read it directly to decide whether to re-register.
property bool wasRegistered: false
// We owe the authority a registration that has not landed yet — set when a
// handshake times out or is refused because the lease is still held,
// cleared once one is answered. Drives registerRetry.
property bool needsRegistration: false
// Consecutive "already registered" refusals. Only for log cadence — the
// retry itself is unconditional, because the common cause is our own
// outgoing connection not having hit EOF yet.
property int refusalStreak: 0
// sessiond held the session lock when we registered; we owe it a lock
// and a locked_ack.
property bool oweLock: false
property bool ackSent: false
property var pendingReady: null // callback awaiting the shell_ready response
function claimAuthority() {
root.authorityScope = true
}
function load() {
if (!root.authorityScope)
console.log("[sessiond-bridge] inactive outside authority scope")
}
// Announce the shell. cb(mustLock) fires exactly once: mustLock true
// means sessiond was holding and the session IS locked — the shell must
// raise its own lock surface immediately.
//
// EVERY failure path here answers TRUE. Not knowing whether the session is
// locked is not the same as knowing it is not, and the two must never be
// collapsed: sessiond takes ext-session-lock before any shell surface can
// exist (protocol.rs §1), so a shell that cannot get an answer is a shell
// that arrived after something already locked the session. The daemon side
// of this contract is explicit — heartbeat EOF retakes the lock "whether or
// not the session was locked at the time. Fail closed." — and this side has
// to match it or the pair fails open at exactly the moment the authority is
// unreachable.
//
// Answering true costs an unnecessary lock surface the user dismisses with
// PAM. Answering false costs an unlocked phone.
function shellReady(cb) {
if (!root.authorityScope) {
// Not a fail-open: a utility QML process is not the session
// authority and has no lock to owe. Only shell.qml claims scope.
console.log("[sessiond-bridge] shell_ready refused outside authority scope")
cb(false)
return
}
if (!sock.connected) {
console.warn("[sessiond-bridge] shell_ready with no socket — assuming locked");
cb(true);
return;
}
if (root.pendingReady) {
// A handshake is already in flight; it will answer authoritatively.
// This duplicate assumes locked rather than racing it to "unlocked".
console.warn("[sessiond-bridge] duplicate shell_ready — assuming locked");
cb(true);
return;
}
root.pendingReady = cb;
readyTimeout.restart();
sock.write(JSON.stringify({ op: "shell_ready" }) + "\n");
sock.flush();
}
function sendLockedAck() {
if (!sock.connected || !root.registered || root.ackSent) return;
root.ackSent = true;
sock.write(JSON.stringify({ op: "locked_ack" }) + "\n");
sock.flush();
console.log("[sessiond-bridge] locked_ack sent");
}
Timer {
id: readyTimeout
// Longer than sessiond's own deadline, and that ordering is the whole
// point. `shell_ready` blocks in the daemon for up to 5 s waiting for
// its lock-session thread to drop its Wayland connection, because the
// compositor refuses a second locker while the first is alive
// (server.rs, `wait_timeout_while`). At 3 s this timer fired *first*,
// so the shell gave up on a handshake the daemon was still answering,
// assumed locked, and asked for a lock sessiond had not released yet —
// straight into TASK-48's `Tried to show lockscreen surfaces without
// active lock`.
//
// Under Hyprland that race is usually won: the release lands in
// milliseconds. Measured against viewtop on blueline 2026-08-02 it
// loses every time — the shell crash-looped every 11 s and never came
// up. Same latent bug, a compositor that exposes it.
//
// The daemon has an answer for this case and it is a refusal
// (`lock session did not release in time`). Waiting for a real refusal
// beats inventing a verdict: `assuming locked` is the shell holding
// state the authority owns, which is the failure doctrine §4 is about.
interval: 7000
repeat: false
onTriggered: {
if (root.pendingReady) {
// Was "proceeding without sessiond" with cb(false): a silent
// fail-open that left the session unlocked precisely when the
// authority was not answering. Assume locked, and keep trying —
// an unanswered handshake is a transient (sessiond restarting),
// not a verdict.
console.warn("[sessiond-bridge] shell_ready timed out — assuming locked, will retry");
const cb = root.pendingReady;
root.pendingReady = null;
root.needsRegistration = true;
cb(true);
}
}
}
// Register, and apply whatever the authority says we owe. One path, used by
// the reconnect handler and the retry timer alike, so the two can never
// drift into handling the answer differently.
function registerWithAuthority() {
root.shellReady(function(mustLock) {
if (!mustLock)
return;
// sessiond locked while we were disconnected (it treats our EOF as
// shell death), or we could not confirm and are failing closed.
GlobalStates.screenLocked = true;
// Our surface may ALREADY be secure from before the daemon
// restarted. onScreenLockSecureChanged is an edge, and that edge is
// in the past, so nothing would ever send the ack this new handoff
// owes — sessiond waits out its timer and retakes the lock
// ("shell never confirmed its lock after handoff").
if (GlobalStates.screenLockSecure)
root.sendLockedAck();
});
}
// A handshake that timed out is retried until it lands. Without this a
// shell that merely started while sessiond was restarting stays
// unregistered for its whole life: sessiond sees no heartbeat, believes the
// shell is dead, and raises its own fallback surface over ours forever.
Timer {
id: registerRetry
interval: 5000
repeat: true
running: root.authorityScope && root.needsRegistration
&& !root.registered && sock.connected
onTriggered: root.registerWithAuthority()
}
// Reconnect: sessiond may restart (upgrade) or start late. While
// connected this timer is idle; the Socket does not retry by itself.
Timer {
id: reconnect
interval: 5000
repeat: true
running: root.authorityScope && !sock.connected
onTriggered: sock.connected = true
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
connected: root.authorityScope
// `connectionStateChanged` is the real Signal on Quickshell.Io.Socket;
// the `onSocketConnected`/`onSocketDisconnected` handler-slots aren't
// reliably attachable across quickshell builds, so branch on
// `connected` here. Re-register on reconnect if we were registered
// before (sessiond restarted underneath us).
onConnectionStateChanged: {
if (sock.connected) {
console.log("[sessiond-bridge] connected");
const wasRegistered = root.wasRegistered;
root.registered = false;
root.wasRegistered = false;
root.ackSent = false;
if (wasRegistered || root.needsRegistration) {
root.registerWithAuthority();
}
} else {
console.log("[sessiond-bridge] disconnected");
root.wasRegistered = root.registered;
root.registered = false;
root.pendingReady = null;
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
console.log("[sessiond-bridge] unparseable reply: " + message);
return;
}
// Not every line is a reply. The authority pushes directives
// down this connection: it owns the decision, the shell owns
// the surface. A blank that needs a lock first arrives here
// (LOCK-DPMS-LESSONS §1 — lock, then off), and the daemon is
// holding the panel dark until this is answered.
if (reply.directive !== undefined) {
root.handleDirective(reply);
return;
}
if (root.pendingReady) {
// The only request we await a response for.
const cb = root.pendingReady;
root.pendingReady = null;
readyTimeout.stop();
if (reply.ok) {
root.registered = true;
root.needsRegistration = false;
root.refusalStreak = 0;
root.oweLock = reply.must_lock === true;
console.log("[sessiond-bridge] registered, must_lock=" + root.oweLock);
cb(root.oweLock);
} else {
// "already registered" means the lease is held right
// now. It does NOT mean it is held by someone else,
// and treating it as a verdict was a real bug: a scene
// RELOAD re-runs this file while the outgoing
// connection is still open, so the reload's shell_ready
// races its own predecessor's EOF and is refused. The
// old code then stopped retrying — and when that EOF
// landed a second later it cleared shell_alive, leaving
// sessiond believing there was no shell at all, for the
// life of the session. Observed 2026-07-29 09:14:35:
// refused, old socket closed 09:16:36, and the daemon
// reported shell_alive=false with a live shell on the
// other end of a connected socket.
//
// So it is a TRANSIENT. Keep registering. If the lease
// really is another live shell's, every retry is
// refused again and costs nothing — and the moment that
// shell dies we are the one that should hold it.
const leaseHeld =
String(reply.reason || "").indexOf("already registered") !== -1;
if (leaseHeld) {
root.needsRegistration = true;
root.refusalStreak += 1;
// Loud once, then once a minute: a lease that never
// frees is a real problem, but 12 lines a minute is
// how a real problem gets scrolled past.
if (root.refusalStreak === 1 || root.refusalStreak % 12 === 0)
console.warn("[sessiond-bridge] shell_ready refused: "
+ reply.reason + " — lease still held, retrying ("
+ root.refusalStreak + ")");
} else {
console.warn("[sessiond-bridge] shell_ready refused: "
+ reply.reason + " — assuming locked");
}
cb(!leaseHeld);
}
}
}
}
}
// Carry out an authority directive. The shell is the executor here, not a
// peer deciding whether it agrees: sessiond is the session authority and
// it has already withheld the panel waiting for this.
//
// Unknown directives are LOUD. A newer daemon asking for something this
// shell cannot do is a real divergence, and silently dropping it would
// leave the daemon waiting out its ack budget and then blanking unlocked.
function handleDirective(msg) {
if (msg.directive === "lock") {
console.log("[sessiond-bridge] authority directive: lock ("
+ (msg.why || "no reason given") + ")");
Session.lock();
return;
}
console.error("[sessiond-bridge] UNKNOWN authority directive: "
+ JSON.stringify(msg)
+ " — this shell is older than the daemon driving it");
}
// The compositor acknowledged OUR lock surface — tell sessiond the
// handoff is complete. Gated on secure, not the request, per doctrine.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (GlobalStates.screenLockSecure && root.oweLock && !root.ackSent) {
root.sendLockedAck();
}
}
}
}