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<AtomicBool>.
  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<Arc<AtomicBool>> 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.
This commit is contained in:
en 2026-09-16 08:05:17 +02:00
parent 5da77b28b3
commit b9d5f144bd
2 changed files with 41 additions and 7 deletions

View File

@ -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<Arc<AtomicBool>>,
}
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<OverlayHandle> {
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();
}
}
}

View File

@ -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<AtomicBool>,
join: Option<JoinHandle<()>>,
}
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<LiveOverlayHandle> {
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<AtomicBool>) -> anyhow::Result<()> {
let conn = Connection::connect_to_env()?;
let display = conn.display();
let mut event_queue = conn.new_event_queue::<OverlayState>();
@ -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;