From 4c9094d776bc72f89f1c9ea265fd34cb8805bf7f Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Sat, 31 Oct 2026 10:08:54 -0700 Subject: [PATCH] feat(backend): Registry, a thread-safe table from WindowId to a platform handle Step 11(ii), the pure part. macOS cannot act on a window from its id: it needs the Accessibility element, the pid and the observer. Registry is that table, generic over the handle so that its rules run on any host. Rules, written at the top of registry.rs and tested: thread-safe; a lookup clones the handle and releases the lock before anything is done to it; a removed or replaced handle is handed back to the caller instead of being dropped under the lock (dropping a macOS handle can make an AX round trip, which must never happen with a lock held: seam.md 3.3); a stale or unknown id is Error::WindowGone, never a panic; an entry ends on remove, remove_pid (application quit) or a failed call; re-inserting replaces. Nothing uses it yet; no daemon changes. Verified: native cargo test -p komorebi-backend: 38 unit (9 new) + 7 doc (1 new) + 1 compile_fail passed, 0 failed. Not run: the Windows, KDE and macOS checks (the new module is std-only and no other file changed). --- crates/komorebi-backend/src/lib.rs | 2 + crates/komorebi-backend/src/registry.rs | 312 ++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 crates/komorebi-backend/src/registry.rs diff --git a/crates/komorebi-backend/src/lib.rs b/crates/komorebi-backend/src/lib.rs index 8fbde7df..20ce1958 100644 --- a/crates/komorebi-backend/src/lib.rs +++ b/crates/komorebi-backend/src/lib.rs @@ -31,6 +31,7 @@ mod error; pub mod fake; mod id; mod info; +mod registry; pub use backend::Backend; pub use backend::Batch; @@ -41,4 +42,5 @@ pub use id::MonitorId; pub use id::WindowId; pub use info::MonitorInfo; pub use info::WindowInfo; +pub use registry::Registry; pub use komorebi_layouts::Rect; diff --git a/crates/komorebi-backend/src/registry.rs b/crates/komorebi-backend/src/registry.rs new file mode 100644 index 00000000..aae9940c --- /dev/null +++ b/crates/komorebi-backend/src/registry.rs @@ -0,0 +1,312 @@ +//! A thread-safe table from a [`WindowId`] to whatever a platform needs in order to act on that +//! window from nothing but the id. +//! +//! macOS is the platform that needs it: a window there is an Accessibility element with the pid +//! of its application and the observer that reports its events, and nothing can be done to a +//! window without them. The table is generic over the handle (`H`) so that its rules can be +//! tested on any host with a stand-in handle; macOS instantiates it with its Accessibility types. +//! +//! # Rules +//! +//! 1. **Thread-safe.** Any thread may call any method. +//! 2. **Nothing of the platform runs under the lock.** [`Registry::get`] clones the handle (the +//! handle must be cheap to clone: a retained reference) and returns; the caller acts on the +//! clone with the lock released. A method that removes an entry hands the removed handle back +//! to the caller instead of dropping it inside, because dropping a macOS handle can make an +//! Accessibility round trip. This is what keeps a registry call from ever waiting on the main +//! queue, or on an application, while holding the lock (`docs/dev/seam.md` section 3.3). +//! 3. **A stale id is an error, not a panic.** Looking up an id that was never registered, was +//! removed, or was removed with its application answers [`Error::WindowGone`]. +//! 4. **Entries end** when the window is destroyed ([`Registry::remove`]), when its application +//! quits ([`Registry::remove_pid`]), and when a call on the window shows that it is gone. An +//! entry that nobody removes is a leak of a retained element; the platform removes at every +//! place it learns of a death. +//! 5. **Re-registering an id replaces the entry** and returns the old handle (a tab that is +//! now shown by another element keeps the window id). +//! +//! ``` +//! use komorebi_backend::Registry; +//! use komorebi_backend::WindowId; +//! +//! let registry: Registry<&'static str> = Registry::new(); +//! let id = WindowId::from_u32(7); +//! registry.insert(id, 100, "element"); +//! assert_eq!(registry.get(id).unwrap(), "element"); +//! +//! registry.remove(id); +//! assert!(registry.get(id).unwrap_err().is_window_gone()); +//! ``` + +use crate::Error; +use crate::Result; +use crate::WindowId; +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::MutexGuard; + +struct Entry { + pid: i32, + handle: H, +} + +/// The table. See the [module documentation](self) for its rules. +pub struct Registry { + entries: Mutex>>, +} + +impl Default for Registry { + fn default() -> Self { + Self::new() + } +} + +impl Registry { + /// Returns an empty registry. + pub fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + + // a thread that panicked while holding the lock cannot have left an entry half written (every + // method is a single map operation), so a poisoned lock is still good + fn lock(&self) -> MutexGuard<'_, HashMap>> { + self.entries.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Registers the window, owned by the process `pid`. Returns the handle that this replaces, + /// if the id was registered: the caller drops it, with no lock held. + #[must_use = "drop the replaced handle yourself: it is not dropped under the lock"] + pub fn insert(&self, id: WindowId, pid: i32, handle: H) -> Option { + self.lock().insert(id, Entry { pid, handle }).map(|e| e.handle) + } + + /// Replaces the handle of a window that is registered, keeping its pid. Returns the old + /// handle, or [`Error::WindowGone`] (and registers nothing) if the id is not registered. + pub fn replace(&self, id: WindowId, handle: H) -> Result { + match self.lock().get_mut(&id) { + Some(entry) => Ok(std::mem::replace(&mut entry.handle, handle)), + None => Err(Error::WindowGone(id)), + } + } + + /// Forgets the window. Returns its handle (for the caller to drop with no lock held), or + /// `None` if it was not registered, which is not an error: a death is usually seen twice. + #[must_use = "drop the removed handle yourself: it is not dropped under the lock"] + pub fn remove(&self, id: WindowId) -> Option { + self.lock().remove(&id).map(|e| e.handle) + } + + /// Forgets every window of the process, as when its application quits. Returns the ids and + /// handles removed, sorted by id. + #[must_use = "drop the removed handles yourself: they are not dropped under the lock"] + pub fn remove_pid(&self, pid: i32) -> Vec<(WindowId, H)> { + let mut entries = self.lock(); + let ids: Vec = entries + .iter() + .filter(|(_, e)| e.pid == pid) + .map(|(id, _)| *id) + .collect(); + let mut removed: Vec<(WindowId, H)> = ids + .into_iter() + .filter_map(|id| entries.remove(&id).map(|e| (id, e.handle))) + .collect(); + drop(entries); + removed.sort_by_key(|(id, _)| *id); + removed + } + + /// Whether the id is registered. + pub fn contains(&self, id: WindowId) -> bool { + self.lock().contains_key(&id) + } + + /// The pid of the registered window, or [`Error::WindowGone`]. + pub fn pid(&self, id: WindowId) -> Result { + self.lock() + .get(&id) + .map(|e| e.pid) + .ok_or(Error::WindowGone(id)) + } + + /// How many windows are registered. + pub fn len(&self) -> usize { + self.lock().len() + } + + /// Whether nothing is registered. + pub fn is_empty(&self) -> bool { + self.lock().is_empty() + } + + /// The registered ids, sorted. + pub fn ids(&self) -> Vec { + let mut ids: Vec<_> = self.lock().keys().copied().collect(); + ids.sort(); + ids + } +} + +impl Registry { + /// A clone of the handle of the window, or [`Error::WindowGone`] for an id that is not + /// registered. The lock is released before this returns. + pub fn get(&self, id: WindowId) -> Result { + self.lock() + .get(&id) + .map(|e| e.handle.clone()) + .ok_or(Error::WindowGone(id)) + } + + /// Clones of every handle with their ids, sorted by id. + pub fn snapshot(&self) -> Vec<(WindowId, H)> { + let mut all: Vec<_> = self + .lock() + .iter() + .map(|(id, e)| (*id, e.handle.clone())) + .collect(); + all.sort_by_key(|(id, _)| *id); + all + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + fn id(n: u32) -> WindowId { + WindowId::from_u32(n) + } + + #[test] + fn a_registered_window_is_found_by_its_id() { + let r = Registry::new(); + assert!(r.insert(id(1), 10, "a").is_none()); + assert!(matches!(r.get(id(1)), Ok("a"))); + assert!(matches!(r.pid(id(1)), Ok(10))); + assert!(r.contains(id(1))); + } + + #[test] + fn an_unknown_id_is_window_gone_and_not_a_panic() { + let r: Registry<&str> = Registry::new(); + assert!(matches!(r.get(id(9)), Err(Error::WindowGone(w)) if w == id(9))); + assert!(matches!(r.pid(id(9)), Err(Error::WindowGone(_)))); + assert!(r.get(id(9)).unwrap_err().is_window_gone()); + } + + #[test] + fn a_removed_window_is_stale() { + let r = Registry::new(); + let _ = r.insert(id(1), 10, "a"); + assert_eq!(r.remove(id(1)), Some("a")); + assert!(r.get(id(1)).unwrap_err().is_window_gone()); + // a death seen twice is not an error + assert_eq!(r.remove(id(1)), None); + } + + #[test] + fn reinserting_replaces_and_hands_back_the_old_handle() { + let r = Registry::new(); + let _ = r.insert(id(1), 10, "tab one"); + assert_eq!(r.insert(id(1), 10, "tab two"), Some("tab one")); + assert!(matches!(r.get(id(1)), Ok("tab two"))); + assert_eq!(r.len(), 1); + } + + #[test] + fn replace_keeps_the_pid_and_refuses_a_stale_id() { + let r = Registry::new(); + let _ = r.insert(id(1), 10, "a"); + assert!(matches!(r.replace(id(1), "b"), Ok("a"))); + assert!(matches!(r.pid(id(1)), Ok(10))); + assert!(r.replace(id(2), "c").unwrap_err().is_window_gone()); + assert!(!r.contains(id(2))); + } + + #[test] + fn an_application_quitting_takes_all_its_windows_and_only_them() { + let r = Registry::new(); + let _ = r.insert(id(3), 10, "c"); + let _ = r.insert(id(1), 10, "a"); + let _ = r.insert(id(2), 20, "b"); + + assert_eq!(r.remove_pid(10), vec![(id(1), "a"), (id(3), "c")]); + assert_eq!(r.ids(), vec![id(2)]); + assert!(r.get(id(1)).unwrap_err().is_window_gone()); + assert_eq!(r.remove_pid(10), vec![]); + } + + #[test] + fn snapshot_is_sorted_and_independent_of_later_changes() { + let r = Registry::new(); + let _ = r.insert(id(2), 1, "b"); + let _ = r.insert(id(1), 1, "a"); + let snap = r.snapshot(); + let _ = r.remove(id(1)); + assert_eq!(snap, vec![(id(1), "a"), (id(2), "b")]); + } + + /// A handle whose drop would, on macOS, make a round trip: it must not run under the lock. + struct Probe { + registry: Arc>>, + drops: Arc, + } + + impl Drop for Probe { + fn drop(&mut self) { + // would deadlock if the registry dropped the handle while holding its lock + let _ = self.registry.len(); + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + #[test] + fn handles_are_never_dropped_under_the_lock() { + let registry = Arc::new(Registry::new()); + let drops = Arc::new(AtomicUsize::new(0)); + let probe = |_: u32| { + Arc::new(Probe { + registry: registry.clone(), + drops: drops.clone(), + }) + }; + + let _ = registry.insert(id(1), 10, probe(1)); + // the replaced and the removed handles come back to us, so their drop runs here + drop(registry.insert(id(1), 10, probe(2))); + drop(registry.remove(id(1))); + let _ = registry.insert(id(2), 10, probe(3)); + drop(registry.remove_pid(10)); + + assert_eq!(drops.load(Ordering::SeqCst), 3); + assert!(registry.is_empty()); + } + + #[test] + fn many_threads_can_register_look_up_and_remove() { + let r = Arc::new(Registry::new()); + let threads: Vec<_> = (0..8u32) + .map(|t| { + let r = r.clone(); + std::thread::spawn(move || { + for n in 0..200u32 { + let wid = id(t * 1000 + n); + let _ = r.insert(wid, t as i32, n); + assert!(matches!(r.get(wid), Ok(v) if v == n)); + assert_eq!(r.remove(wid), Some(n)); + assert!(r.get(wid).unwrap_err().is_window_gone()); + } + }) + }) + .collect(); + + for t in threads { + t.join().unwrap(); + } + + assert!(r.is_empty()); + } +}