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}