git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit48c0710f53e353b502e921e3648c6a634886ce4d
parent99650522d7
authorLucas Galante <[email protected]>
date2026-09-19 07:31
A locked session gets its locker back

When a lock client dies without unlocking, the session correctly stays
locked — that is ext-session-lock-v1's whole point and nothing here
changes it. But the screen was then a dead end: locked, blank, and with
no process left to type a password into, so the only way back in was a
TTY and a kill.

Everything needed was already here except the spawn. `handle_new_lock`
has always accepted a fresh client for an already-locked session and
sends it `locked` immediately ("given control of already locked
session"); nothing ever started one. So `handle_destroy` now schedules
one, but only when it was reached because the client vanished — when
`handle_unlock` calls it the state is already Unlocked and nothing
happens.

Backed off 200/500/1000/2000/5000ms and then abandoned, because the bad
case is a locker that dies during startup and respawning that at full
speed is a fork bomb aimed at someone who cannot see the screen. Giving
up is safe: the session stays locked, which is where it already was,
and the error says how to get back in from a TTY. The budget resets
when a locker draws a surface, so a working locker killed twice gets a
full allowance each time while a crash loop still exhausts it.

The arm after a successful fork is the part worth keeping: a forked
child proves nothing, and the likeliest real failure — no PAM stack, no
GPU, binary missing — dies before creating a wlr_session_lock_v1, so no
destroy event is coming to drive another attempt. Without it the single
attempt failed silently. `handle_new_lock` cancels the armed check when
a client binds, so the success path spawns exactly one.

Verified against a tree-built cce-fx in throwaway shadows, never the
live session: kill the locker and a replacement binds within ~200ms
with no `session unlocked` in between; kill it again and the budget has
reset to attempt 1; plant a locker that exits immediately and it backs
off through all five steps and gives up with the TTY instructions, the
session still locked throughout.

 src/server/lock_manager.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 167 insertions(+)

diff --git a/src/server/lock_manager.rs b/src/server/lock_manager.rs
index 642876a..8156df7 100644
--- a/src/server/lock_manager.rs
+++ b/src/server/lock_manager.rs
@@ -6,11 +6,39 @@ use crate::server::{Server, WlListener, wl_signal_add, wl_listener_remove, WlLis
 use crate::scene_node_data::{SceneNodeData, SceneNodeDataVal};
 use crate::seat::Focus;
 
+/// Delay before each successive attempt to bring the locker back, in ms. The
+/// length of this is also the attempt limit.
+///
+/// It backs off because the bad case is a locker that dies during startup:
+/// respawning that at full speed is a fork bomb against a user who cannot see
+/// the screen. It gives up rather than retrying forever for the same reason,
+/// and giving up is safe — the session simply stays locked, which is where it
+/// already was.
+const RESPAWN_BACKOFF_MS: [i32; 5] = [200, 500, 1000, 2000, 5000];
+
+/// The locker to run. Mirrors `cce_cloud_cmd()`: prefer the installed path,
+/// fall back to the bare name on PATH.
+fn cce_lock_cmd() -> String {
+    if let Ok(home) = std::env::var("HOME") {
+        let path = format!("{}/.local/bin/cce-lock", home);
+        if std::path::Path::new(&path).exists() {
+            return path;
+        }
+    }
+    "cce-lock".to_string()
+}
+
 pub struct LockManager {
     pub wlr_manager: *mut ffi::wlr_session_lock_manager_v1,
     pub state: LockState,
     pub lock: *mut ffi::wlr_session_lock_v1,
     pub lock_surfaces_timer: *mut ffi::wl_event_source,
+    /// Fires to bring a locker back after one died mid-lock; see
+    /// [`LockManager::schedule_locker_respawn`].
+    pub respawn_timer: *mut ffi::wl_event_source,
+    /// Respawns since the last locker that got as far as drawing. Indexes
+    /// [`RESPAWN_BACKOFF_MS`]; past its end, we stop trying.
+    pub respawn_attempts: usize,
     pub server: *mut Server,
 
     pub new_lock: ffi::wl_listener,
@@ -55,6 +83,16 @@ impl LockManager {
         }
         self.lock_surfaces_timer = timer;
 
+        let respawn_timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_respawn_timeout),
+            self as *mut LockManager as *mut _,
+        );
+        if respawn_timer.is_null() {
+            return Err("Failed to create the locker respawn timer");
+        }
+        self.respawn_timer = respawn_timer;
+
         let new_lock_ptr = &mut self.new_lock as *mut ffi::wl_listener as *mut WlListener;
         (*new_lock_ptr).notify = Some(handle_new_lock);
         wl_signal_add(&mut (*self.wlr_manager).events.new_lock, &mut self.new_lock);
@@ -67,6 +105,10 @@ impl LockManager {
             ffi::wl_event_source_remove(self.lock_surfaces_timer);
             self.lock_surfaces_timer = std::ptr::null_mut();
         }
+        if !self.respawn_timer.is_null() {
+            ffi::wl_event_source_remove(self.respawn_timer);
+            self.respawn_timer = std::ptr::null_mut();
+        }
         wl_listener_remove(&mut self.new_lock);
     }
 
