Skip to main content

tiny_pubsub/
lib.rs

1//! In-process, topic-based publish/subscribe message bus.
2//!
3//! A subscriber is a function called with `(topic, data)`. You register it
4//! against a topic string and get back a [`Token`]. A publisher sends a value
5//! to a topic. Every subscriber whose topic matches runs.
6//!
7//! Topics are hierarchical and dot-delimited. Publishing `a.b.c` notifies
8//! subscribers of `a.b.c`, then `a.b`, then `a`, then the wildcard `*`. A
9//! subscriber on an ancestor topic receives the original leaf topic as its
10//! first argument, not the ancestor it matched.
11//!
12//! # Delivery modes
13//!
14//! [`PubSub::publish`] is deferred. It queues delivery and returns right away.
15//! Subscribers run later, when you call [`PubSub::process_deferred`]. This
16//! mirrors a non-blocking publisher that does not wait on subscriber work.
17//!
18//! [`PubSub::publish_sync`] runs every matching subscriber before it returns.
19//!
20//! Both return `true` if the topic had at least one matching subscriber
21//! (direct, ancestor, or `*`), else `false`. The return value is computed
22//! before any subscriber runs.
23//!
24//! # Panics during delivery
25//!
26//! Default mode catches a panicking subscriber, finishes delivery to the rest,
27//! then re-raises the first panic after the dispatch. Set
28//! [`PubSub::set_immediate_exceptions`] to `true` to stop at the first panic
29//! and let it propagate, skipping the remaining subscribers.
30//!
31//! # Example
32//!
33//! ```
34//! use tiny_pubsub::PubSub;
35//! use std::cell::Cell;
36//! use std::rc::Rc;
37//!
38//! let bus: PubSub<&str> = PubSub::new();
39//! let hits = Rc::new(Cell::new(0));
40//! let h = hits.clone();
41//! bus.subscribe("car.engine", move |_topic, _data| h.set(h.get() + 1));
42//!
43//! assert!(bus.publish_sync("car.engine", "start"));
44//! assert_eq!(hits.get(), 1);
45//! ```
46
47#![forbid(unsafe_code)]
48#![warn(missing_docs)]
49
50use std::cell::RefCell;
51use std::panic::{catch_unwind, AssertUnwindSafe};
52use std::rc::Rc;
53
54/// The wildcard topic. A subscriber here receives every published message.
55pub const WILDCARD: &str = "*";
56
57/// A handle to one subscription, returned by [`PubSub::subscribe`].
58///
59/// Tokens are unique for the life of a bus and never reused, even after a
60/// subscriber is removed. Pass a token to [`PubSub::unsubscribe`] to remove
61/// that single subscription.
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
63pub struct Token(String);
64
65impl Token {
66    /// The token's string form, shaped `uid_<n>`.
67    #[must_use]
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71}
72
73impl std::fmt::Display for Token {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(&self.0)
76    }
77}
78
79/// A boxed subscriber callback.
80type Callback<D> = Rc<dyn Fn(&str, &D)>;
81
82/// One stored subscription: its token paired with the callback.
83struct Entry<D> {
84    token: Token,
85    callback: Callback<D>,
86    /// When true, the entry is removed before its first invocation.
87    once: bool,
88}
89
90/// One topic's subscribers, kept in insertion order so delivery order is
91/// stable.
92struct Topic<D> {
93    entries: Vec<Entry<D>>,
94}
95
96impl<D> Topic<D> {
97    fn new() -> Self {
98        Topic {
99            entries: Vec::new(),
100        }
101    }
102}
103
104/// Mutable state shared behind a [`RefCell`].
105struct Inner<D> {
106    /// Topic name to its subscriber list. Insertion-ordered so delivery and
107    /// iteration order stay stable.
108    topics: Vec<(String, Topic<D>)>,
109    /// Monotonic token counter. Starts at -1 so the first token is `uid_0`.
110    last_uid: i64,
111    /// Pending deferred deliveries, drained by [`PubSub::process_deferred`].
112    deferred: Vec<DeferredJob<D>>,
113}
114
115/// A captured deferred publish: the leaf topic and the data to deliver.
116struct DeferredJob<D> {
117    message: String,
118    data: D,
119    immediate_exceptions: bool,
120}
121
122/// Yield `message`, then each ancestor prefix, then the wildcard topic.
123///
124/// The ancestor prefixes are slices of `message`, so the walk allocates
125/// nothing. For `a.b.c` this yields `a.b.c`, `a.b`, `a`, `*`. Publishing `*`
126/// itself yields it once, so wildcard subscribers fire once.
127fn delivery_levels(message: &str) -> impl Iterator<Item = &str> {
128    let trailing_wildcard = (message != WILDCARD).then_some(WILDCARD);
129    std::iter::once(message)
130        .chain(message.rmatch_indices('.').map(move |(i, _)| &message[..i]))
131        .chain(trailing_wildcard)
132}
133
134/// An in-process hierarchical-topic pub/sub message bus.
135///
136/// `D` is the payload type passed to subscribers. The bus is single-threaded
137/// and uses interior mutability, so subscribers may call back into the bus
138/// during synchronous delivery.
139pub struct PubSub<D> {
140    inner: RefCell<Inner<D>>,
141    /// Read at publish time. When true, the first panicking subscriber aborts
142    /// delivery and propagates immediately.
143    immediate_exceptions: std::cell::Cell<bool>,
144}
145
146impl<D> Default for PubSub<D> {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl<D> std::fmt::Debug for PubSub<D> {
153    /// Print topic names with their subscriber counts. Payloads and callbacks
154    /// are never formatted, so `D` need not be `Debug`.
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        let inner = self.inner.borrow();
157        f.debug_struct("PubSub")
158            .field(
159                "topics",
160                &inner
161                    .topics
162                    .iter()
163                    .map(|(name, t)| (name.as_str(), t.entries.len()))
164                    .collect::<Vec<_>>(),
165            )
166            .field("last_uid", &inner.last_uid)
167            .field("pending", &inner.deferred.len())
168            .field("immediate_exceptions", &self.immediate_exceptions.get())
169            .finish()
170    }
171}
172
173impl<D> PubSub<D> {
174    /// Create an empty bus.
175    #[must_use]
176    pub fn new() -> Self {
177        PubSub {
178            inner: RefCell::new(Inner {
179                topics: Vec::new(),
180                last_uid: -1,
181                deferred: Vec::new(),
182            }),
183            immediate_exceptions: std::cell::Cell::new(false),
184        }
185    }
186
187    /// Set whether a panicking subscriber aborts delivery immediately.
188    ///
189    /// Read fresh on each publish. Toggling it changes only later publishes.
190    pub fn set_immediate_exceptions(&self, value: bool) {
191        self.immediate_exceptions.set(value);
192    }
193
194    /// Current value of the immediate-exceptions flag.
195    #[must_use]
196    pub fn immediate_exceptions(&self) -> bool {
197        self.immediate_exceptions.get()
198    }
199
200    /// Subscribe `func` to `topic`. Returns a [`Token`] for later removal.
201    ///
202    /// Each call gets a fresh, unique token, even for the same callback on the
203    /// same topic. The topic does not need to exist first.
204    pub fn subscribe<F>(&self, topic: impl Into<String>, func: F) -> Token
205    where
206        F: Fn(&str, &D) + 'static,
207    {
208        self.subscribe_entry(topic.into(), Rc::new(func), false)
209    }
210
211    /// Insert a subscriber entry, optionally marked one-shot. Returns its token.
212    fn subscribe_entry(&self, topic: String, callback: Callback<D>, once: bool) -> Token {
213        let mut inner = self.inner.borrow_mut();
214        inner.last_uid += 1;
215        let token = Token(format!("uid_{}", inner.last_uid));
216        let entry = Entry {
217            token: token.clone(),
218            callback,
219            once,
220        };
221        match inner.topics.iter_mut().find(|(name, _)| name == &topic) {
222            Some((_, t)) => t.entries.push(entry),
223            None => {
224                let mut t = Topic::new();
225                t.entries.push(entry);
226                inner.topics.push((topic, t));
227            }
228        }
229        token
230    }
231
232    /// Subscribe `func` to the wildcard topic `*`. It then runs for every
233    /// published message. Returns the token, like [`PubSub::subscribe`].
234    pub fn subscribe_all<F>(&self, func: F) -> Token
235    where
236        F: Fn(&str, &D) + 'static,
237    {
238        self.subscribe(WILDCARD, func)
239    }
240
241    /// Subscribe `func` to fire at most once.
242    ///
243    /// Delivery removes the entry before it calls `func`. A republish of the
244    /// same topic from inside `func` does not retrigger it.
245    pub fn subscribe_once<F>(&self, topic: impl Into<String>, func: F) -> Token
246    where
247        F: Fn(&str, &D) + 'static,
248    {
249        self.subscribe_entry(topic.into(), Rc::new(func), true)
250    }
251
252    /// Publish `data` to `topic` for deferred delivery.
253    ///
254    /// Queues delivery and returns at once. Subscribers run on the next
255    /// [`PubSub::process_deferred`] call. Returns `true` if the topic had at
256    /// least one matching subscriber when this was called.
257    #[must_use]
258    pub fn publish(&self, topic: impl Into<String>, data: D) -> bool {
259        let message = topic.into();
260        let immediate = self.immediate_exceptions.get();
261        let has = self.message_has_subscribers(&message);
262        if !has {
263            return false;
264        }
265        self.inner.borrow_mut().deferred.push(DeferredJob {
266            message,
267            data,
268            immediate_exceptions: immediate,
269        });
270        true
271    }
272
273    /// Publish `data` to `topic` and deliver to all matching subscribers now.
274    ///
275    /// Returns `true` if there was at least one matching subscriber, else
276    /// `false`. The return value is computed before any subscriber runs.
277    #[must_use]
278    pub fn publish_sync(&self, topic: impl Into<String>, data: D) -> bool {
279        let message = topic.into();
280        let immediate = self.immediate_exceptions.get();
281        let has = self.message_has_subscribers(&message);
282        if !has {
283            return false;
284        }
285        self.deliver(&message, &data, immediate);
286        true
287    }
288
289    /// Run the deliveries queued by [`PubSub::publish`] so far, in call order.
290    ///
291    /// This stands in for one event loop tick. It drains the jobs queued before
292    /// the call. A subscriber that publishes during the drain queues its job for
293    /// the next call, not this one. Call again to drain those.
294    ///
295    /// Under delayed exceptions a panicking subscriber does not stop the other
296    /// jobs in the batch. Every job runs, then the first panic is re-raised
297    /// after the batch drains. Under immediate exceptions the first panic
298    /// propagates at once and the rest of the batch does not run.
299    pub fn process_deferred(&self) {
300        let batch = std::mem::take(&mut self.inner.borrow_mut().deferred);
301        let mut held_panic: Option<Box<dyn std::any::Any + Send>> = None;
302        for job in batch {
303            if job.immediate_exceptions {
304                self.deliver(&job.message, &job.data, true);
305            } else if let Err(panic) = catch_unwind(AssertUnwindSafe(|| {
306                self.deliver(&job.message, &job.data, false);
307            })) {
308                if held_panic.is_none() {
309                    held_panic = Some(panic);
310                }
311            }
312        }
313        if let Some(panic) = held_panic {
314            std::panic::resume_unwind(panic);
315        }
316    }
317
318    /// Number of queued deferred deliveries.
319    #[must_use]
320    pub fn pending(&self) -> usize {
321        self.inner.borrow().deferred.len()
322    }
323
324    /// Deliver `data` to every subscriber of `message` and its ancestors,
325    /// then the wildcard topic.
326    fn deliver(&self, message: &str, data: &D, immediate_exceptions: bool) {
327        // Hold the first caught panic and re-raise it after the whole
328        // dispatch, so the remaining subscribers still run.
329        let mut held_panic: Option<Box<dyn std::any::Any + Send>> = None;
330
331        for level in delivery_levels(message) {
332            // Snapshot the matched callbacks before invoking any, so a
333            // subscriber that mutates the registry mid-delivery cannot skip a
334            // peer that was matched for this publish. One-shot entries are
335            // removed here, before invocation, so a re-entrant publish from a
336            // subscriber cannot retrigger them.
337            let snapshot: Vec<Callback<D>> = {
338                let mut inner = self.inner.borrow_mut();
339                match inner.topics.iter_mut().find(|(name, _)| name == level) {
340                    Some((_, t)) => {
341                        let snapshot = t.entries.iter().map(|e| e.callback.clone()).collect();
342                        t.entries.retain(|e| !e.once);
343                        snapshot
344                    }
345                    None => Vec::new(),
346                }
347            };
348            for callback in snapshot {
349                if immediate_exceptions {
350                    callback(message, data);
351                } else if let Err(panic) =
352                    catch_unwind(AssertUnwindSafe(|| callback(message, data)))
353                {
354                    if held_panic.is_none() {
355                        held_panic = Some(panic);
356                    }
357                }
358            }
359        }
360
361        if let Some(panic) = held_panic {
362            std::panic::resume_unwind(panic);
363        }
364    }
365
366    /// True if `message`, any ancestor, or the wildcard topic has a subscriber.
367    fn message_has_subscribers(&self, message: &str) -> bool {
368        let inner = self.inner.borrow();
369        delivery_levels(message).any(|level| {
370            inner
371                .topics
372                .iter()
373                .any(|(name, t)| name == level && !t.entries.is_empty())
374        })
375    }
376
377    /// Remove the single subscription identified by `token`.
378    ///
379    /// Returns the token if it matched a live subscription, else `None`. A
380    /// second removal of the same token returns `None`.
381    pub fn unsubscribe(&self, token: &Token) -> Option<Token> {
382        let mut inner = self.inner.borrow_mut();
383        for (_, t) in inner.topics.iter_mut() {
384            if let Some(idx) = t.entries.iter().position(|e| &e.token == token) {
385                t.entries.remove(idx);
386                return Some(token.clone());
387            }
388        }
389        None
390    }
391
392    /// Remove a topic and every descendant topic, by string prefix.
393    ///
394    /// Returns `true` if `topic` names an existing topic or a prefix of one,
395    /// else `false`. Matching is a raw string prefix, not dot-boundary aware.
396    /// `unsubscribe_topic("a")` therefore removes `a`, `a.b`, and `ab` alike.
397    pub fn unsubscribe_topic(&self, topic: &str) -> bool {
398        let is_topic = {
399            let inner = self.inner.borrow();
400            inner.topics.iter().any(|(name, _)| name.starts_with(topic))
401        };
402        if is_topic {
403            self.clear_subscriptions(topic);
404        }
405        is_topic
406    }
407
408    /// Empty the whole registry. Does not reset the token counter.
409    pub fn clear_all_subscriptions(&self) {
410        self.inner.borrow_mut().topics.clear();
411    }
412
413    /// Remove every topic whose name starts with `topic` (raw string prefix).
414    pub fn clear_subscriptions(&self, topic: &str) {
415        self.inner
416            .borrow_mut()
417            .topics
418            .retain(|(name, _)| !name.starts_with(topic));
419    }
420
421    /// Count subscribers in the first registered topic whose name starts with
422    /// `topic`, then stop.
423    ///
424    /// This counts one topic, not the sum across the hierarchy. It stops at the
425    /// first prefix match and returns that topic's subscriber count.
426    #[must_use]
427    pub fn count_subscriptions(&self, topic: &str) -> usize {
428        let inner = self.inner.borrow();
429        for (name, t) in &inner.topics {
430            if name.starts_with(topic) {
431                return t.entries.len();
432            }
433        }
434        0
435    }
436
437    /// List every registered topic name that starts with `topic`, in
438    /// insertion order.
439    #[must_use]
440    pub fn get_subscriptions(&self, topic: &str) -> Vec<String> {
441        let inner = self.inner.borrow();
442        inner
443            .topics
444            .iter()
445            .filter(|(name, _)| name.starts_with(topic))
446            .map(|(name, _)| name.clone())
447            .collect()
448    }
449}