Skip to main content

max / makenotwork

Add livechat hub One broadcast channel per room, created on first listener and pruned on last. Correct for the single-process deployment both consumers have; multi-node means putting LISTEN/NOTIFY behind Hub::publish, which is a known second entry on the same list as the in-process rate limiter. Diverges from the SyncKit SSE pattern twice, both deliberate. Pruning uses DashMap::remove_if so the predicate runs under the shard lock: SyncKit reads receiver_count(), drops the guard, then removes, and a subscriber arriving in between is left holding a receiver on a sender that is about to leave the map, receiving nothing thereafter. And lag becomes ChatEvent::Gap rather than being swallowed, so the client re-fetches from its cursor instead of sitting in a room with a hole. The connection permit is acquired before anything it guards, so there is no window where a counter is incremented but nothing owns releasing it. tests/concurrency.rs covers the orphan race, counter drift under contention, and delivery to a stable listener through churn. Verified to bite: the orphan test fails 3/3 runs against the read-then-remove pattern and passes against remove_if. Design: wiki livechat-design Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 23:48 UTC
Commit: 7126666c61549b5a89413d58589859937907258c
Parent: e45245c
6 files changed, +742 insertions, -2 deletions
@@ -14,6 +14,12 @@ dependencies = [
14 14 ]
15 15
16 16 [[package]]
17 + name = "bitflags"
18 + version = "2.13.1"
19 + source = "registry+https://github.com/rust-lang/crates.io-index"
20 + checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
21 +
22 + [[package]]
17 23 name = "bumpalo"
18 24 version = "3.20.3"
19 25 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -26,6 +32,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
26 32 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
27 33
28 34 [[package]]
35 + name = "crossbeam-utils"
36 + version = "0.8.22"
37 + source = "registry+https://github.com/rust-lang/crates.io-index"
38 + checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
39 +
40 + [[package]]
41 + name = "dashmap"
42 + version = "6.2.1"
43 + source = "registry+https://github.com/rust-lang/crates.io-index"
44 + checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
45 + dependencies = [
46 + "cfg-if",
47 + "crossbeam-utils",
48 + "hashbrown",
49 + "lock_api",
50 + "once_cell",
51 + "parking_lot_core",
52 + ]
53 +
54 + [[package]]
29 55 name = "futures-core"
30 56 version = "0.3.33"
31 57 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -61,6 +87,12 @@ dependencies = [
61 87 ]
62 88
63 89 [[package]]
90 + name = "hashbrown"
91 + version = "0.14.5"
92 + source = "registry+https://github.com/rust-lang/crates.io-index"
93 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
94 +
95 + [[package]]
64 96 name = "itoa"
65 97 version = "1.0.18"
66 98 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -88,6 +120,7 @@ name = "livechat"
88 120 version = "0.1.0"
89 121 dependencies = [
90 122 "async-trait",
123 + "dashmap",
91 124 "serde",
92 125 "serde_json",
93 126 "thiserror",
@@ -96,6 +129,15 @@ dependencies = [
96 129 ]
97 130
98 131 [[package]]
132 + name = "lock_api"
133 + version = "0.4.14"
134 + source = "registry+https://github.com/rust-lang/crates.io-index"
135 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
136 + dependencies = [
137 + "scopeguard",
138 + ]
139 +
140 + [[package]]
99 141 name = "memchr"
100 142 version = "2.8.3"
101 143 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -108,6 +150,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
108 150 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
109 151
110 152 [[package]]
153 + name = "parking_lot_core"
154 + version = "0.9.12"
155 + source = "registry+https://github.com/rust-lang/crates.io-index"
156 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
157 + dependencies = [
158 + "cfg-if",
159 + "libc",
160 + "redox_syscall",
161 + "smallvec",
162 + "windows-link",
163 + ]
164 +
165 + [[package]]
111 166 name = "pin-project-lite"
112 167 version = "0.2.17"
113 168 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -138,12 +193,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
138 193 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
139 194
140 195 [[package]]
196 + name = "redox_syscall"
197 + version = "0.5.18"
198 + source = "registry+https://github.com/rust-lang/crates.io-index"
199 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
200 + dependencies = [
201 + "bitflags",
202 + ]
203 +
204 + [[package]]
141 205 name = "rustversion"
142 206 version = "1.0.23"
143 207 source = "registry+https://github.com/rust-lang/crates.io-index"
144 208 checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
145 209
146 210 [[package]]
211 + name = "scopeguard"
212 + version = "1.2.0"
213 + source = "registry+https://github.com/rust-lang/crates.io-index"
214 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
215 +
216 + [[package]]
147 217 name = "serde"
148 218 version = "1.0.229"
149 219 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -193,6 +263,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
193 263 checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
194 264
195 265 [[package]]
266 + name = "smallvec"
267 + version = "1.15.2"
268 + source = "registry+https://github.com/rust-lang/crates.io-index"
269 + checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
270 +
271 + [[package]]
196 272 name = "syn"
197 273 version = "2.0.119"
198 274 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -319,6 +395,12 @@ dependencies = [
319 395 ]
320 396
321 397 [[package]]
398 + name = "windows-link"
399 + version = "0.2.1"
400 + source = "registry+https://github.com/rust-lang/crates.io-index"
401 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
402 +
403 + [[package]]
322 404 name = "zmij"
323 405 version = "1.0.23"
324 406 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5,10 +5,12 @@ edition = "2024"
5 5
6 6 [dependencies]
7 7 async-trait = "0.1"
8 + dashmap = "6"
8 9 serde = { version = "1", features = ["derive"] }
9 10 thiserror = "2"
11 + tokio = { version = "1", features = ["sync"] }
10 12 uuid = { version = "1", features = ["v4", "serde"] }
11 13
12 14 [dev-dependencies]
13 15 serde_json = "1"
14 - tokio = { version = "1", features = ["macros", "rt"] }
16 + tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "sync", "time"] }
@@ -6,6 +6,28 @@
6 6
7 7 use std::time::Duration;
8 8
9 + /// Which budget a connection ran out of.
10 + ///
11 + /// Worth distinguishing: [`LimitScope::User`] means this client should close a
12 + /// tab, while [`LimitScope::Global`] means the process is saturated and is a
13 + /// signal an operator needs, not an end user.
14 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
15 + pub enum LimitScope {
16 + /// This user holds too many concurrent connections.
17 + User,
18 + /// The process is at its total connection budget.
19 + Global,
20 + }
21 +
22 + impl std::fmt::Display for LimitScope {
23 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 + match self {
25 + Self::User => f.write_str("per-user"),
26 + Self::Global => f.write_str("global"),
27 + }
28 + }
29 + }
30 +
9 31 /// Anything that can go wrong in the crate or in a host's trait impl.
10 32 #[derive(Debug, thiserror::Error)]
11 33 pub enum ChatError {
@@ -33,6 +55,11 @@ pub enum ChatError {
33 55 #[error("room is read-only")]
34 56 RoomReadOnly,
35 57
58 + /// At capacity. The caller has already passed authz by this point, so this is
59 + /// a resource refusal and belongs in the 503 family, not the 403 one.
60 + #[error("connection limit reached ({0})")]
61 + ConnectionLimit(LimitScope),
62 +
36 63 /// An error from the host app's trait impl: a query failure, a session
37 64 /// lookup, anything the crate has no vocabulary for.
38 65 #[error("host error: {0}")]
@@ -0,0 +1,446 @@
1 + //! In-process fan-out.
2 + //!
3 + //! One [`tokio::sync::broadcast`] channel per room, created when the first
4 + //! listener arrives and pruned when the last one leaves. This is correct for the
5 + //! deployment both consumers actually have (a single process on a single box) and
6 + //! is knowingly the wrong shape for more than one node. Going multi-node means
7 + //! putting Postgres `LISTEN/NOTIFY` or a message bus behind [`Hub::publish`]; it
8 + //! is a second entry on the same list as the in-process rate limiter, not a
9 + //! surprise.
10 + //!
11 + //! # Why the connection caps are load-bearing
12 + //!
13 + //! Both units run under `MemoryMax=512M`, a hard cgroup cap. Each listener holds
14 + //! a task, a buffer, and a broadcast receiver, and an OOM does not degrade chat,
15 + //! it restarts the whole site. So the caps here are a availability control, not
16 + //! tidiness, and they are enforced before any allocation the connection would
17 + //! own.
18 + //!
19 + //! # Divergence from the SyncKit SSE pattern
20 + //!
21 + //! This is modeled on `server/src/routes/synckit/subscribe.rs` and deviates from
22 + //! it twice, both deliberately:
23 + //!
24 + //! 1. **Pruning is atomic.** SyncKit reads `receiver_count()`, drops the guard,
25 + //! then calls `remove()`. Between those two steps a new subscriber can take
26 + //! the entry, and the `remove()` then drops a channel that has a live
27 + //! listener; the next publish lazily creates a *different* channel and the
28 + //! listener silently receives nothing. SyncKit tolerates this because its
29 + //! clients re-pull on their own schedule. A chat client would just sit in a
30 + //! dead room. [`DashMap::remove_if`] runs the predicate under the shard lock,
31 + //! which closes the race and still avoids the same-shard deadlock that made
32 + //! SyncKit split the read and the remove in the first place.
33 + //! 2. **Lag is surfaced, not swallowed.** SyncKit discards `Lagged`. Here it
34 + //! becomes [`ChatEvent::Gap`] so the client re-fetches from its cursor,
35 + //! because a room with a silent hole in it is worse than a visible one.
36 +
37 + use std::sync::Arc;
38 + use std::sync::atomic::{AtomicUsize, Ordering};
39 +
40 + use dashmap::DashMap;
41 + use tokio::sync::broadcast;
42 +
43 + use crate::error::{ChatError, LimitScope};
44 + use crate::event::ChatEvent;
45 + use crate::ids::{RoomId, UserId};
46 +
47 + /// Caps and buffer sizes. Every consumer sets these explicitly; there is no
48 + /// unbounded configuration.
49 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
50 + pub struct HubLimits {
51 + /// Concurrent listeners one user may hold. A few, for multiple tabs. Not a
52 + /// spam control, a fairness one: without it a single client can consume the
53 + /// global budget.
54 + pub max_connections_per_user: usize,
55 + /// Concurrent listeners across every room in the process. This is the number
56 + /// that keeps the box alive.
57 + pub max_connections_total: usize,
58 + /// Messages buffered per room before a slow listener starts missing events
59 + /// and gets a [`ChatEvent::Gap`].
60 + pub room_buffer: usize,
61 + }
62 +
63 + impl Default for HubLimits {
64 + fn default() -> Self {
65 + Self {
66 + max_connections_per_user: 4,
67 + max_connections_total: 2_000,
68 + room_buffer: 256,
69 + }
70 + }
71 + }
72 +
73 + struct Inner {
74 + rooms: DashMap<RoomId, broadcast::Sender<ChatEvent>>,
75 + per_user: DashMap<UserId, AtomicUsize>,
76 + total: AtomicUsize,
77 + limits: HubLimits,
78 + }
79 +
80 + /// Per-room fan-out with connection accounting.
81 + ///
82 + /// Cheap to clone; both consumers hang one off application state.
83 + #[derive(Clone)]
84 + pub struct Hub {
85 + inner: Arc<Inner>,
86 + }
87 +
88 + impl Hub {
89 + pub fn new(limits: HubLimits) -> Self {
90 + Self {
91 + inner: Arc::new(Inner {
92 + rooms: DashMap::new(),
93 + per_user: DashMap::new(),
94 + total: AtomicUsize::new(0),
95 + limits,
96 + }),
97 + }
98 + }
99 +
100 + /// Send to everyone listening to `room`, returning how many received it.
101 + ///
102 + /// Zero is normal and not an error: an empty room is the common case, and a
103 + /// message still lands in storage regardless of who is connected.
104 + pub fn publish(&self, room: RoomId, event: ChatEvent) -> usize {
105 + self.inner
106 + .rooms
107 + .get(&room)
108 + .and_then(|tx| tx.send(event).ok())
109 + .unwrap_or(0)
110 + }
111 +
112 + /// Start listening to `room`.
113 + ///
114 + /// # Errors
115 + ///
116 + /// [`ChatError::ConnectionLimit`] when the user or the process is at capacity.
117 + /// The caller has already passed authz by this point, so this is a resource
118 + /// refusal and should read as one: it is a 503, not a 403.
119 + pub fn subscribe(&self, room: RoomId, user: UserId) -> Result<Subscription, ChatError> {
120 + // The permit is acquired first and owns the rollback. There is no window
121 + // in which a counter is incremented but nothing is responsible for
122 + // decrementing it, including if anything below panics.
123 + let permit = ConnectionPermit::acquire(self.inner.clone(), user)?;
124 +
125 + let rx = self
126 + .inner
127 + .rooms
128 + .entry(room)
129 + .or_insert_with(|| broadcast::channel(self.inner.limits.room_buffer).0)
130 + .subscribe();
131 +
132 + Ok(Subscription {
133 + rx,
134 + room,
135 + _permit: permit,
136 + })
137 + }
138 +
139 + /// Rooms currently holding a channel. Diagnostics only.
140 + pub fn room_count(&self) -> usize {
141 + self.inner.rooms.len()
142 + }
143 +
144 + /// Listeners across every room. Worth exposing in a health payload: it is the
145 + /// number that predicts the memory cap.
146 + pub fn connection_count(&self) -> usize {
147 + self.inner.total.load(Ordering::Acquire)
148 + }
149 +
150 + /// Listeners in one room.
151 + pub fn room_connection_count(&self, room: RoomId) -> usize {
152 + self.inner
153 + .rooms
154 + .get(&room)
155 + .map(|tx| tx.receiver_count())
156 + .unwrap_or(0)
157 + }
158 + }
159 +
160 + /// Holds one connection's slot in the global and per-user counters, releasing
161 + /// both on drop.
162 + struct ConnectionPermit {
163 + inner: Arc<Inner>,
164 + user: UserId,
165 + }
166 +
167 + impl ConnectionPermit {
168 + fn acquire(inner: Arc<Inner>, user: UserId) -> Result<Self, ChatError> {
169 + // Global first: it is a single atomic, so the cheap check rejects the
170 + // overload case without touching the per-user map at all.
171 + let prev = inner.total.fetch_add(1, Ordering::AcqRel);
172 + if prev >= inner.limits.max_connections_total {
173 + inner.total.fetch_sub(1, Ordering::AcqRel);
174 + return Err(ChatError::ConnectionLimit(LimitScope::Global));
175 + }
176 +
177 + let over_user_limit = {
178 + let entry = inner.per_user.entry(user).or_insert_with(|| AtomicUsize::new(0));
179 + let prev = entry.value().fetch_add(1, Ordering::AcqRel);
180 + if prev >= inner.limits.max_connections_per_user {
181 + entry.value().fetch_sub(1, Ordering::AcqRel);
182 + true
183 + } else {
184 + false
185 + }
186 + };
187 +
188 + if over_user_limit {
189 + inner.total.fetch_sub(1, Ordering::AcqRel);
190 + // The entry may now be at zero and prunable, but leaving it costs one
191 + // small map slot and the next successful subscribe reuses it. Do not
192 + // remove here: this path is reached under contention, which is
193 + // exactly when an extra shard lock is least welcome.
194 + return Err(ChatError::ConnectionLimit(LimitScope::User));
195 + }
196 +
197 + Ok(Self { inner, user })
198 + }
199 + }
200 +
201 + impl Drop for ConnectionPermit {
202 + fn drop(&mut self) {
203 + self.inner.total.fetch_sub(1, Ordering::AcqRel);
204 +
205 + if let Some(entry) = self.inner.per_user.get(&self.user) {
206 + entry.value().fetch_sub(1, Ordering::AcqRel);
207 + }
208 + // Predicate runs under the shard lock, so a user reconnecting in this
209 + // instant either keeps their entry or gets a fresh one. Never both.
210 + self.inner
211 + .per_user
212 + .remove_if(&self.user, |_, count| count.load(Ordering::Acquire) == 0);
213 + }
214 + }
215 +
216 + /// A live listener on one room.
217 + ///
218 + /// Dropping it releases the connection slot and prunes the room channel if this
219 + /// was the last listener.
220 + pub struct Subscription {
221 + rx: broadcast::Receiver<ChatEvent>,
222 + room: RoomId,
223 + _permit: ConnectionPermit,
224 + }
225 +
226 + impl Subscription {
227 + /// Next event, or `None` once the room is gone and nothing more will arrive.
228 + ///
229 + /// A listener that fell behind gets [`ChatEvent::Gap`] rather than silence.
230 + /// The events it missed are not recoverable from the channel by design: the
231 + /// client re-fetches from its cursor, which is the same path a reconnect
232 + /// after a deploy takes.
233 + pub async fn recv(&mut self) -> Option<ChatEvent> {
234 + match self.rx.recv().await {
235 + Ok(event) => Some(event),
236 + Err(broadcast::error::RecvError::Lagged(_)) => Some(ChatEvent::Gap),
237 + Err(broadcast::error::RecvError::Closed) => None,
238 + }
239 + }
240 +
241 + pub fn room(&self) -> RoomId {
242 + self.room
243 + }
244 + }
245 +
246 + impl Drop for Subscription {
247 + fn drop(&mut self) {
248 + // `self.rx` is still alive here (fields drop after this body), so this
249 + // subscription is still counted. Compare against 1, not 0.
250 + //
251 + // The predicate runs under the shard lock, so a subscriber arriving in
252 + // this instant either keeps this channel or creates a fresh one after the
253 + // removal. It cannot end up holding a receiver on a sender that has just
254 + // been dropped from the map, which is the silent-dead-room failure.
255 + self._permit
256 + .inner
257 + .rooms
258 + .remove_if(&self.room, |_, tx| tx.receiver_count() <= 1);
259 + }
260 + }
261 +
262 + #[cfg(test)]
263 + mod tests {
264 + use super::*;
265 + use crate::ids::MessageId;
266 + use uuid::Uuid;
267 +
268 + fn hub(limits: HubLimits) -> Hub {
269 + Hub::new(limits)
270 + }
271 +
272 + fn room() -> RoomId {
273 + RoomId(Uuid::new_v4())
274 + }
275 +
276 + fn user() -> UserId {
277 + UserId(Uuid::new_v4())
278 + }
279 +
280 + fn delete(id: i64) -> ChatEvent {
281 + ChatEvent::Delete {
282 + id: MessageId(id),
283 + }
284 + }
285 +
286 + #[tokio::test]
287 + async fn delivers_to_every_listener_in_the_room() {
288 + let hub = hub(HubLimits::default());
289 + let r = room();
290 + let mut a = hub.subscribe(r, user()).unwrap();
291 + let mut b = hub.subscribe(r, user()).unwrap();
292 +
293 + assert_eq!(hub.publish(r, delete(1)), 2);
294 + assert_eq!(a.recv().await, Some(delete(1)));
295 + assert_eq!(b.recv().await, Some(delete(1)));
296 + }
297 +
298 + #[tokio::test]
299 + async fn rooms_are_isolated() {
300 + let hub = hub(HubLimits::default());
301 + let (r1, r2) = (room(), room());
302 + let mut listener = hub.subscribe(r1, user()).unwrap();
303 +
304 + assert_eq!(hub.publish(r2, delete(1)), 0);
305 + assert_eq!(hub.publish(r1, delete(2)), 1);
306 + assert_eq!(listener.recv().await, Some(delete(2)));
307 + }
308 +
309 + #[test]
310 + fn publishing_to_an_empty_room_is_not_an_error() {
311 + let hub = hub(HubLimits::default());
312 + assert_eq!(hub.publish(room(), delete(1)), 0);
313 + // and does not conjure a channel for a room nobody is listening to
314 + assert_eq!(hub.room_count(), 0);
315 + }
316 +
317 + #[test]
318 + fn room_channel_is_pruned_when_the_last_listener_leaves() {
319 + let hub = hub(HubLimits::default());
320 + let r = room();
321 +
322 + let a = hub.subscribe(r, user()).unwrap();
323 + let b = hub.subscribe(r, user()).unwrap();
324 + assert_eq!(hub.room_count(), 1);
325 +
326 + drop(a);
327 + assert_eq!(hub.room_count(), 1, "one listener remains");
328 +
329 + drop(b);
330 + assert_eq!(hub.room_count(), 0, "last listener pruned the room");
331 + }
332 +
333 + #[tokio::test]
334 + async fn a_room_recreated_after_pruning_still_delivers() {
335 + // The failure this guards: pruning a channel that a new subscriber has
336 + // just taken, leaving them attached to an orphaned sender.
337 + let hub = hub(HubLimits::default());
338 + let r = room();
339 +
340 + let first = hub.subscribe(r, user()).unwrap();
341 + drop(first);
342 + assert_eq!(hub.room_count(), 0);
343 +
344 + let mut second = hub.subscribe(r, user()).unwrap();
345 + assert_eq!(hub.publish(r, delete(9)), 1);
346 + assert_eq!(second.recv().await, Some(delete(9)));
347 + }
348 +
349 + #[test]
350 + fn per_user_limit_is_enforced_and_released() {
351 + let hub = hub(HubLimits {
352 + max_connections_per_user: 2,
353 + ..HubLimits::default()
354 + });
355 + let r = room();
356 + let u = user();
357 +
358 + let a = hub.subscribe(r, u).unwrap();
359 + let b = hub.subscribe(r, u).unwrap();
360 + assert!(matches!(
361 + hub.subscribe(r, u),
362 + Err(ChatError::ConnectionLimit(LimitScope::User))
363 + ));
364 +
365 + // A different user is unaffected by another's exhausted budget.
366 + let other = hub.subscribe(r, user()).unwrap();
367 +
368 + drop(a);
369 + let _c = hub.subscribe(r, u).expect("slot released on drop");
370 + drop((b, other));
371 + }
372 +
373 + #[test]
374 + fn global_limit_is_enforced_and_released() {
375 + let hub = hub(HubLimits {
376 + max_connections_total: 2,
377 + ..HubLimits::default()
378 + });
379 + let r = room();
380 +
381 + let a = hub.subscribe(r, user()).unwrap();
382 + let b = hub.subscribe(r, user()).unwrap();
383 + assert!(matches!(
384 + hub.subscribe(r, user()),
385 + Err(ChatError::ConnectionLimit(LimitScope::Global))
386 + ));
387 +
388 + drop(a);
389 + assert_eq!(hub.connection_count(), 1);
390 + let _c = hub.subscribe(r, user()).expect("slot released on drop");
391 + drop(b);
392 + }
393 +
394 + #[test]
395 + fn a_rejected_subscribe_leaks_no_slot() {
396 + let hub = hub(HubLimits {
397 + max_connections_per_user: 1,
398 + ..HubLimits::default()
399 + });
400 + let r = room();
401 + let u = user();
402 +
403 + let held = hub.subscribe(r, u).unwrap();
404 + for _ in 0..10 {
405 + assert!(hub.subscribe(r, u).is_err());
406 + }
407 + // The global counter must not have drifted upward on the rejected path.
408 + assert_eq!(hub.connection_count(), 1);
409 + drop(held);
410 + assert_eq!(hub.connection_count(), 0);
411 + }
412 +
413 + #[tokio::test]
414 + async fn a_listener_that_falls_behind_gets_a_gap_not_silence() {
415 + let hub = hub(HubLimits {
416 + room_buffer: 2,
417 + ..HubLimits::default()
418 + });
419 + let r = room();
420 + let mut slow = hub.subscribe(r, user()).unwrap();
421 +
422 + for i in 0..10 {
423 + hub.publish(r, delete(i));
424 + }
425 +
426 + assert_eq!(slow.recv().await, Some(ChatEvent::Gap));
427 + // and the stream keeps working afterward
428 + hub.publish(r, delete(99));
429 + let next = slow.recv().await;
430 + assert!(next.is_some(), "stream survives a gap");
431 + }
432 +
433 + #[test]
434 + fn counters_return_to_zero_when_everything_drops() {
435 + let hub = hub(HubLimits::default());
436 + let r = room();
437 + let subs: Vec<_> = (0..5).map(|_| hub.subscribe(r, user()).unwrap()).collect();
438 + assert_eq!(hub.connection_count(), 5);
439 + assert_eq!(hub.room_connection_count(r), 5);
440 +
441 + drop(subs);
442 + assert_eq!(hub.connection_count(), 0);
443 + assert_eq!(hub.room_count(), 0);
444 + assert_eq!(hub.room_connection_count(r), 0);
445 + }
446 + }
@@ -39,14 +39,16 @@
39 39
40 40 mod error;
41 41 mod event;
42 + mod hub;
42 43 mod ids;
43 44 mod message;
44 45 mod retention;
45 46 mod room;
46 47 mod traits;
47 48
48 - pub use error::ChatError;
49 + pub use error::{ChatError, LimitScope};
49 50 pub use event::ChatEvent;
51 + pub use hub::{Hub, HubLimits, Subscription};
50 52 pub use ids::{MessageId, Nonce, RoomId, UserId};
51 53 pub use message::{MAX_MESSAGE_LEN, Message, MessageBody};
52 54 pub use retention::{MAX_AGE_CEILING, MAX_MESSAGES_CEILING, Retention};
@@ -0,0 +1,181 @@
1 + //! Concurrent churn against the hub.
2 + //!
3 + //! The hub deliberately diverges from the SyncKit SSE pruning pattern, and the
4 + //! reason is a race that only appears under contention: read `receiver_count()`,
5 + //! drop the guard, then `remove()`, and a subscriber arriving between those steps
6 + //! is left holding a receiver whose sender has just been dropped from the map.
7 + //! Nothing errors. The subscriber simply never receives anything again.
8 + //!
9 + //! A single-threaded test cannot see that, so the claim is worth nothing without
10 + //! these. Each test here fails against the read-then-remove implementation and
11 + //! passes against `remove_if`.
12 +
13 + use std::sync::Arc;
14 + use std::sync::atomic::{AtomicUsize, Ordering};
15 +
16 + use livechat::{ChatEvent, Hub, HubLimits, MessageId, RoomId, UserId};
17 + use uuid::Uuid;
18 +
19 + fn event(id: i64) -> ChatEvent {
20 + ChatEvent::Delete {
21 + id: MessageId(id),
22 + }
23 + }
24 +
25 + /// Subscribe, immediately publish, and require delivery, while other tasks churn
26 + /// the same room's channel in and out of existence.
27 + ///
28 + /// This is the orphaned-subscriber failure stated directly: if pruning can race a
29 + /// subscribe, some iteration attaches to a sender that is about to be removed and
30 + /// the publish goes to a fresh channel it is not listening to.
31 + #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
32 + async fn a_subscriber_is_never_orphaned_by_a_concurrent_prune() {
33 + let hub = Hub::new(HubLimits {
34 + max_connections_per_user: 512,
35 + max_connections_total: 4096,
36 + room_buffer: 64,
37 + });
38 + let room = RoomId(Uuid::new_v4());
39 + let missed = Arc::new(AtomicUsize::new(0));
40 +
41 + let mut tasks = Vec::new();
42 + for _ in 0..16 {
43 + let hub = hub.clone();
44 + let missed = missed.clone();
45 + tasks.push(tokio::spawn(async move {
46 + for i in 0..200 {
47 + let mut sub = hub.subscribe(room, UserId(Uuid::new_v4())).unwrap();
48 +
49 + // Publish to our own subscription. Delivery is not optional: we
50 + // hold a live receiver on this room.
51 + let delivered = hub.publish(room, event(i));
52 + if delivered == 0 {
53 + missed.fetch_add(1, Ordering::Relaxed);
54 + continue;
55 + }
56 +
57 + match tokio::time::timeout(std::time::Duration::from_secs(5), sub.recv()).await {
58 + Ok(Some(_)) => {}
59 + _ => {
60 + missed.fetch_add(1, Ordering::Relaxed);
61 + }
62 + }
63 + // Drop here, racing every other task's subscribe.
64 + }
65 + }));
66 + }
67 +
68 + for t in tasks {
69 + t.await.unwrap();
70 + }
71 +
72 + assert_eq!(
73 + missed.load(Ordering::Relaxed),
74 + 0,
75 + "a live subscriber missed a publish, which means a prune raced a subscribe"
76 + );
77 + assert_eq!(hub.connection_count(), 0);
78 + assert_eq!(hub.room_count(), 0, "every room channel was pruned");
79 + }
80 +
81 + /// Connection accounting must survive concurrent acquire and release, including
82 + /// the rejected path. A counter that drifts upward silently shrinks the budget
83 + /// until the process refuses connections it has room for.
84 + #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
85 + async fn connection_counters_do_not_drift_under_contention() {
86 + let limits = HubLimits {
87 + max_connections_per_user: 2,
88 + max_connections_total: 16,
89 + room_buffer: 8,
90 + };
91 + let hub = Hub::new(limits);
92 + let rooms: Vec<RoomId> = (0..4).map(|_| RoomId(Uuid::new_v4())).collect();
93 + let users: Vec<UserId> = (0..4).map(|_| UserId(Uuid::new_v4())).collect();
94 +
95 + let mut tasks = Vec::new();
96 + for t in 0..16 {
97 + let hub = hub.clone();
98 + let room = rooms[t % rooms.len()];
99 + let user = users[t % users.len()];
100 + tasks.push(tokio::spawn(async move {
101 + for _ in 0..300 {
102 + // Most of these are rejected by one budget or the other. Every
103 + // rejection must leave both counters exactly as it found them.
104 + if let Ok(sub) = hub.subscribe(room, user) {
105 + tokio::task::yield_now().await;
106 + drop(sub);
107 + }
108 + }
109 + }));
110 + }
111 +
112 + for t in tasks {
113 + t.await.unwrap();
114 + }
115 +
116 + assert_eq!(
117 + hub.connection_count(),
118 + 0,
119 + "global counter drifted; some path incremented without releasing"
120 + );
121 + assert_eq!(hub.room_count(), 0, "room channels leaked");
122 +
123 + // The budget must be fully available again, which it is not if the counter
124 + // drifted even by one.
125 + let mut held = Vec::new();
126 + for i in 0..limits.max_connections_total {
127 + held.push(
128 + hub.subscribe(rooms[i % rooms.len()], UserId(Uuid::new_v4()))
129 + .expect("full budget available after churn"),
130 + );
131 + }
132 + assert_eq!(hub.connection_count(), limits.max_connections_total);
133 + }
134 +
135 + /// Publishes to a room must reach every listener while others come and go.
136 + #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
137 + async fn a_stable_listener_receives_everything_through_churn() {
138 + let hub = Hub::new(HubLimits {
139 + max_connections_per_user: 512,
140 + max_connections_total: 4096,
141 + room_buffer: 4096,
142 + });
143 + let room = RoomId(Uuid::new_v4());
144 +
145 + let mut stable = hub.subscribe(room, UserId(Uuid::new_v4())).unwrap();
146 +
147 + let churn = {
148 + let hub = hub.clone();
149 + tokio::spawn(async move {
150 + for _ in 0..500 {
151 + let s = hub.subscribe(room, UserId(Uuid::new_v4())).unwrap();
152 + tokio::task::yield_now().await;
153 + drop(s);
154 + }
155 + })
156 + };
157 +
158 + let publisher = {
159 + let hub = hub.clone();
160 + tokio::spawn(async move {
161 + for i in 0..500 {
162 + hub.publish(room, event(i));
163 + tokio::task::yield_now().await;
164 + }
165 + })
166 + };
167 +
168 + let mut received = 0;
169 + while received < 500 {
170 + match tokio::time::timeout(std::time::Duration::from_secs(5), stable.recv()).await {
171 + Ok(Some(ChatEvent::Gap)) => panic!("buffer was sized to make a gap impossible here"),
172 + Ok(Some(_)) => received += 1,
173 + Ok(None) => panic!("stable listener's room was closed out from under it"),
174 + Err(_) => panic!("stable listener stopped receiving after {received} events"),
175 + }
176 + }
177 +
178 + churn.await.unwrap();
179 + publisher.await.unwrap();
180 + assert_eq!(received, 500);
181 + }