pjson_rs/infrastructure/bounded_channel.rs
1//! Byte-bounded mpsc channel.
2//!
3//! [`tokio::sync::mpsc::channel`] bounds queue depth by message *count*,
4//! not by queued bytes. When individual message sizes vary widely (e.g. a
5//! WebSocket frame governed only by a coarse frame-count ceiling), a
6//! count-bounded channel can still queue an unbounded amount of memory in
7//! the worst case: `capacity * largest_possible_message`.
8//!
9//! [`byte_bounded_channel`] layers an additional byte budget on top of a
10//! normal bounded channel using a [`Semaphore`]: the sender acquires
11//! `payload_len` permits before pushing, and the permit travels with the
12//! item in an [`Envelope`]. The permit is released back to the budget when
13//! the [`Envelope`] (or, if detached via [`Envelope::split`], the returned
14//! [`BudgetPermit`]) is dropped — not simply when the item leaves the
15//! channel's internal queue. That drop normally follows shortly after
16//! receipt, but a receiver can hold the bytes charged for longer, either
17//! by keeping the `Envelope` alive past receipt or by using `split` to
18//! detach the payload while keeping the permit held separately (e.g. for
19//! the duration of a socket write) — bounding worst-case queued memory to
20//! `max_queued_bytes` regardless of individual message size or how many
21//! items that adds up to.
22
23use std::sync::Arc;
24use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
25
26/// An item received from a channel created by [`byte_bounded_channel`],
27/// carrying the byte-budget permit it reserved.
28///
29/// Access the payload via [`Envelope::into_inner`] or through [`Deref`].
30/// The permit is released back to the channel's byte budget when this
31/// value is dropped, so receiving (and discarding, or finishing with) an
32/// item is what frees its bytes for new sends — not simply the item
33/// leaving the channel's internal queue. If the consumer does further
34/// work with the payload after taking it out (e.g. writing it to a
35/// socket) and wants the budget charged for that duration too, use
36/// [`Envelope::split`] instead of `into_inner` and drop the returned
37/// [`BudgetPermit`] once that work finishes.
38///
39/// [`Deref`]: std::ops::Deref
40pub struct Envelope<T> {
41 value: T,
42 permit: OwnedSemaphorePermit,
43}
44
45impl<T> Envelope<T> {
46 /// Unwraps the payload, dropping the byte-budget permit alongside it.
47 pub fn into_inner(self) -> T {
48 self.value
49 }
50
51 /// Splits into the owned payload and a [`BudgetPermit`] guarding its
52 /// bytes, without releasing them yet.
53 ///
54 /// Use this instead of [`Self::into_inner`] when the payload's memory
55 /// stays live past the point of dequeuing it — e.g. handed to a
56 /// socket write that hasn't completed — so the byte budget reflects
57 /// the payload's actual lifetime instead of being released the moment
58 /// it leaves the channel while a copy of it is still held elsewhere.
59 pub fn split(self) -> (T, BudgetPermit) {
60 (
61 self.value,
62 BudgetPermit {
63 _permit: self.permit,
64 },
65 )
66 }
67}
68
69impl<T> std::ops::Deref for Envelope<T> {
70 type Target = T;
71
72 fn deref(&self) -> &T {
73 &self.value
74 }
75}
76
77// No `Clone` impl: `OwnedSemaphorePermit` isn't `Clone`, and there's no
78// correct way to fabricate one for a clone — sharing the original permit
79// would let the clone's bytes escape accounting when only the original is
80// dropped, while acquiring a fresh same-sized permit would make `Clone`
81// fallible (the budget might not have room), which the trait can't
82// express. Use `Envelope::split` to detach the payload from its permit
83// when a caller needs to hold or duplicate the value independently.
84
85impl<T: std::fmt::Debug> std::fmt::Debug for Envelope<T> {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.debug_tuple("Envelope").field(&self.value).finish()
88 }
89}
90
91impl<T: PartialEq> PartialEq for Envelope<T> {
92 fn eq(&self, other: &Self) -> bool {
93 self.value == other.value
94 }
95}
96
97impl<T: Eq> Eq for Envelope<T> {}
98
99/// A byte-budget permit detached from its payload by [`Envelope::split`].
100///
101/// Releases its bytes back to the originating channel's budget when
102/// dropped.
103pub struct BudgetPermit {
104 _permit: OwnedSemaphorePermit,
105}
106
107/// Error returned by [`ByteBoundedSender::try_send`].
108#[derive(Debug)]
109pub enum TrySendError<T> {
110 /// Enqueuing `payload_len` more bytes would exceed the channel's
111 /// remaining byte budget.
112 BudgetExceeded(T),
113 /// The underlying bounded channel rejected the send (full or closed).
114 Channel(mpsc::error::TrySendError<T>),
115}
116
117/// Error returned by [`ByteBoundedSender::send`].
118#[derive(Debug)]
119pub struct SendError<T>(pub T);
120
121/// Sending half of a channel created by [`byte_bounded_channel`].
122pub struct ByteBoundedSender<T> {
123 inner: mpsc::Sender<Envelope<T>>,
124 budget: Arc<Semaphore>,
125 /// The budget's total permit count, captured at creation time.
126 ///
127 /// `Semaphore` has no "total permits" query, and a request for more
128 /// than this can *never* be satisfied — [`Semaphore::acquire_many`]
129 /// would wait forever (draining `available_permits` to 0 and starving
130 /// every other waiter in the process) rather than erroring, since the
131 /// semaphore has no way to distinguish "not available yet" from "not
132 /// available ever". `try_send`/`send` check against this upfront so
133 /// an over-budget payload is rejected immediately instead of queuing
134 /// a waiter that can never be woken.
135 max_queued_bytes: usize,
136}
137
138impl<T> Clone for ByteBoundedSender<T> {
139 fn clone(&self) -> Self {
140 Self {
141 inner: self.inner.clone(),
142 budget: Arc::clone(&self.budget),
143 max_queued_bytes: self.max_queued_bytes,
144 }
145 }
146}
147
148/// Converts `payload_len` into a permit count, clamping to `u32::MAX` and
149/// flooring at 1 so a zero-length payload still consumes (and later
150/// releases) a slot rather than bypassing the budget entirely.
151///
152/// The `u32::MAX` clamp only matters when `max_queued_bytes` itself
153/// exceeds `u32::MAX`: `ByteBoundedSender::send`/`try_send` already reject
154/// any `payload_len` greater than `max_queued_bytes` upfront, so a
155/// four-gigabyte-or-larger single payload is unreachable unless the
156/// channel was configured with a multi-gigabyte budget in the first
157/// place. In that configuration, a payload above 4 GiB would be silently
158/// under-charged relative to its true size rather than rejected — this
159/// channel is sized for message-scale payloads (see
160/// [`byte_bounded_channel`]'s doc), not multi-gigabyte budgets, so the
161/// clamp is accepted as a known limitation rather than handled explicitly.
162fn permits_for(payload_len: usize) -> u32 {
163 u32::try_from(payload_len.max(1)).unwrap_or(u32::MAX)
164}
165
166impl<T> ByteBoundedSender<T> {
167 /// Attempts to enqueue `value` (whose serialized size is `payload_len`
168 /// bytes) without blocking.
169 ///
170 /// Rejects with [`TrySendError::BudgetExceeded`] if `payload_len`
171 /// would push the channel's cumulative queued bytes over its budget
172 /// (including if `payload_len` alone exceeds the channel's total
173 /// budget — such a payload could never fit), or with
174 /// [`TrySendError::Channel`] if the underlying channel is full or its
175 /// receiver has been dropped.
176 pub fn try_send(&self, value: T, payload_len: usize) -> Result<(), TrySendError<T>> {
177 if payload_len.max(1) > self.max_queued_bytes {
178 return Err(TrySendError::BudgetExceeded(value));
179 }
180 let permit = match Arc::clone(&self.budget).try_acquire_many_owned(permits_for(payload_len))
181 {
182 Ok(permit) => permit,
183 Err(_) => return Err(TrySendError::BudgetExceeded(value)),
184 };
185 self.inner
186 .try_send(Envelope { value, permit })
187 .map_err(|err| match err {
188 mpsc::error::TrySendError::Full(envelope) => {
189 TrySendError::Channel(mpsc::error::TrySendError::Full(envelope.value))
190 }
191 mpsc::error::TrySendError::Closed(envelope) => {
192 TrySendError::Channel(mpsc::error::TrySendError::Closed(envelope.value))
193 }
194 })
195 }
196
197 /// Enqueues `value` (whose serialized size is `payload_len` bytes),
198 /// waiting for both byte budget and channel capacity to become
199 /// available.
200 ///
201 /// Unlike [`Self::try_send`], this applies backpressure to the caller
202 /// instead of rejecting immediately — appropriate for callers outside
203 /// a connection's own read/write loop, where waiting cannot deadlock
204 /// the consumer.
205 ///
206 /// Returns [`SendError`] immediately (without waiting) if
207 /// `payload_len` exceeds the channel's total byte budget: such a
208 /// payload could never fit, so waiting for it to would hang forever —
209 /// see [`ByteBoundedSender`]'s `max_queued_bytes` doc for why the
210 /// underlying `Semaphore` can't reject this on its own.
211 pub async fn send(&self, value: T, payload_len: usize) -> Result<(), SendError<T>> {
212 if payload_len.max(1) > self.max_queued_bytes {
213 return Err(SendError(value));
214 }
215 let permit = match Arc::clone(&self.budget)
216 .acquire_many_owned(permits_for(payload_len))
217 .await
218 {
219 Ok(permit) => permit,
220 Err(_) => return Err(SendError(value)),
221 };
222 self.inner
223 .send(Envelope { value, permit })
224 .await
225 .map_err(|err| SendError(err.0.value))
226 }
227
228 /// Forwards to the underlying channel's [`mpsc::Sender::capacity`]:
229 /// the number of additional item slots currently available (ignoring
230 /// the byte budget).
231 pub fn capacity(&self) -> usize {
232 self.inner.capacity()
233 }
234
235 /// Forwards to the underlying channel's [`mpsc::Sender::max_capacity`]:
236 /// the item-count capacity the channel was created with.
237 pub fn max_capacity(&self) -> usize {
238 self.inner.max_capacity()
239 }
240}
241
242/// Creates a byte-bounded mpsc channel.
243///
244/// `item_capacity` bounds queue depth by message count, exactly like
245/// [`mpsc::channel`]. `max_queued_bytes` additionally bounds the sum of
246/// `payload_len` across all currently-queued items, so worst-case queued
247/// memory stays a predictable constant regardless of individual message
248/// size.
249///
250/// # Panics
251///
252/// Panics if `max_queued_bytes` exceeds [`Semaphore::MAX_PERMITS`]
253/// (`usize::MAX >> 3`) — [`Semaphore::new`]'s own limit. In practice this
254/// requires a caller-chosen `max_queued_bytes` in the exabyte range, far
255/// beyond any realistic byte budget for this channel.
256///
257/// # Examples
258///
259/// ```
260/// use pjson_rs::infrastructure::bounded_channel::byte_bounded_channel;
261///
262/// # #[tokio::main]
263/// # async fn main() {
264/// let (tx, mut rx) = byte_bounded_channel::<&str>(10, 1024);
265/// tx.try_send("hello", "hello".len()).unwrap();
266/// let received = rx.recv().await.unwrap();
267/// assert_eq!(received.into_inner(), "hello");
268/// # }
269/// ```
270pub fn byte_bounded_channel<T>(
271 item_capacity: usize,
272 max_queued_bytes: usize,
273) -> (ByteBoundedSender<T>, mpsc::Receiver<Envelope<T>>) {
274 let (inner, rx) = mpsc::channel(item_capacity);
275 (
276 ByteBoundedSender {
277 inner,
278 budget: Arc::new(Semaphore::new(max_queued_bytes)),
279 max_queued_bytes,
280 },
281 rx,
282 )
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[tokio::test]
290 async fn test_rejects_send_once_item_capacity_is_full() {
291 let (tx, mut rx) = byte_bounded_channel::<&str>(2, 1024);
292
293 tx.try_send("a", 1).unwrap();
294 tx.try_send("b", 1).unwrap();
295
296 assert!(matches!(
297 tx.try_send("c", 1),
298 Err(TrySendError::Channel(mpsc::error::TrySendError::Full(_)))
299 ));
300
301 rx.recv().await.unwrap();
302 tx.try_send("c", 1).expect("capacity freed after a receive");
303 }
304
305 #[tokio::test]
306 async fn test_rejects_send_once_byte_budget_is_exceeded() {
307 // Item-count capacity is generous (100 slots); the byte budget
308 // (10 bytes) is what should reject this send.
309 let (tx, _rx) = byte_bounded_channel::<&str>(100, 10);
310
311 assert!(matches!(
312 tx.try_send("this string is way over budget", 31),
313 Err(TrySendError::BudgetExceeded(_))
314 ));
315
316 // A payload that fits within budget still succeeds.
317 tx.try_send("ok", 2).expect("small payload fits in budget");
318 }
319
320 #[tokio::test]
321 async fn test_byte_budget_is_released_when_envelope_is_received() {
322 let (tx, mut rx) = byte_bounded_channel::<&str>(100, 10);
323
324 tx.try_send("12345", 5).unwrap();
325 // Budget nearly exhausted (5/10 bytes used): a second 6-byte
326 // payload must not fit.
327 assert!(matches!(
328 tx.try_send("123456", 6),
329 Err(TrySendError::BudgetExceeded(_))
330 ));
331
332 // Draining the first item releases its 5 bytes back to the budget.
333 let received = rx.recv().await.unwrap();
334 assert_eq!(received.into_inner(), "12345");
335
336 tx.try_send("123456", 6)
337 .expect("budget freed after the first item was received and dropped");
338 }
339
340 #[tokio::test]
341 async fn test_try_send_rejects_payload_larger_than_total_budget() {
342 let (tx, _rx) = byte_bounded_channel::<&str>(100, 10);
343 assert!(matches!(
344 tx.try_send("too big", 11),
345 Err(TrySendError::BudgetExceeded(_))
346 ));
347 }
348
349 #[tokio::test]
350 async fn test_send_rejects_payload_larger_than_total_budget_instead_of_hanging() {
351 // Regression test for a critical bug: `payload_len > max_queued_bytes`
352 // can never be satisfied by the underlying `Semaphore`, which has no
353 // way to distinguish "not available yet" from "not available ever" —
354 // `acquire_many` would wait forever, draining `available_permits` to
355 // 0 and starving every other waiter (including unrelated `try_send`
356 // calls, which would then also report `BudgetExceeded` forever).
357 // Must reject immediately instead of hanging.
358 let (tx, _rx) = byte_bounded_channel::<&str>(100, 10);
359
360 let result = tokio::time::timeout(
361 std::time::Duration::from_millis(200),
362 tx.send("too big", 11),
363 )
364 .await
365 .expect("send must reject an unsatisfiable payload immediately, not hang");
366 assert!(result.is_err());
367
368 // The channel must still be usable afterward — proves the
369 // semaphore wasn't left drained/bricked by the rejected send.
370 tx.try_send("ok", 5)
371 .expect("channel must remain usable after rejecting an oversized send");
372 }
373
374 #[tokio::test]
375 async fn test_split_defers_budget_release_until_permit_is_dropped() {
376 let (tx, mut rx) = byte_bounded_channel::<&str>(100, 10);
377 tx.try_send("12345", 5).unwrap();
378
379 let envelope = rx.recv().await.unwrap();
380 let (value, permit) = envelope.split();
381 assert_eq!(value, "12345");
382
383 // The budget is still charged after `split`: a payload that only
384 // fits once the first 5 bytes are released must not fit yet.
385 assert!(matches!(
386 tx.try_send("123456", 6),
387 Err(TrySendError::BudgetExceeded(_))
388 ));
389
390 drop(permit);
391 tx.try_send("123456", 6)
392 .expect("budget freed once the split-off permit is dropped");
393 }
394
395 #[tokio::test]
396 async fn test_concurrent_try_send_never_admits_past_byte_budget() {
397 // Regression guard for the Semaphore-based budget under real
398 // contention: sequential push-until-full tests can't catch a
399 // races-losing-permits bug, since they never have two callers
400 // acquiring from the same `Semaphore` at once. Here, 20 tasks race
401 // to acquire from a 100-byte budget at 10 bytes each — at most 10
402 // may be admitted concurrently, and every admitted item's bytes
403 // must still be accounted for exactly once when drained.
404 let (tx, mut rx) = byte_bounded_channel::<usize>(1000, 100);
405 let payload_len = 10;
406
407 let mut handles = Vec::new();
408 for i in 0..20 {
409 let tx = tx.clone();
410 handles.push(tokio::spawn(
411 async move { tx.try_send(i, payload_len).is_ok() },
412 ));
413 }
414
415 let mut admitted = 0;
416 for handle in handles {
417 if handle.await.expect("task panicked") {
418 admitted += 1;
419 }
420 }
421
422 assert!(
423 admitted <= 10,
424 "a 100-byte budget at 10 bytes/item must never admit more than 10 \
425 concurrent items, got {admitted}"
426 );
427
428 let mut drained = 0;
429 while rx.try_recv().is_ok() {
430 drained += 1;
431 }
432 assert_eq!(
433 drained, admitted,
434 "every admitted item must be receivable exactly once"
435 );
436 }
437
438 #[tokio::test]
439 async fn test_send_waits_for_budget_then_succeeds() {
440 let (tx, mut rx) = byte_bounded_channel::<&str>(100, 5);
441
442 tx.try_send("abcde", 5).unwrap();
443
444 let tx2 = tx.clone();
445 let waiter = tokio::spawn(async move { tx2.send("fghij", 5).await });
446
447 // The waiter cannot have made progress yet: no budget is free.
448 tokio::task::yield_now().await;
449 assert!(!waiter.is_finished());
450
451 rx.recv().await.unwrap();
452 waiter
453 .await
454 .expect("task panicked")
455 .expect("send should succeed once budget frees up");
456 }
457}