// 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") } // One short-lived request connection for power authority. Never put this // on `sock`: suspend can keep the daemon's synchronous request handler // occupied until resume, while the heartbeat connection must remain free // to carry locked_ack, directives and the EOF that means shell death. // // This is deliberately only the transport seam for now. Session.qml keeps // its legacy executor until the packaged daemon and its polkit subject have // been proven on each target. A locally accepted request returns pending; // the callback and powerFinished carry the daemon's eventual verdict. property var pendingPower: null property int powerRequestSequence: 0 signal powerFinished(string requestId, var reply) function requestPower(verb, callback) { const powerVerb = String(verb ?? ""); if (!["poweroff", "reboot", "suspend", "hibernate"].includes(powerVerb)) { return { ok: false, code: "unsupported", reason: "unsupported power verb: " + powerVerb }; } if (root.pendingPower !== null) { return { ok: false, code: "refused_by_state", reason: "another power request is already in flight" }; } root.powerRequestSequence += 1; const requestId = "power-" + Date.now() + "-" + root.powerRequestSequence; root.pendingPower = { requestId: requestId, verb: powerVerb, callback: typeof callback === "function" ? callback : null, sent: false }; powerConnectTimeout.restart(); powerSock.connected = true; return { ok: true, status: "pending", request_id: requestId }; } function _finishPower(reply) { if (root.pendingPower === null) return; const pending = root.pendingPower; root.pendingPower = null; powerConnectTimeout.stop(); const result = {}; for (const key in reply) result[key] = reply[key]; result.request_id = pending.requestId; // Clear our request before closing. The disconnect edge must not turn // a parsed refusal/acceptance into a second outcome_unknown callback. powerSock.connected = false; root.powerFinished(pending.requestId, result); if (pending.callback !== null) { try { pending.callback(result); } catch (error) { console.error("[sessiond-bridge] power callback failed: " + error); } } } Timer { id: powerConnectTimeout interval: 1500 repeat: false onTriggered: { if (root.pendingPower !== null && !root.pendingPower.sent) { root._finishPower({ ok: false, code: "unavailable", reason: "sessiond power socket unavailable" }); } } } Socket { id: powerSock path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock" onConnectionStateChanged: { if (powerSock.connected && root.pendingPower !== null && !root.pendingPower.sent) { const pending = root.pendingPower; root.pendingPower = { requestId: pending.requestId, verb: pending.verb, callback: pending.callback, sent: true }; powerConnectTimeout.stop(); powerSock.write(JSON.stringify({ op: "power", verb: pending.verb }) + "\n"); powerSock.flush(); } else if (!powerSock.connected && root.pendingPower !== null) { const sent = root.pendingPower.sent; root._finishPower(sent ? { ok: false, code: "outcome_unknown", status: "outcome_unknown", reason: "sessiond disconnected after the power request was sent" } : { ok: false, code: "unavailable", reason: "sessiond power socket unavailable" }); } } parser: SplitParser { splitMarker: "\n" onRead: message => { if (root.pendingPower === null) return; let reply; try { reply = JSON.parse(message); } catch (error) { root._finishPower({ ok: false, code: "outcome_unknown", status: "outcome_unknown", reason: "sessiond returned an unparseable power reply" }); return; } root._finishPower(reply); } } } // 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; } if (msg.directive === "accessory_presentation") { // sessiond sends this only after producer admission and content // classification. The shell projects it; it does not reinterpret // a Bluetooth observation into an authority decision. AccessoryPresentation.present(msg.presentation ?? {}); 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(); } } } }