Skip to main content

harn_vm/stdlib/
session_change.rs

1//! Process-wide registry of observers for committed session-metadata changes.
2//!
3//! Split out of `session_store` rather than living beside the store opener: it
4//! is a notification concern, not a storage one, and the two have no shared
5//! state beyond the hook the opener attaches.
6//!
7//! Writes in this process still publish immediately through the store hook.
8//! Writes from another process reach the same SQLite file; [`super::session_wal_watch`]
9//! notices those via WAL + `PRAGMA data_version` and publishes through the
10//! same fanout.
11
12use std::path::Path;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::{Arc, RwLock, RwLockWriteGuard};
15
16use harn_session_store::{SessionMeta, SharedSessionChangeObserver};
17
18use super::session_wal_watch;
19
20/// Process-wide observers notified when session metadata is committed.
21///
22/// Deliberately process-scoped, not thread-local like the redaction policy
23/// beside it: a store handle is opened on whatever thread needs it, and an
24/// update commits on whatever executor thread the caller happens to be on. A
25/// thread-local sink would be installed on one thread and silently miss every
26/// write made from another, which reads as "notifications do not work" rather
27/// than as a wiring mistake.
28///
29/// A list rather than one slot, because more than one surface can care about
30/// the same store and a single slot makes the second registration silently
31/// evict the first.
32static OBSERVERS: RwLock<Vec<(u64, SharedSessionChangeObserver)>> = RwLock::new(Vec::new());
33static NEXT_SUBSCRIPTION: AtomicU64 = AtomicU64::new(1);
34type TitleFingerprint = (Option<String>, bool);
35type RememberedTitles = Vec<(String, TitleFingerprint)>;
36static TITLES: RwLock<RememberedTitles> = RwLock::new(Vec::new());
37
38fn observers() -> RwLockWriteGuard<'static, Vec<(u64, SharedSessionChangeObserver)>> {
39    OBSERVERS
40        .write()
41        .unwrap_or_else(|poisoned| poisoned.into_inner())
42}
43
44fn titles() -> RwLockWriteGuard<'static, RememberedTitles> {
45    TITLES
46        .write()
47        .unwrap_or_else(|poisoned| poisoned.into_inner())
48}
49
50/// Live registration for [`subscribe`]. Unregisters on drop so
51/// a surface that goes away cannot keep receiving, and so a test cannot leak a
52/// sink into the next one sharing the process.
53#[must_use = "dropping the subscription immediately unregisters the observer"]
54pub struct SessionChangeSubscription {
55    id: u64,
56}
57
58impl Drop for SessionChangeSubscription {
59    fn drop(&mut self) {
60        observers().retain(|(id, _)| *id != self.id);
61        let live = subscriber_count() > 0;
62        if !live {
63            titles().clear();
64        }
65        session_wal_watch::sync_watchers(live);
66    }
67}
68
69/// Register an observer notified after any session metadata update commits
70/// through a store this VM opens, or after a WAL watcher sees another
71/// process rename a session in the same file.
72///
73/// Every canonical store opened here carries the registered observers, so a
74/// surface does not have to be handed the specific handle a writer happened to
75/// use.
76pub fn subscribe(observer: SharedSessionChangeObserver) -> SessionChangeSubscription {
77    let id = NEXT_SUBSCRIPTION.fetch_add(1, Ordering::Relaxed);
78    observers().push((id, observer));
79    session_wal_watch::sync_watchers(true);
80    SessionChangeSubscription { id }
81}
82
83/// Claim a canonical store file so a live subscription can watch it.
84///
85/// The returned registration belongs to the store handle that opened the file.
86/// While it lives the path is watchable; when the last handle for that path
87/// drops, so does the watcher.
88#[must_use = "dropping the registration stops the path being watched"]
89pub(crate) fn watch_store(path: &Path) -> session_wal_watch::StoreWatchRegistration {
90    let registration = session_wal_watch::register_store_path(path);
91    if subscriber_count() > 0 {
92        session_wal_watch::sync_watchers(true);
93    }
94    registration
95}
96
97fn subscriber_count() -> usize {
98    OBSERVERS
99        .read()
100        .unwrap_or_else(|poisoned| poisoned.into_inner())
101        .len()
102}
103
104/// Whether a title/pin pair is new to this process, unchanged, or moved.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub(super) enum TitleMemory {
107    New,
108    Unchanged,
109    Changed,
110}
111
112pub(super) fn remember_title(
113    session_id: &str,
114    title: Option<&str>,
115    title_pinned: bool,
116) -> TitleMemory {
117    let next = (title.map(str::to_string), title_pinned);
118    let mut titles = titles();
119    if let Some((_, previous)) = titles.iter_mut().find(|(id, _)| id == session_id) {
120        if *previous == next {
121            return TitleMemory::Unchanged;
122        }
123        *previous = next;
124        return TitleMemory::Changed;
125    }
126    titles.push((session_id.to_string(), next));
127    TitleMemory::New
128}
129
130/// Fans one committed change out to every live subscriber.
131pub(super) fn dispatch(meta: &SessionMeta) {
132    let observers: Vec<SharedSessionChangeObserver> = OBSERVERS
133        .read()
134        .unwrap_or_else(|poisoned| poisoned.into_inner())
135        .iter()
136        .map(|(_, observer)| Arc::clone(observer))
137        .collect();
138    // Copy out before dispatching: an observer is allowed to subscribe or
139    // unsubscribe in response, which would deadlock against a held guard.
140    for observer in observers {
141        observer.session_updated(meta);
142    }
143}
144
145struct SessionChangeFanout;
146
147impl harn_session_store::SessionChangeObserver for SessionChangeFanout {
148    fn session_updated(&self, meta: &SessionMeta) {
149        remember_title(&meta.id, meta.title.as_deref(), meta.title_pinned);
150        dispatch(meta);
151    }
152}
153
154pub(crate) fn current_observer() -> Option<SharedSessionChangeObserver> {
155    if subscriber_count() == 0 {
156        return None;
157    }
158    Some(Arc::new(SessionChangeFanout))
159}
160
161#[cfg(test)]
162pub(crate) mod test_support {
163    use std::sync::OnceLock;
164
165    use tokio::sync::{Mutex, MutexGuard};
166
167    /// Exclusive access to the process-wide change bus for one test.
168    ///
169    /// The observer list, the remembered titles and the running watchers are
170    /// one process singleton, the way the process environment is. Two cases
171    /// that subscribe at the same time each receive the other's committed
172    /// titles, because a subscriber is registered against the process and not
173    /// against a store: the double-publish case then read a sibling's rename
174    /// as its own republish (harn#7960). One lock, held for the life of the
175    /// subscription, is what makes each case see only its own traffic.
176    ///
177    /// An async mutex rather than a `std` one because every case that needs it
178    /// holds it across an await, which is exactly what a blocking guard must
179    /// not do. It also has no poisoning to recover: the lock guards no
180    /// invariant of its own, so a panicking holder leaves nothing behind.
181    #[must_use = "the bus is shared for as long as the guard lives"]
182    pub(crate) async fn exclusive_bus() -> MutexGuard<'static, ()> {
183        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
184        LOCK.get_or_init(|| Mutex::new(())).lock().await
185    }
186}