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

commit2254827edba8df81a952da97a4072a86815eca76
parent92c41f5c1b
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 21:46
Lock the session before every sleep, and run no keybinds while locked

Nothing ever started the lock screen. The only code that launched cce-lock
was the respawn timer, which runs only for a session that is already
locked. The idle timeout's sleep and logind's sleeps (lid close, power key,
systemctl suspend) all suspended an UNLOCKED session, so whoever opened
the laptop got the desktop.

LockManager::lock_now locks from the compositor's side with no client: the
normal tree goes off, each output renders the blank locked tree, and the
respawn timer starts cce-lock. That binds through handle_new_lock's
already-locked branch. A locker that never comes leaves the session locked,
as the crash path already guaranteed.
- The idle sleep and `ccectl idle sleep` lock, then sleep once the session
  is locked (or after 3s regardless, with the desktop already hidden).
  Activity in between cancels the sleep but keeps the lock.
- sleep_lock.rs holds a logind delay inhibitor. On PrepareForSleep(true)
  it sends the new `lock` control command, whose reply waits for the lock
  to complete, then releases the inhibitor. It runs on real seats only.

handle_group_key never asked about the lock either. At the lock screen,
super+q closed the window behind it and any spawn bind ran. While locked
it now runs only VT switches and config keybinds on volume, brightness and
media keys, and never an input-method grab (which would read the password).

Verified in a shadow, with sleep_command set to a file touch:
- `ccectl lock` replies "ok locked" and cce-lock takes over.
- An F12 spawn bind fires unlocked but not locked (it also fired locked on
  the build without this change).
- Typed keys reach the lock prompt.
- The idle sleep locks before its command runs, with the display on and
  with it already off.
- Waking the display shows the lock prompt.
The logind half, which a shadow cannot reach, has an opt-in test
(--ignored) that takes and drops a real delay inhibitor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                    |  20 +++++++
 Cargo.lock                   |   1 +
 Cargo.toml                   |   3 ++
 src/cce_ctl.rs               |   3 +-
 src/lib.rs                   |   2 +
 src/server/idle.rs           |  77 +++++++++++++++++++++++++-
 src/server/ipc_server.rs     |   7 ++-
 src/server/keyboard_group.rs |  86 +++++++++++++++++++++++++++--
 src/server/lock_manager.rs   |  80 ++++++++++++++++++++++++++-
 src/server/sleep_lock.rs     | 126 +++++++++++++++++++++++++++++++++++++++++++
 src/server/window_manager.rs |  19 +++++++
 11 files changed, 416 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 50f53a56..d0eff8e2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -790,6 +790,26 @@ then `ccectl outputs` reads `enabled=false`, then any injected input reads
 `true`). `ccectl idle` prints the state; `idle wake|sleep|display on|off` act
 now. Untested in a shadow, which has no session: the resume wake.
 
