Skip to main content

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}