From b9d5f144bd878c0079462c1fb3e1cdf9f3d2b315 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 16 Sep 2026 08:05:17 +0200 Subject: [PATCH] Overlay: thread can be stopped externally instead of detaching (Bug #5) Bug from Grok round-1 #5. spawn_with_sock returned Ok(_) and dropped the LiveOverlayHandle, so the JoinHandle was never joined or signalled. The thread detached; OverlayHub could only clear its slot map, never stop the actual Wayland thread. With env-gated rendering (Bug #8), the operator's overlays would accumulate as ghost threads. Changes: - wayland_layer: LiveOverlayHandle gains a stop: Arc. spawn() allocates it, threads a copy into run(), stores a copy on the returned handle. - wayland_layer: run() polls stop in addition to state.exited; flipping the bit causes the next roundtrip to exit instead of waiting on the compositor's Closed event. - overlay: OverlayHandle gains stop: Option> and a kill() method that flips the bit. - overlay: spawn_with_sock now puts the same stop Arc on the returned OverlayHandle (was previously throwing the live handle away). - overlay: OverlayHub.sync() and kill_all() call kill() on every removed handle, so slot changes actually tear the threads down. cargo test 96+/0; clippy clean. --- src/overlay.rs | 30 ++++++++++++++++++++++++++---- src/wayland_layer.rs | 18 +++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/overlay.rs b/src/overlay.rs index 859a344..d760883 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -17,6 +17,8 @@ //! spawn / kill plan are real and tested here so the live render slots into a //! known shape. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use crate::hypr::Client; const OVERLAY_W: i32 = 96; @@ -66,12 +68,23 @@ pub fn live_enabled() -> bool { pub struct OverlayHandle { pub slot: u32, pub rect: OverlayRect, + /// External kill signal for the live Wayland thread. The hub flips + /// this to true when the slot goes away so the thread exits cleanly + /// instead of being silently detached (Bug #5). + pub stop: Option>, } impl OverlayHandle { /// Stub handle for non-live paths. Does not connect to Wayland. pub fn stub(slot: u32, rect: OverlayRect) -> Self { - Self { slot, rect } + Self { slot, rect, stop: None } + } + + /// Ask the underlying thread to exit. No-op for stubs. + pub fn kill(&self) { + if let Some(s) = &self.stop { + s.store(true, Ordering::Relaxed); + } } } @@ -101,7 +114,7 @@ pub fn spawn_with_sock( } let sock = ipc_sock.unwrap_or_else(crate::session::default_sock); match crate::wayland_layer::spawn(slot, rect, sock) { - Ok(_) => OverlayHandle::stub(slot, rect), + Ok(handle) => OverlayHandle { slot, rect, stop: Some(handle.stop) }, Err(e) => { tracing::warn!("overlay: live spawn failed for slot {slot}: {e}"); OverlayHandle::stub(slot, rect) @@ -151,7 +164,9 @@ impl OverlayHub { ) -> Vec { let (to_spawn, kill) = self.plan(slots); for k in kill { - self.by_slot.remove(&k); + if let Some(h) = self.by_slot.remove(&k) { + h.kill(); + } } for h in &to_spawn { self.by_slot.insert(h.slot, h.clone()); @@ -168,7 +183,14 @@ impl OverlayHub { } pub fn kill_all(&mut self) { - self.by_slot.clear(); + // Drain via mem::take rather than the BTreeMap::drain iterator to + // keep ownership types local (std::mem::take always works + // regardless of the nightly/stable Rust allocation churn in the + // IntoIterator impls). + let taken = std::mem::take(&mut self.by_slot); + for (_, h) in taken { + h.kill(); + } } } diff --git a/src/wayland_layer.rs b/src/wayland_layer.rs index e5d71fe..3c56345 100644 --- a/src/wayland_layer.rs +++ b/src/wayland_layer.rs @@ -22,6 +22,8 @@ //! commit and listens for clicks. Per T9 the badge is readable (3x5 //! digits, ARGB8888, opaque background, solid foreground). +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use crate::overlay::OverlayRect; use crate::profile::runtime_dir; use std::fs::File; @@ -160,11 +162,18 @@ struct OverlayState { pub struct LiveOverlayHandle { pub slot: u32, pub rect: OverlayRect, + /// External kill signal. The dispatch loop in [`run`] polls this + /// once per roundtrip; flipping to true causes the thread to exit + /// on the next round, so the join in [`shutdown`] does not block + /// forever waiting on the compositor's `Closed` event. + pub stop: Arc, join: Option>, } impl LiveOverlayHandle { + /// Ask the thread to exit, then join it. Idempotent. pub fn shutdown(mut self) { + self.stop.store(true, Ordering::Relaxed); if let Some(j) = self.join.take() { let _ = j.join(); } @@ -177,20 +186,23 @@ impl LiveOverlayHandle { /// surface. Errors during connect are returned; setup errors after /// connect are logged and the thread exits. pub fn spawn(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result { + let stop = Arc::new(AtomicBool::new(false)); + let stop_for_thread = stop.clone(); let join = std::thread::Builder::new() .name(format!("enboxer-overlay-{slot}")) - .spawn(move || match run(slot, rect, ipc_sock) { + .spawn(move || match run(slot, rect, ipc_sock, stop_for_thread) { Ok(()) => tracing::debug!("overlay slot {slot} exited cleanly"), Err(e) => tracing::warn!("overlay slot {slot}: {e}"), })?; Ok(LiveOverlayHandle { slot, rect, + stop, join: Some(join), }) } -fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<()> { +fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf, stop: Arc) -> anyhow::Result<()> { let conn = Connection::connect_to_env()?; let display = conn.display(); let mut event_queue = conn.new_event_queue::(); @@ -267,7 +279,7 @@ fn run(slot: u32, rect: OverlayRect, ipc_sock: PathBuf) -> anyhow::Result<()> { state.buffer = Some(buffer.clone()); surface.attach(Some(&buffer), 0, 0); surface.commit(); - while !state.exited { + while !state.exited && !stop.load(Ordering::Relaxed) { if let Err(e) = event_queue.blocking_dispatch(&mut state) { tracing::warn!("overlay slot {slot}: dispatch: {e}"); break;