@@ -146,6 +188,58 @@ impl LockManager {
         self.state = LockState::Locked;
         (*self.server).wm.dirty_windowing();
     }
+
+    /// Bring a locker back after the one holding the session went away
+    /// without unlocking.
+    ///
+    /// The session stays locked when a locker dies — that is the protocol's
+    /// guarantee and this does not weaken it. What it fixes is that the
+    /// screen was then a dead end: locked, blank, with no process left to
+    /// type a password into, so the only way back into the session was a TTY
+    /// and a kill. `handle_new_lock` already knows how to hand an
+    /// already-locked session to a fresh client ("given control of already
+    /// locked session"); nothing ever started one.
+    ///
+    /// Backed off and capped by [`RESPAWN_BACKOFF_MS`]. Exhausting it leaves
+    /// the session exactly as it is now — locked — which is why giving up is
+    /// an acceptable outcome and looping forever is not.
+    unsafe fn schedule_locker_respawn(&mut self) {
+        if self.respawn_timer.is_null() {
+            return;
+        }
+        let Some(&delay) = RESPAWN_BACKOFF_MS.get(self.respawn_attempts) else {
+            log::error!(
+                "the locker died {} times without drawing; giving up. The session \
+                 STAYS LOCKED and there is no prompt to type into — switch to a TTY \
+                 (ctrl+alt+F2), log in, and run `cce-lock` against this display, or \
+                 kill the session.",
+                self.respawn_attempts
+            );
+            return;
+        };
+        self.respawn_attempts += 1;
+        // Worded for both callers: the one after a locker vanished, and the
+        // one right after a spawn that arms this as a "did it take?" check.
+        // The check is the common case and is cancelled by `handle_new_lock`
+        // without ever firing, so this must not promise a respawn outright.
+        log::warn!(
+            "no lock client for a locked session; starting one in {}ms unless one \
+             binds first (attempt {} of {})",
+            delay,
+            self.respawn_attempts,
+            RESPAWN_BACKOFF_MS.len()
+        );
+        ffi::wl_event_source_timer_update(self.respawn_timer, delay);
+    }
+
+    /// Stop a respawn that is armed but no longer wanted — a locker is here.
+    /// Without this, a timer armed during the gap could fire after the
+    /// session was unlocked and lock it again out of nowhere.
+    unsafe fn cancel_locker_respawn(&mut self) {
+        if !self.respawn_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.respawn_timer, 0);
+        }
+    }
 }
 
 pub struct LockSurface {
@@ -278,6 +372,57 @@ unsafe extern "C" fn handle_lock_surfaces_timeout(data: *mut std::ffi::c_void) -
     0
 }
 