+**Every sleep locks first** (since 2026-10-01; before, nothing ever started
+the lock screen and a laptop woke to its desktop). `LockManager::lock_now` locks
+from the compositor's side with no client yet — the normal tree off, each output
+rendering the blank locked tree — and then starts `cce-lock` through the
+respawn timer, which binds via `handle_new_lock`'s already-locked branch; a
+locker that never comes leaves the session locked, the crash path's guarantee.
+The idle sleep and `ccectl idle sleep` go through `IdleManager::lock_then_sleep`
+(sleep on `on_locked`, or after `LOCK_BEFORE_SLEEP_MS` regardless; activity in
+between calls the sleep off and keeps the lock). logind's own sleeps — lid,
+power key, `systemctl suspend` — are `sleep_lock.rs`: a thread holding a
+`delay` sleep inhibitor that answers `PrepareForSleep(true)` with `ccectl
+lock` (whose reply waits for `send_locked`) and then lets logind go. Real seats
+only; a shadow has no session and starts no thread, so test `ccectl lock` and
+`idle sleep` there (give the shadow a harmless `idle { sleep_command }` first —
+its seeded config's default is `systemctl suspend`, the REAL machine) and the
+logind half with `cargo test --lib sleep_lock -- --ignored`. While locked,
+`handle_group_key` runs no binding but VT switches and config keybinds on
+volume/brightness/media keys (`allowed_while_locked`), and no input-method
+grab; every other key goes to the lock surface.
+
 Persistent window state is saved to **`~/.local/state/cce/state.json`**
 (`XDG_STATE_HOME/cce/state.json`) on shutdown and restored on start
 (`save_state` / `load_state` / `spawn_restored_windows`). A window's
diff --git a/Cargo.lock b/Cargo.lock
index bec3a4b5..b498179e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -449,6 +449,7 @@ dependencies = [
  "serde_json",
  "tokio",
  "xkbcommon",
+ "zbus",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 04a1c7f3..04790631 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,9 @@ xkbcommon = "0.7"
 serde = { version = "1", features = ["derive"] }
 nix = { version = "0.29", features = ["signal", "process", "fs", "poll", "resource"] }
 libc = "0.2"
+# logind's sleep-delay inhibitor (`sleep_lock`). Already in the tree through
+# cce-ui (rfd -> ashpd), so this adds the blocking API, not a dependency.
+zbus = "5"
 bitflags = "2"
 tokio = { version = "1.35", features = ["full"] }
 serde_json = "1.0"
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index eb2bb731..5d7da17b 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -65,8 +65,9 @@ fn usage(name: &str, to_stderr: bool) {
     print("  windows [--json]           # list windows; --json emits one JSON object per line");
     print("  outputs [--json]           # list outputs: mode, scale, logical size, physical mm, px/mm, and where the mm came from");
     print("  idle [status]              # idle timeouts in force, idle time, inhibited and by whom (app ids), displays_off/sleeping, and the power plan's override of each (plan_*)");
-    print("  idle wake|sleep|display on|display off   # act now: wake darkened outputs, run the sleep command, darken/wake outputs");
+    print("  idle wake|sleep|display on|display off   # act now: wake darkened outputs, lock, then run the sleep command; darken/wake outputs");
     print("  idle timeouts <display_off_s> <sleep_s>  # set the config timeouts live (0 = off); config.kdl `idle { }` on reload; a Power-plan file under /run/cce overrides either while it exists");
+    print("  lock                       # lock the session now (cce-lock takes the prompt); replies `ok locked` once every output shows the locked scene");
     print("  status-hide-mode [true|false]");
     print("  adjust-position-mode [true|false|query]");
     print("  exit [force]               # log out; waits for windows to close, cancels if one stays (a save prompt); force skips the wait");
diff --git a/src/lib.rs b/src/lib.rs
index 41f49f83..7b235344 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -63,6 +63,8 @@ pub mod idle_inhibit_manager;
 pub mod idle;
 #[path = "server/lock_manager.rs"]
 pub mod lock_manager;
+#[path = "server/sleep_lock.rs"]
+pub mod sleep_lock;
 #[path = "server/input_device.rs"]
 pub mod input_device;
 #[path = "server/pointer_constraint.rs"]
diff --git a/src/server/idle.rs b/src/server/idle.rs
index 1309cb95..bf810c2a 100644
--- a/src/server/idle.rs
+++ b/src/server/idle.rs
@@ -36,6 +36,13 @@ use crate::output::{Output, OutputStateValue};
 
 pub const DEFAULT_SLEEP_COMMAND: &str = "systemctl suspend";
 
+/// How long the sleep waits for the session to finish locking before it
+/// goes ahead anyway. The desktop is hidden from the moment the lock starts
+/// (`LockManager::lock_now`), so a lock still settling at that point is a
+/// blank screen, not an open one; staying awake forever on a wedged output
+/// would be the worse failure.
+const LOCK_BEFORE_SLEEP_MS: i32 = 3000;
+
 /// The `idle { }` block, in seconds; 0 disables a timeout.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct IdleConfig {
@@ -136,6 +143,10 @@ pub struct IdleManager {
     displays_off: bool,
     /// The sleep command was spawned; cleared by the next activity.
     sleeping: bool,
+    /// A sleep is waiting for the session lock to complete (`on_locked`),
+    /// or for `lock_fallback_timer`, whichever comes first.
+    sleep_after_lock: bool,
+    lock_fallback_timer: *mut ffi::wl_event_source,
     /// Monotonic ms of the last (re)arm and of the last activity.
     armed_at_ms: u64,
     last_activity_ms: u64,
@@ -170,6 +181,18 @@ impl IdleManager {
             self.display_timer = std::ptr::null_mut();
             return Err("Failed to create idle sleep timer");
         }
+        self.lock_fallback_timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_lock_fallback),
+            self as *mut IdleManager as *mut _,
+        );
+        if self.lock_fallback_timer.is_null() {
+            ffi::wl_event_source_remove(self.display_timer);
+            ffi::wl_event_source_remove(self.sleep_timer);
+            self.display_timer = std::ptr::null_mut();
+            self.sleep_timer = std::ptr::null_mut();
+            return Err("Failed to create idle lock-fallback timer");
+        }
         self.plan_timer = ffi::wl_event_loop_add_timer(
             event_loop,
             Some(handle_plan_poll),
@@ -195,6 +218,7 @@ impl IdleManager {
         self.inhibitors = None;
         self.displays_off = false;
         self.sleeping = false;
+        self.sleep_after_lock = false;
         self.armed_at_ms = 0;
         self.last_activity_ms = now_ms();
 
@@ -226,6 +250,10 @@ impl IdleManager {
             ffi::wl_event_source_remove(self.plan_timer);
             self.plan_timer = std::ptr::null_mut();
         }
+        if !self.lock_fallback_timer.is_null() {
+            ffi::wl_event_source_remove(self.lock_fallback_timer);
+            self.lock_fallback_timer = std::ptr::null_mut();
+        }
     }
 
     /// Apply an `idle { }` block (config load and `ccectl reload`). A plan
@@ -306,6 +334,13 @@ impl IdleManager {
             self.set_displays(true);
         }
         self.sleeping = false;
+        // Someone is here: a sleep still waiting for its lock is called off.
+        // The lock itself stands.
+        if self.sleep_after_lock {
+            self.sleep_after_lock = false;
+            ffi::wl_event_source_timer_update(self.lock_fallback_timer, 0);
+            log::info!("idle: activity while locking; the sleep is off, the lock stays");
+        }
         self.rearm(changed);
     }
 
@@ -416,6 +451,36 @@ impl IdleManager {
         }
     }
 
+    /// Lock the session, then sleep once it is locked.
+    ///
+    /// Every compositor-initiated sleep goes through here (the idle timeout,
+    /// `ccectl idle sleep`): a sleep used to run with the session unlocked,
+    /// so the desktop was there for whoever woke the machine. A lid close is
+    /// logind's sleep, not ours, and is covered by `sleep_lock`.
+    pub unsafe fn lock_then_sleep(&mut self) {
+        if self.sleeping || self.sleep_after_lock {
+            return;
+        }
+        let lock = &mut (*self.server).lock_manager;
+        lock.lock_now();
+        if lock.state == crate::lock_manager::LockState::Locked {
+            self.sleep_now();
+            return;
+        }
+        log::info!("idle: locking before sleep");
+        self.sleep_after_lock = true;
+        ffi::wl_event_source_timer_update(self.lock_fallback_timer, LOCK_BEFORE_SLEEP_MS);
+    }
+
+    /// The session finished locking (`LockManager::send_locked`).
+    pub unsafe fn on_locked(&mut self) {
+        if self.sleep_after_lock {
+            self.sleep_after_lock = false;
+            ffi::wl_event_source_timer_update(self.lock_fallback_timer, 0);
+            self.sleep_now();
+        }
+    }
+
     /// `ccectl idle` report.
     pub fn status(&self) -> String {
         let idle_s = now_ms().saturating_sub(self.last_activity_ms) / 1000;
@@ -451,7 +516,7 @@ impl IdleManager {
                 "ok\n".to_string()
             }
             ["sleep"] => {
-                self.sleep_now();
+                self.lock_then_sleep();
                 "ok\n".to_string()
             }
             ["timeouts", display, sleep] => {
@@ -505,6 +570,16 @@ unsafe extern "C" fn handle_sleep_timeout(data: *mut std::ffi::c_void) -> std::o
     let idle = &mut *(data as *mut IdleManager);
     if !idle.inhibited && !idle.sleeping {
         log::info!("idle: sleep timeout reached");
+        idle.lock_then_sleep();
+    }
+    0
+}
+
+unsafe extern "C" fn handle_lock_fallback(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let idle = &mut *(data as *mut IdleManager);
+    if idle.sleep_after_lock {
+        log::warn!("idle: the lock did not complete in {}ms; sleeping anyway (the desktop is already hidden)", LOCK_BEFORE_SLEEP_MS);
+        idle.sleep_after_lock = false;
         idle.sleep_now();
     }
     0
diff --git a/src/server/ipc_server.rs b/src/server/ipc_server.rs
index 9964f7f3..55cab3e8 100644
--- a/src/server/ipc_server.rs
+++ b/src/server/ipc_server.rs
@@ -67,7 +67,7 @@ pub fn new_wake_fd() -> std::io::Result<Arc<OwnedFd>> {
     Ok(Arc::new(unsafe { OwnedFd::from_raw_fd(raw) }))
 }
 
-fn get_ipc_socket_path(display_socket: Option<&str>) -> String {
+pub fn get_ipc_socket_path(display_socket: Option<&str>) -> String {
     if let Some(display) = display_socket {
         format!("/tmp/cce-{}.sock", display)
     } else {
@@ -167,8 +167,13 @@ fn handle_client(mut stream: UnixStream, tx: IpcSender) {
                 // spell — NVIDIA recompiles shaders on the way) has been
                 // measured over a second. Timing that out would report
                 // failure for a capture that lands.
+                // `lock` answers once the session IS locked: a frame on every
+                // output plus a locker starting, and the lock-before-sleep
+                // thread holds logind's sleep (5s at most) on that reply.
                 let timeout = if cmd.starts_with("screenshot") {
                     std::time::Duration::from_secs(5)
+                } else if cmd == "lock" {
+                    std::time::Duration::from_secs(4)
                 } else {
                     std::time::Duration::from_millis(1000)
                 };
diff --git a/src/server/keyboard_group.rs b/src/server/keyboard_group.rs
index ec6395e4..21520a07 100644
--- a/src/server/keyboard_group.rs
+++ b/src/server/keyboard_group.rs
@@ -280,6 +280,38 @@ impl KeyboardGroup {
     }
 }
 
+fn is_vt_switch(keysym: u32) -> bool {
+    (ffi::XKB_KEY_XF86Switch_VT_1..=ffi::XKB_KEY_XF86Switch_VT_12).contains(&keysym)
+}
+
+/// The keys that still act while the session is locked. Everything else —
+/// every config keybind (close window, spawn, reload), portal shortcut,
+/// client-registered xkb binding and input-method grab — goes to the lock
+/// screen instead. Until 2026-10-01 nothing here asked about the lock, so at
+/// the lock screen super+q closed the focused window behind it and any
+/// `spawn` bind ran. What stays: switching VTs (a builtin), and a config
+/// keybind on a volume, brightness or media key, the controls a locked
+/// laptop still needs — the same set other compositors bind `--locked`.
+fn allowed_while_locked(keysym: u32) -> bool {
+    is_vt_switch(keysym)
+        || matches!(
+            keysym,
+            ffi::XKB_KEY_XF86AudioRaiseVolume
+                | ffi::XKB_KEY_XF86AudioLowerVolume
+                | ffi::XKB_KEY_XF86AudioMute
+                | ffi::XKB_KEY_XF86AudioMicMute
+                | ffi::XKB_KEY_XF86AudioPlay
+                | ffi::XKB_KEY_XF86AudioPause
+                | ffi::XKB_KEY_XF86AudioStop
+                | ffi::XKB_KEY_XF86AudioNext
+                | ffi::XKB_KEY_XF86AudioPrev
+                | ffi::XKB_KEY_XF86MonBrightnessUp
+                | ffi::XKB_KEY_XF86MonBrightnessDown
+                | ffi::XKB_KEY_XF86KbdBrightnessUp
+                | ffi::XKB_KEY_XF86KbdBrightnessDown
+        )
+}
+
 unsafe fn handle_builtin_binding(seat: *mut Seat, keysym: u32, modifiers: u32) -> bool {
     match keysym {
         ffi::XKB_KEY_XF86Switch_VT_1..=ffi::XKB_KEY_XF86Switch_VT_12 => {
@@ -398,6 +430,9 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
     } else {
         let xkb_keycode = (*event).keycode + 8;
         let modifiers = ffi::wlr_keyboard_get_modifiers(&mut group.wlr_keyboard);
+        // While the session is locked (or locking) a key is the lock
+        // screen's: see `allowed_while_locked` for the few that still act.
+        let locked = (*(*group.seat).server).lock_manager.state != crate::lock_manager::LockState::Unlocked;
         
         let mut matched_builtin = false;
         let mut syms_ptr: *const ffi::xkb_keysym_t = std::ptr::null();
@@ -407,6 +442,9 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
             let syms = std::slice::from_raw_parts(syms_ptr, num_syms as usize);
             for &sym in syms {
                 log::debug!("  keysym={:#x}", sym);
+                if locked && !is_vt_switch(sym) {
+                    continue;
+                }
                 if handle_builtin_binding(group.seat, sym, modifiers) {
                     matched_builtin = true;
                     break;
@@ -416,13 +454,21 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
 
         if matched_builtin {
             KeyConsumer::Builtin
-        } else if let Some(kb) = match_cce_keybind(&(*(*group.seat).server).wm, xkb_keycode, modifiers, xkb_state) {
+        } else if let Some(kb) = match_cce_keybind(&(*(*group.seat).server).wm, xkb_keycode, modifiers, xkb_state)
+            .filter(|kb| !locked || allowed_while_locked(kb.keysym))
+        {
             log::debug!("matched CCE monolithic keybind: {:?}", kb);
             KeyConsumer::CceBinding(kb)
-        } else if let Some((session, id)) = match_portal_shortcut(&(*(*group.seat).server).wm, xkb_keycode, modifiers, xkb_state) {
+        } else if let Some((session, id)) = (!locked)
+            .then(|| match_portal_shortcut(&(*(*group.seat).server).wm, xkb_keycode, modifiers, xkb_state))
+            .flatten()
+        {
             log::debug!("matched portal shortcut {} {}", session, id);
             KeyConsumer::PortalShortcut { session, id }
-        } else if let Some(binding) = (*group.seat).match_xkb_binding(xkb_keycode, &mut group.wlr_keyboard) {
+        } else if let Some(binding) = (!locked)
+            .then(|| (*group.seat).match_xkb_binding(xkb_keycode, &mut group.wlr_keyboard))
+            .flatten()
+        {
             log::debug!("matched xkb binding");
             (*group.seat).xkb_bindings_seat.ensure_next_key_eaten = false;
             KeyConsumer::Binding(if (*binding).sent_pressed {
@@ -449,7 +495,8 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
             } else {
                 KeyConsumer::Focus
             }
-        } else if !group.get_input_method_grab().is_null() {
+        } else if !locked && !group.get_input_method_grab().is_null() {
+            // Never while locked: an input method would read the password.
             KeyConsumer::ImGrab
         } else {
             KeyConsumer::Focus
@@ -650,3 +697,34 @@ unsafe extern "C" fn handle_group_modifiers(listener: *mut ffi::wl_listener, _da
 
     group.send_state();
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_locked_session_keeps_only_vt_switching_and_the_media_keys() {
+        for sym in [
+            ffi::XKB_KEY_XF86Switch_VT_1,
+            ffi::XKB_KEY_XF86Switch_VT_12,
+            ffi::XKB_KEY_XF86AudioRaiseVolume,
+            ffi::XKB_KEY_XF86AudioMute,
+            ffi::XKB_KEY_XF86MonBrightnessDown,
+        ] {
+            assert!(allowed_while_locked(sym), "{sym:#x} should act while locked");
+        }
+        // The keys a bind like super+q or super+d is made of, and media-range
+        // keys that launch things.
+        for sym in [
+            ffi::XKB_KEY_q,
+            ffi::XKB_KEY_d,
+            ffi::XKB_KEY_Return,
+            ffi::XKB_KEY_Escape,
+            ffi::XKB_KEY_XF86Calculator,
+            ffi::XKB_KEY_XF86WWW,
+            ffi::XKB_KEY_XF86PowerOff,
+        ] {
+            assert!(!allowed_while_locked(sym), "{sym:#x} must go to the lock screen");
+        }
+    }
+}
diff --git a/src/server/lock_manager.rs b/src/server/lock_manager.rs
index 8156df7a..1742e40d 100644
--- a/src/server/lock_manager.rs
+++ b/src/server/lock_manager.rs
@@ -39,6 +39,11 @@ pub struct LockManager {
     /// 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,
+    /// Replies owed to `ccectl lock` callers (the lock-before-sleep thread
+    /// among them), sent once the session is `Locked`. `Option` because the
+    /// struct is zero-initialised: `None` is the null niche, an empty `Vec`
+    /// is not.
+    pub lock_waiters: Option<Vec<std::sync::mpsc::Sender<String>>>,
     pub server: *mut Server,
 
     pub new_lock: ffi::wl_listener,
@@ -65,6 +70,7 @@ impl LockManager {
     pub unsafe fn init(&mut self, server: *mut Server) -> Result<(), &'static str> {
         self.server = server;
         self.state = LockState::Unlocked;
+        std::ptr::write(&mut self.lock_waiters, None);
 
         let wlr_manager = ffi::wlr_session_lock_manager_v1_create((*server).wl_server);
         if wlr_manager.is_null() {
@@ -186,9 +192,79 @@ impl LockManager {
             ffi::wlr_session_lock_v1_send_locked(self.lock);
         }
         self.state = LockState::Locked;
+        for tx in self.lock_waiters.take().unwrap_or_default() {
+            let _ = tx.send("ok locked\n".to_string());
+        }
+        (*self.server).idle.on_locked();
         (*self.server).wm.dirty_windowing();
     }
 
+    /// Lock the session from the compositor's side, with no lock client yet.
+    ///
+    /// Until 2026-10-01 only a client could lock — the protocol's `lock()` —
+    /// and nothing ever started one: the idle timeout and a lid close both
+    /// suspended an UNLOCKED session, so whoever opened the lid had the
+    /// desktop. Now the compositor hides the desktop at once (the normal tree
+    /// off, every output rendering the blank locked tree) and then starts
+    /// `cce-lock`, which binds through `handle_new_lock`'s "already locked
+    /// session" branch and puts the prompt up. This is the state a crashed
+    /// locker leaves behind, so the respawn timer is what starts it, with the
+    /// same backoff and the same fail-closed end: a locker that never comes
+    /// leaves the session locked, not open.
+    ///
+    /// No-op when the session is already locked or locking.
+    pub unsafe fn lock_now(&mut self) {
+        if self.state != LockState::Unlocked {
+            return;
+        }
+        log::info!("locking the session (compositor-initiated)");
+        self.state = LockState::WaitingForBlank;
+        let scene = &(*self.server).scene;
+        ffi::wlr_scene_node_set_enabled(scene.locked_tree as *mut ffi::wlr_scene_node, true);
+        ffi::wlr_scene_node_set_enabled(scene.normal_tree as *mut ffi::wlr_scene_node, false);
+
+        let seats_head = &mut (*self.server).input_manager.seats as *mut ffi::wl_list as *mut WlList;
+        let mut curr = (*seats_head).next;
+        while curr != seats_head {
+            let next = (*curr).next;
+            let seat = crate::container_of!(curr, crate::seat::Seat, link);
+            (*seat).focus(Focus::None);
+            curr = next;
+        }
+
+        // Each enabled output reports `Blanked` after its next frame, and the
+        // last of them completes the lock in `maybe_lock`. With none enabled
+        // (the idle timeout darkened them all) there is nothing to wait for,
+        // and this call completes it now.
+        let outputs_head = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
+        let mut curr = (*outputs_head).next;
+        while curr != outputs_head {
+            let next = (*curr).next;
+            let output = crate::container_of!(curr, crate::output::Output, link);
+            if !(*output).wlr_output.is_null() && ffi::river_wlr_output_get_enabled((*output).wlr_output) {
+                ffi::wlr_output_schedule_frame((*output).wlr_output);
+            }
+            curr = next;
+        }
+        (*self.server).wm.dirty_windowing();
+        self.maybe_lock();
+
+        self.respawn_attempts = 0;
+        if !self.respawn_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.respawn_timer, 1);
+        }
+    }
+
+    /// Answer `tx` once the session is locked: now if it is, else from
+    /// `send_locked`.
+    pub fn reply_when_locked(&mut self, tx: std::sync::mpsc::Sender<String>) {
+        if self.state == LockState::Locked {
+            let _ = tx.send("ok locked\n".to_string());
+        } else {
+            self.lock_waiters.get_or_insert_with(Vec::new).push(tx);
+        }
+    }
+
     /// Bring a locker back after the one holding the session went away
     /// without unlocking.
     ///
@@ -390,7 +466,7 @@ unsafe extern "C" fn handle_respawn_timeout(data: *mut std::ffi::c_void) -> std:
     }
 
     let cmd = cce_lock_cmd();
-    log::warn!("respawning the locker: {}", cmd);
+    log::info!("starting the locker: {}", cmd);
     match nix::unistd::fork() {
         Ok(nix::unistd::ForkResult::Child) => {
             crate::process::cleanup_child();
@@ -482,6 +558,8 @@ unsafe extern "C" fn handle_unlock(listener: *mut ffi::wl_listener, _data: *mut
 
     manager.state = LockState::Unlocked;
     log::info!("session unlocked");
+    // Nobody is waiting for a lock that is over.
+    manager.lock_waiters = None;
 
     // The session is going away legitimately: no respawn is wanted, and the
     // next lock starts with a full budget.
diff --git a/src/server/sleep_lock.rs b/src/server/sleep_lock.rs
new file mode 100644
index 00000000..0b845fdd
--- /dev/null
+++ b/src/server/sleep_lock.rs
@@ -0,0 +1,126 @@
+//! Lock the session before the machine sleeps, whoever puts it to sleep.
+//!
+//! The idle timeout locks before its own sleep (`IdleManager::lock_then_sleep`),
+//! but most sleeps are not ours: closing the lid, the power key, `systemctl
+//! suspend` from a terminal are all logind's. Until 2026-10-01 none of them
+//! locked anything, so a laptop opened after any of them showed the desktop.
+//!
+//! logind's answer is a DELAY inhibitor: hold one, and before sleeping logind
+//! emits `PrepareForSleep(true)` and waits — up to `InhibitDelayMaxSec`, 5s by
+//! default — for the holder to close it. This thread holds one, and on the
+//! signal asks the compositor to lock over its own control socket (`lock`,
+//! which replies once every output shows the locked scene), then lets go. On
+//! `PrepareForSleep(false)` — the resume — it takes a fresh inhibitor for the
+//! next sleep.
+//!
+//! The control socket is the way in because it already is the bridge from a
+//! thread to the main loop, and a `lock` reply that waits for the lock is
+//! exactly the "done" this needs. Failure is not silent and not fatal: no
+//! system bus, or logind refusing, is logged and retried, and the idle path
+//! keeps locking on its own.
+
+use std::io::{Read, Write};
+use std::time::Duration;
+
+/// Started only for a real seat (a DRM session): a headless shadow has no
+/// lid, and taking a delay inhibitor on the real system bus from every
+/// shadow would make each suspend wait on them.
+pub fn spawn(display_socket: Option<String>) {
+    let socket = crate::ipc_server::get_ipc_socket_path(display_socket.as_deref());
+    let spawned = std::thread::Builder::new()
+        .name("cce-sleep-lock".to_string())
+        .spawn(move || loop {
+            if let Err(e) = run(&socket) {
+                log::warn!("lock-before-sleep: {e}; retrying in 30s");
+            }
+            std::thread::sleep(Duration::from_secs(30));
+        });
+    if let Err(e) = spawned {
+        log::error!("lock-before-sleep: could not start its thread: {e}");
+    }
+}
+
+fn run(socket: &str) -> Result<(), String> {
+    let conn = zbus::blocking::Connection::system().map_err(|e| format!("no system bus: {e}"))?;
+    let logind = zbus::blocking::Proxy::new(
+        &conn,
+        "org.freedesktop.login1",
+        "/org/freedesktop/login1",
+        "org.freedesktop.login1.Manager",
+    )
+    .map_err(|e| format!("no logind: {e}"))?;
+    // Subscribed before the first inhibitor is taken, so no PrepareForSleep
+    // can fall between holding one and listening for it.
+    let mut signals = logind
+        .receive_signal("PrepareForSleep")
+        .map_err(|e| format!("cannot watch PrepareForSleep: {e}"))?;
+
+    loop {
+        let inhibitor: zbus::zvariant::OwnedFd = logind
+            .call("Inhibit", &("sleep", "cce", "Lock the screen before sleep", "delay"))
+            .map_err(|e| format!("logind refused a sleep inhibitor: {e}"))?;
+        log::info!("lock-before-sleep: holding a sleep delay");
+
+        wait_for(&mut signals, true)?;
+        log::info!("lock-before-sleep: the system is going to sleep; locking");
+        match request(socket, "lock") {
+            Ok(reply) => log::info!("lock-before-sleep: {}", reply.trim()),
+            // The sleep goes ahead either way: logind stops waiting at its
+            // own deadline, and holding on would only spend that.
+            Err(e) => log::error!("lock-before-sleep: the lock request failed: {e}"),
+        }
+        drop(inhibitor);
+
+        wait_for(&mut signals, false)?;
+        log::info!("lock-before-sleep: resumed");
+    }
+}
+
+/// Block until `PrepareForSleep(going)`.
+fn wait_for(signals: &mut zbus::blocking::proxy::SignalIterator<'_>, going: bool) -> Result<(), String> {
+    for msg in signals.by_ref() {
+        match msg.body().deserialize::<bool>() {
+            Ok(v) if v == going => return Ok(()),
+            Ok(_) => {}
+            Err(e) => log::warn!("lock-before-sleep: unreadable PrepareForSleep: {e}"),
+        }
+    }
+    Err("the system bus closed the PrepareForSleep stream".to_string())
+}
+
+/// One control-socket command, answered: the same exchange `ccectl` makes.
+fn request(socket: &str, command: &str) -> std::io::Result<String> {
+    let mut stream = std::os::unix::net::UnixStream::connect(socket)?;
+    stream.set_read_timeout(Some(Duration::from_secs(5)))?;
+    stream.write_all(command.as_bytes())?;
+    let mut reply = String::new();
+    stream.read_to_string(&mut reply)?;
+    Ok(reply)
+}
+
+#[cfg(test)]
+mod tests {
+    /// Talks to the real logind, so it is opt-in: `cargo test -p cce-fx --lib
+    /// sleep_lock -- --ignored`. It takes a delay inhibitor and drops it at
+    /// once (nothing sleeps), and checks the signal subscription the thread
+    /// waits on can be made — the two calls a shadow, with no seat, never
+    /// exercises.
+    #[test]
+    #[ignore]
+    fn logind_grants_a_sleep_delay_and_the_signal_can_be_watched() {
+        let conn = zbus::blocking::Connection::system().expect("system bus");
+        let logind = zbus::blocking::Proxy::new(
+            &conn,
+            "org.freedesktop.login1",
+            "/org/freedesktop/login1",
+            "org.freedesktop.login1.Manager",
+        )
+        .unwrap();
+        let _signals = logind.receive_signal("PrepareForSleep").expect("PrepareForSleep subscription");
+        let fd: zbus::zvariant::OwnedFd = logind
+            .call("Inhibit", &("sleep", "cce-test", "test: dropped at once", "delay"))
+            .expect("a sleep delay inhibitor");
+        use std::os::fd::AsRawFd;
+        assert!(std::os::fd::AsFd::as_fd(&fd).as_raw_fd() >= 0);
+    }
+}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index d413a4d5..5ff67038 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1789,6 +1789,10 @@ impl WindowManager {
 
     pub unsafe fn start_ipc(&mut self, display_socket: Option<String>) {
         if self.ipc_rx.is_none() {
+            // Lock before logind's sleeps (lid, power key) — a real seat only.
+            if !(*self.server).session.is_null() {
+                crate::sleep_lock::spawn(display_socket.clone());
+            }
             let (rx, wake) = crate::ipc_server::spawn_ipc_server(display_socket);
             let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
             self.ipc_source = ffi::wl_event_loop_add_fd(
@@ -5957,6 +5961,21 @@ impl WindowManager {
                 "ok\n".to_string()
             }
             "idle" => unsafe { (*self.server).idle.ipc(&parts[1..]) },
+            // Lock the session (`LockManager::lock_now`). The reply waits for
+            // the lock to complete, which is what the lock-before-sleep
+            // thread (`sleep_lock`) holds logind's sleep for: `ok locked`
+            // once every output shows the locked scene.
+            "lock" => unsafe {
+                let lock = &mut (*self.server).lock_manager;
+                lock.lock_now();
+                match self.pending_ipc_reply.take() {
+                    Some(tx) => {
+                        lock.reply_when_locked(tx);
+                        String::new()
+                    }
+                    None => "ok\n".to_string(),
+                }
+            },
             // Live key repeat for every hardware keyboard, like `idle
             // timeouts`: it lasts until the next config load, which puts
             // `input { repeat_rate repeat_delay }` back.