From 9171f0771a5a0031fc47f8f9a675816fa383205b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20H=C3=B6rmann?= Date: Thu, 26 Mar 2026 08:33:51 +0100 Subject: [PATCH 1/2] fix: properly unlock on niri by flushing Wayland connection before exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ext-session-lock-v1 protocol does not guarantee a `finished` event after the client calls `unlock_and_destroy` — that event is only sent when the compositor independently terminates the lock. Waiting for it caused rustlock to hang forever on niri (and any spec-compliant compositor). The previous attempt to fix this by setting exit=true immediately broke unlocking because the while loop stopped calling event_loop.dispatch, leaving the unlock_and_destroy bytes unflushed in the client-side Wayland send buffer and never reaching the compositor. Store the Connection in WaylandLock and explicitly call conn.flush() after session_lock.unlock(), ensuring the unlock request is sent before we exit. Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main.rs b/src/main.rs index d40daef..ebfbdbd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,7 @@ impl log::Log for DualLogger { } struct WaylandLock { + conn: Connection, loop_handle: LoopHandle<'static, Self>, lock_manager: Arc>, config: Config, @@ -123,7 +124,6 @@ struct WaylandLock { captured_backgrounds: Vec>, pending_screenshots: usize, exit: bool, - unlocking: bool, screenshot_manager: Option, grace_until: Option, system_manager: Arc, @@ -140,8 +140,9 @@ impl WaylandLock { log::info!("✅ Authentication successful - unlocking session"); if let Some(session_lock) = &self.session_lock { session_lock.unlock(); - self.unlocking = true; - log::debug!("Unlock requested - waiting for compositor finished event"); + let _ = self.conn.flush(); + self.exit = true; + log::debug!("Unlock requested - exiting"); } else { log::error!("No session_lock available to unlock!"); self.exit = true; @@ -617,6 +618,7 @@ fn main() -> Result<(), Box> { let pool = SlotPool::new(1, &shm_state)?; let mut state = WaylandLock { + conn: conn.clone(), loop_handle: event_loop.handle(), lock_manager: lock_manager.clone(), config: config.clone(), @@ -636,7 +638,6 @@ fn main() -> Result<(), Box> { captured_backgrounds: Vec::new(), pending_screenshots: 0, exit: false, - unlocking: false, screenshot_manager: ScreenshotManager::new(&globals, &qh).ok(), grace_until: None, system_manager: system_manager.clone(), @@ -727,9 +728,6 @@ fn main() -> Result<(), Box> { } } - if state.unlocking { - return calloop::timer::TimeoutAction::ToDuration(Duration::from_millis(100)); - } let mut status = state.system_manager.get_status(); status.keyboard_layout = Some(state.current_layout.to_string()); From f8c4381a1116bef0e8ef8adffe2d785af1bc3178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20H=C3=B6rmann?= Date: Fri, 27 Mar 2026 12:57:20 +0100 Subject: [PATCH 2/2] fix: handle dynamic output changes while session is locked When monitors are powered off (e.g. via niri power-off-monitors or physical power switches), niri destroys and re-advertises the Wayland outputs as they come back. Previously all three OutputHandler callbacks were no-ops, causing two bugs: - new_output: no lock surface was created for outputs that appeared after the initial lock, so the slowest monitor to wake up would show the compositor's red fallback instead of the lock screen. - output_destroyed: stale LockedSurface, SessionLockSurface, output, and captured_background entries accumulated for gone outputs. Fix new_output to create a lock surface (and register it with the lock manager) whenever a new output appears while the session is locked. Fix output_destroyed to remove the corresponding entries from lock_surfaces, lock_manager.surfaces, outputs, and captured_backgrounds, keeping all parallel vecs in sync. Add LockManager::remove_surface_by_output to support this, returning the removal index so lock_surfaces can be updated with the same index. The all-monitors-off scenario (all outputs destroyed simultaneously) is handled naturally: the vecs are emptied and repopulated as each monitor fires new_output on wake. Co-Authored-By: Claude Sonnet 4.6 --- src/lock.rs | 11 ++++++++++ src/main.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/lock.rs b/src/lock.rs index 087228b..d087220 100644 --- a/src/lock.rs +++ b/src/lock.rs @@ -262,6 +262,17 @@ impl LockManager { action } + pub fn remove_surface_by_output(&mut self, output: &wl_output::WlOutput) -> Option { + use wayland_client::Proxy; + let output_id = Proxy::id(output); + let idx = self + .surfaces + .iter() + .position(|s| Proxy::id(s.output()) == output_id)?; + self.surfaces.remove(idx); + Some(idx) + } + pub fn set_system_status(&mut self, status: SystemStatus) { for surface in &mut self.surfaces { surface.set_system_status(status.clone()); diff --git a/src/main.rs b/src/main.rs index ebfbdbd..0bab7a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -296,9 +296,61 @@ impl OutputHandler for WaylandLock { fn output_state(&mut self) -> &mut OutputState { &mut self.output_state } - fn new_output(&mut self, _conn: &Connection, _qh: &QueueHandle, _output: WlOutput) {} - fn update_output(&mut self, _conn: &Connection, _qh: &QueueHandle, _output: WlOutput) {} - fn output_destroyed(&mut self, _conn: &Connection, _qh: &QueueHandle, _output: WlOutput) { + fn new_output(&mut self, _conn: &Connection, qh: &QueueHandle, output: WlOutput) { + // If we are already locked, we must create a lock surface for this newly + // available output. This happens e.g. when a monitor powers back on after + // "niri msg action power-off-monitors" — niri re-advertises the output and + // the compositor requires a lock surface on every output or it shows a + // compositor-defined fallback (typically a solid red/black screen). + if let Some(session_lock) = &self.session_lock { + let surface = self.compositor_state.create_surface(qh); + let (width, height) = self.get_output_dimensions(&output); + let lock_surface = session_lock.create_lock_surface(surface.clone(), &output, qh); + self.lock_surfaces.push(lock_surface); + if !self.outputs.contains(&output) { + self.outputs.push(output.clone()); + } + if let Ok(mut lm) = self.lock_manager.lock() { + lm.add_surface(width, height, output); + let count = lm.surface_count(); + if let Some(ls) = lm.get_surface_mut(count - 1) { + ls.set_wayland_surface(surface); + } + } + log::info!("Created lock surface for newly available output"); + } + } + fn update_output(&mut self, _conn: &Connection, _qh: &QueueHandle, _output: WlOutput) { + // Dimension changes while locked are handled by the compositor sending a configure + // event on the lock surface, which the SessionLockHandler::configure callback + // already processes via locked_surface.resize(). + } + fn output_destroyed(&mut self, _conn: &Connection, _qh: &QueueHandle, output: WlOutput) { + // Clean up the lock surface and associated state for this output. + // This happens e.g. when a monitor is powered off with its physical power switch. + // When the monitor comes back on, new_output() will fire and recreate everything. + // + // outputs and captured_backgrounds are kept at the same indices, so we remove + // from both using the same position. + let output_id = Proxy::id(&output); + if let Some(idx) = self.outputs.iter().position(|o| Proxy::id(o) == output_id) { + self.outputs.remove(idx); + if idx < self.captured_backgrounds.len() { + self.captured_backgrounds.remove(idx); + } + } + + // lock_manager.surfaces and lock_surfaces are built in tandem and share indices, + // so the index returned from the lock_manager removal applies to lock_surfaces too. + if let Ok(mut lm) = self.lock_manager.lock() { + if let Some(idx) = lm.remove_surface_by_output(&output) { + if idx < self.lock_surfaces.len() { + drop(self.lock_surfaces.remove(idx)); + } + } + } + + log::info!("Removed lock surface for destroyed output"); } }