+/// Start a locker for a session that is locked and has none.
+///
+/// Forked and detached like every other client the compositor starts (the
+/// server's SIGCHLD source reaps it). The new process calls
+/// `ext_session_lock_manager_v1.lock()` and `handle_new_lock` hands it the
+/// session that is already locked, so the screen never unlocks across the
+/// gap — the user just gets a prompt back.
+unsafe extern "C" fn handle_respawn_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let manager = &mut *(data as *mut LockManager);
+
+    // The world may have moved while the timer was armed: a locker of the
+    // user's own may have attached, or the session may be unlocked. Either
+    // way, spawning now would seize a session nobody asked us to.
+    if manager.state == LockState::Unlocked || !manager.lock.is_null() {
+        return 0;
+    }
+
+    let cmd = cce_lock_cmd();
+    log::warn!("respawning the locker: {}", cmd);
+    match nix::unistd::fork() {
+        Ok(nix::unistd::ForkResult::Child) => {
+            crate::process::cleanup_child();
+            let sh = std::ffi::CString::new("/bin/sh").unwrap();
+            let dash_c = std::ffi::CString::new("-c").unwrap();
+            let cmd_c = std::ffi::CString::new(cmd)
+                .unwrap_or_else(|_| std::ffi::CString::new("true").unwrap());
+            let args = [sh.as_c_str(), dash_c.as_c_str(), cmd_c.as_c_str()];
+            let _ = nix::unistd::execv(&sh, &args);
+            std::process::exit(1);
+        }
+        Ok(nix::unistd::ForkResult::Parent { .. }) => {
+            // Arm the NEXT step as a "did it take?" check, and let
+            // `handle_new_lock` cancel it when the new locker binds. A
+            // forked child proves nothing: the likeliest real failure is a
+            // locker that cannot start at all — no PAM stack, no GPU, binary
+            // missing — and that one dies without ever creating a
+            // `wlr_session_lock_v1`, so no destroy event is coming to
+            // trigger another attempt. Without this, the single attempt
+            // failed silently and the user stayed locked out with nothing in
+            // the log to say why.
+            manager.schedule_locker_respawn();
+        }
+        Err(e) => {
+            log::error!("failed to fork the locker respawn: {}", e);
+            manager.schedule_locker_respawn();
+        }
+    }
+
+    0
+}
+
 unsafe extern "C" fn handle_new_lock(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let manager = &mut *crate::container_of!(listener, LockManager, new_lock);
     let lock = data as *mut ffi::wlr_session_lock_v1;
@@ -292,6 +437,11 @@ unsafe extern "C" fn handle_new_lock(listener: *mut ffi::wl_listener, data: *mut
 
     manager.lock = lock;
 
+    // Someone is holding the session now — whether that is the respawn we
+    // asked for or a locker the user started themselves, we must not spawn
+    // another on top of it.
+    manager.cancel_locker_respawn();
+
     if manager.state == LockState::Unlocked {
         manager.state = LockState::WaitingForLockSurfaces;
 
@@ -333,6 +483,11 @@ unsafe extern "C" fn handle_unlock(listener: *mut ffi::wl_listener, _data: *mut
     manager.state = LockState::Unlocked;
     log::info!("session unlocked");
 
+    // The session is going away legitimately: no respawn is wanted, and the
+    // next lock starts with a full budget.
+    manager.cancel_locker_respawn();
+    manager.respawn_attempts = 0;
+
     ffi::wlr_scene_node_set_enabled((*manager.server).scene.normal_tree as *mut ffi::wlr_scene_node, true);
     ffi::wlr_scene_node_set_enabled((*manager.server).scene.locked_tree as *mut ffi::wlr_scene_node, false);
 
@@ -364,6 +519,14 @@ unsafe extern "C" fn handle_destroy(listener: *mut ffi::wl_listener, _data: *mut
         manager.state = LockState::WaitingForBlank;
         ffi::wl_event_source_timer_update(manager.lock_surfaces_timer, 0);
     }
+
+    // Reached two ways: from `handle_unlock`, which has already set the state
+    // to Unlocked and is just tearing down; or from wlroots because the lock
+    // client died. Only the second leaves the user facing a locked screen
+    // with nothing to authenticate against.
+    if manager.state != LockState::Unlocked {
+        manager.schedule_locker_respawn();
+    }
 }
 
 unsafe extern "C" fn handle_surface(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
@@ -372,6 +535,10 @@ unsafe extern "C" fn handle_surface(listener: *mut ffi::wl_listener, data: *mut
 
     log::debug!("new ext_session_lock_surface_v1 created");
 
+    // Far enough to put something on screen, so this is not the startup crash
+    // loop the cap exists for: give the next failure a full budget again.
+    manager.respawn_attempts = 0;
+
     assert!(manager.state != LockState::Unlocked);
     assert!(!manager.lock.is_null());