feat(broadcast): add a bounded MPMC broadcast channel (#253)

Signed-off-by: onenewcode <lovestudy@qq.com>
Co-authored-by: tison <wander4096@gmail.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef8a4f6..087014c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,12 +25,14 @@
 
 ### New features
 
+* Implement `broadcast::mpmc::bounded`, a lossless bounded broadcast channel that retains at most the requested capacity and makes producers wait for the slowest active receiver.
 * Add opt-in bounded and unbounded `asyncband::mpmc` queues with cloneable producers and competing consumers, delivering each accepted value to exactly one receiver while a receiver remains.
 * Add an opt-in runtime-agnostic `Phaser` with shared observer handles, dynamic RAII participants registered individually or in batches through an owning iterator, `u64` phase numbers, split arrival/wait with cancellation-resilient retries, and a `close` operation that releases unfinished waits with `Closed`.
 * Add bounded MPSC `reserve` and `try_reserve` methods returning a `Permit`, allowing callers to wait for capacity before constructing a message; pending sends and reservations receive capacity in wait-queue order, and unused permits release capacity without claiming message order.
 
 ### Bug fixes
 
+* Complete semaphore permit releases and notify all eligible waiters even if a wake callback panics.
 * Release MPSC receiver wakers when the receiver is dropped, avoiding retained tasks and ownership cycles when a waker holds a sender.
 * Notify all blocked bounded MPSC senders on receiver disconnection even when a buffered message destructor panics.
 * Avoid deadlocks when a bounded MPSC sender's waker clone callback receives from the same channel.
diff --git a/README.md b/README.md
index 287ac38..ed3dae3 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@
 |                            | [`oneshot`](https://docs.rs/asyncband/*/asyncband/oneshot/)                                    | `oneshot`      | Send one value from one sender to one receiver.                                                               |
 |                            | [`mpmc`](https://docs.rs/asyncband/*/asyncband/mpmc/)                                          | `mpmc`         | Distribute each value to exactly one of multiple competing receivers.      |
 |                            | [`mpsc`](https://docs.rs/asyncband/*/asyncband/mpsc/)                                          | `mpsc`         | Send each value from multiple producers to one receiver with bounded backpressure or an unbounded queue.      |
-|                            | [`broadcast`](https://docs.rs/asyncband/*/asyncband/broadcast/)                                | `broadcast`    | Deliver every value to receivers active at send time; retain an unbounded backlog until each consumes or drops. |
+|                            | [`broadcast`](https://docs.rs/asyncband/*/asyncband/broadcast/)                                | `broadcast`    | Deliver every value to active receivers with bounded backpressure or unbounded retention. |
 |                            | [`watch`](https://docs.rs/asyncband/*/asyncband/watch/)                                        | `watch`        | Publish cloneable latest state from one or more senders; receivers independently coalesce intermediate updates. |
 | Object reuse               | [`pool`](https://docs.rs/asyncband/*/asyncband/pool/)                                          | `pool`         | Reuse objects through bounded or unbounded pool variants.                                                     |
 | Sync interop               | [`FutureExt`](https://docs.rs/asyncband/*/asyncband/blocking/trait.FutureExt.html)             | `blocking`     | Drive one runtime-agnostic future from a blocking thread.                                                     |
diff --git a/asyncband/src/broadcast/mpmc/bounded/mod.rs b/asyncband/src/broadcast/mpmc/bounded/mod.rs
new file mode 100644
index 0000000..cadb803
--- /dev/null
+++ b/asyncband/src/broadcast/mpmc/bounded/mod.rs
@@ -0,0 +1,743 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! A multi-producer multi-consumer broadcast channel with a bounded buffer.
+//!
+//! This channel supports multiple senders and multiple receivers. Each message sent by any sender
+//! is received by all active receivers. Nothing is ever displaced to make room, so a receive never
+//! reports lag; instead the channel retains at most `capacity` messages and makes producers wait.
+//!
+//! # Capacity
+//!
+//! Capacity counts the *shared* backlog — the messages retained because the slowest active
+//! receiver has not read them yet — not messages per receiver. Adding receivers therefore does not
+//! consume capacity; falling behind does.
+//!
+//! Because the backlog is shared, a single receiver that stops draining stalls **every** producer
+//! on the channel, however many other receivers are keeping up. That is what "the slowest
+//! subscription exerts backpressure" means, and it is the trade a lossless bounded broadcast
+//! makes. Drop a receiver that will not drain, and its backlog is released immediately.
+//!
+//! If no receivers are active the channel retains nothing, so a send never waits.
+//!
+//! A successful receive releases its subscription's claim before returning the value; processing
+//! that value afterward does not hold capacity. The capacity limit excludes pending sends and
+//! values already handed to application code.
+//!
+//! # Receivers
+//!
+//! Each receiver has an independent cursor. Use [`BoundedSender::subscribe`] or
+//! [`BoundedReceiver::resubscribe`] to create a receiver that starts at the current tail. A new
+//! subscription never sees messages published before it existed.
+//!
+//! # Fairness
+//!
+//! Waiting producers are woken as capacity frees, but capacity is not reserved for them: a
+//! producer calling [`BoundedSender::try_send`] can take a slot that a woken producer was about to
+//! use, and that producer then waits again. Publication itself is one indivisible step, so
+//! cancelling a send can never leave a gap in the committed order.
+//!
+//! # Examples
+//!
+//! Basic usage:
+//!
+//! ```
+//! use asyncband::broadcast::mpmc;
+//!
+//! # #[tokio::main]
+//! # async fn main() {
+//! let (tx, mut rx1) = mpmc::bounded(4);
+//! let mut rx2 = tx.subscribe();
+//!
+//! tx.send(10).await;
+//! tx.send(20).await;
+//!
+//! assert_eq!(rx1.recv().await, Ok(10));
+//! assert_eq!(rx1.recv().await, Ok(20));
+//! assert_eq!(rx2.recv().await, Ok(10));
+//! assert_eq!(rx2.recv().await, Ok(20));
+//! # }
+//! ```
+//!
+//! The slowest receiver holds the capacity:
+//!
+//! ```
+//! use asyncband::broadcast::mpmc;
+//! use asyncband::broadcast::mpmc::TrySendError;
+//!
+//! let (tx, mut rx1) = mpmc::bounded(2);
+//! let rx2 = tx.subscribe();
+//!
+//! tx.try_send(1).unwrap();
+//! tx.try_send(2).unwrap();
+//! assert_eq!(tx.try_send(3), Err(TrySendError::Full(3)));
+//!
+//! // `rx1` draining is not enough: `rx2` has read neither message, so both stay retained.
+//! assert_eq!(rx1.try_recv(), Ok(1));
+//! assert_eq!(tx.retained_message_count(), 2);
+//! assert_eq!(tx.try_send(3), Err(TrySendError::Full(3)));
+//!
+//! // Dropping the lagging receiver releases the backlog only it was holding. `rx1` has still not
+//! // read the second message, so that one stays.
+//! drop(rx2);
+//! assert_eq!(tx.retained_message_count(), 1);
+//! tx.try_send(3).unwrap();
+//! ```
+
+use std::fmt;
+use std::future::Future;
+use std::future::poll_fn;
+use std::pin::Pin;
+use std::pin::pin;
+use std::sync::Arc;
+use std::sync::atomic::AtomicUsize;
+use std::sync::atomic::Ordering;
+use std::task::Context;
+use std::task::Poll;
+
+use super::common;
+use super::common::Backlog;
+use super::common::Inner;
+use super::error::RecvError;
+use super::error::TryRecvError;
+use super::error::TrySendError;
+use crate::internal::arena::SlotId;
+use crate::internal::mutex::Mutex;
+use crate::internal::semaphore::Acquire;
+use crate::internal::semaphore::Semaphore;
+use crate::internal::wake_all;
+use crate::internal::wakerset::WakerToken;
+
+#[cfg(test)]
+mod tests;
+
+/// Creates a new broadcast channel that retains at most `capacity` messages.
+///
+/// Every accepted value stays readable by every receiver that was active when it was accepted.
+/// Once `capacity` messages are retained, [`BoundedSender::send`] waits and
+/// [`BoundedSender::try_send`] reports [`TrySendError::Full`] until the slowest active receiver
+/// consumes a message or is dropped.
+///
+/// # Panics
+///
+/// Panics if `capacity` is zero.
+///
+/// # Examples
+///
+/// ```
+/// use asyncband::broadcast::mpmc;
+///
+/// let (tx, mut rx) = mpmc::bounded(1);
+/// tx.try_send(10).unwrap();
+/// assert_eq!(rx.try_recv(), Ok(10));
+/// ```
+#[track_caller]
+pub fn bounded<T: Clone>(capacity: usize) -> (BoundedSender<T>, BoundedReceiver<T>) {
+    assert!(
+        capacity > 0,
+        "broadcast bounded channel requires capacity > 0"
+    );
+
+    let (inner, key) = Inner::with_first_subscription(Backlog::fixed(capacity));
+    let shared = Arc::new(Shared {
+        inner,
+        senders: AtomicUsize::new(1),
+        capacity,
+        tx_permits: Semaphore::new(0),
+        blocked_senders: AtomicUsize::new(0),
+    });
+    let sender = BoundedSender {
+        shared: shared.clone(),
+    };
+    let receiver = BoundedReceiver { shared, key };
+    (sender, receiver)
+}
+
+struct Shared<T> {
+    /// Buffer, receiver cursors, and parked receivers, all under a single lock.
+    inner: Mutex<Inner<T>>,
+    /// Number of active senders.
+    senders: AtomicUsize,
+    /// The logical limit on the retained backlog.
+    capacity: usize,
+    /// Producers parked in [`BoundedSender::send`].
+    ///
+    /// A permit here is a wake-up hint, not a reserved slot: a woken producer rechecks the backlog
+    /// and parks again if another producer took the space first. The semaphore starts empty and
+    /// only ever grows when a reclaim finds someone waiting, so an idle channel accumulates none.
+    tx_permits: Semaphore,
+    /// How many producers are somewhere inside the waiting path of [`BoundedSender::send`].
+    ///
+    /// An upper bound on the number of parked producers, and the only thing either release path
+    /// consults. It answers both questions a reclaim has — whether to wake anyone, and how many
+    /// permits are worth handing out — without taking the semaphore's lock. Reclaiming is far more
+    /// frequent than blocking — under fan-out every message is reclaimed, while a channel with
+    /// headroom never blocks at all — so paying an atomic load there instead of a lock acquisition
+    /// is what keeps an uncontended receive off the semaphore entirely.
+    blocked_senders: AtomicUsize,
+}
+
+impl<T> Shared<T> {
+    /// Hands `freed` released slots back to producers parked in `send`.
+    ///
+    /// Capacity is `retained()`, which is `buffer.len()`. The buffer grows only in
+    /// `Backlog::publish_retained` and shrinks only in `Backlog::reclaim_consumed`, which is
+    /// reachable from exactly two places: a receive that vacates the last cursor at the backlog
+    /// head, and removing a subscription. Those are the only callers of this method, so no path
+    /// can free capacity without waking a producer. Subscribing cannot: a new cursor starts at the
+    /// tail and never lowers `retained()`.
+    ///
+    /// Callers must invoke this with the channel unlocked, and — on the receive path — before
+    /// touching the payload, since `common::take_msg` runs user code that may panic.
+    fn release_reclaimed(&self, freed: usize) {
+        // Release no more permits than there are producers to wake. A permit the semaphore cannot
+        // hand to a waiter is kept as slack, and the next producer to block has to burn it off one
+        // futile publish attempt — a channel lock apiece — at a time before it can park. Freeing a
+        // large prefix at once is not exotic: dropping a lagging subscription reclaims the whole
+        // backlog, which would otherwise leave nearly `capacity` permits behind.
+        //
+        // Capping cannot lose a wake-up, by the same argument that lets this read the count at all:
+        // a producer this load observes is one the release covers, and one it misses incremented
+        // after the load, which it does before taking the channel lock to recheck — so its recheck
+        // runs after the reclaim and finds the capacity itself.
+        let waiting = self.waiting_senders();
+        if freed > 0 && waiting > 0 {
+            self.tx_permits.release_if_nonempty(freed.min(waiting));
+        }
+    }
+
+    /// Wakes every parked producer, however many slots came back.
+    ///
+    /// The last subscription leaving is not a reclaim of some number of slots — it removes the
+    /// limit itself, because a channel with no receivers discards instead of retaining. Releasing
+    /// only as many permits as that final reclaim freed would strand every producer beyond that
+    /// count, so this is the one release that must be unbounded.
+    fn release_all(&self) {
+        if self.waiting_senders() > 0 {
+            self.tx_permits.notify_all();
+        }
+    }
+
+    /// How many producers might be waiting, answered without touching the semaphore's lock.
+    ///
+    /// This cannot miss a wake-up. A producer increments the count before it ever takes the
+    /// channel lock to recheck capacity, and every caller here loads it after releasing that same
+    /// lock, so the mutex orders the two: either this load observes the producer, or the
+    /// producer's recheck runs after the change and finds the capacity itself.
+    fn waiting_senders(&self) -> usize {
+        self.blocked_senders.load(Ordering::Acquire)
+    }
+}
+
+/// The sending side of a bounded broadcast channel.
+///
+/// The sender can be cloned to create multiple producers. Dropping the final sender disconnects
+/// the channel. Each receiver may drain its own buffered messages before observing disconnection.
+pub struct BoundedSender<T> {
+    shared: Arc<Shared<T>>,
+}
+
+impl<T> Clone for BoundedSender<T> {
+    fn clone(&self) -> Self {
+        // Relaxed is enough because this count publishes nothing on its own: receivers read it
+        // only to decide whether any sender remains, and every message it could hide is published
+        // under `inner`, which a receiver holds before it observes the count.
+        self.shared.senders.fetch_add(1, Ordering::Relaxed);
+        Self {
+            shared: self.shared.clone(),
+        }
+    }
+}
+
+impl<T> fmt::Debug for BoundedSender<T> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("BoundedSender").finish_non_exhaustive()
+    }
+}
+
+impl<T> Drop for BoundedSender<T> {
+    fn drop(&mut self) {
+        match self.shared.senders.fetch_sub(1, Ordering::AcqRel) {
+            // Only parked receivers need waking. A parked producer borrows a live sender for the
+            // duration of its `send`, so the last sender cannot be dropping while one exists.
+            1 => common::disconnect(&self.shared.inner),
+            _ => {
+                // there are still other senders left, do nothing
+            }
+        }
+    }
+}
+
+impl<T> BoundedSender<T> {
+    /// Broadcasts a value to all active receivers, waiting for capacity if the channel is full.
+    ///
+    /// The wait ends when the slowest active receiver consumes a retained message or is dropped.
+    /// If no receivers are active, the message is dropped immediately and this returns without
+    /// waiting.
+    ///
+    /// # Cancel safety
+    ///
+    /// This method is cancel safe in the sense that matters for a lossless log: the value is
+    /// either published to every active receiver or not published at all. Publication happens in
+    /// one indivisible step, so a cancelled send cannot leave a reserved but unfilled position in
+    /// the committed order. A send cancelled before it published drops the value with the future.
+    ///
+    /// # Panics
+    ///
+    /// Panics if the internal message version counter overflows. After `u64::MAX` successful sends
+    /// on one channel instance, the next send panics.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    ///
+    /// # #[tokio::main]
+    /// # async fn main() {
+    /// let (tx, mut rx) = mpmc::bounded(1);
+    /// tx.send(10).await;
+    /// assert_eq!(rx.recv().await, Ok(10));
+    /// # }
+    /// ```
+    pub async fn send(&self, value: T) {
+        let value = match self.try_send(value) {
+            Ok(()) => return,
+            Err(TrySendError::Full(value)) => value,
+        };
+
+        struct SendState<'a, T> {
+            sender: &'a BoundedSender<T>,
+            // Declared before `value` so a cancelled send hands its registration back to the next
+            // waiting producer before running the payload's destructor.
+            acquire: Acquire<'a>,
+            // Boxed once, out of the critical section, and reused by every retry.
+            value: Option<Arc<T>>,
+        }
+
+        impl<T> Drop for SendState<'_, T> {
+            fn drop(&mut self) {
+                // Runs before the fields, so the count drops while `acquire` is still queued.
+                // That ordering is safe in the direction that matters: the window can only make a
+                // receiver skip a wake-up this producer no longer wants, because the future being
+                // dropped is exactly the one leaving. Every other waiting producer still holds its
+                // own increment, so the count cannot reach zero while one of them needs waking.
+                self.sender
+                    .shared
+                    .blocked_senders
+                    .fetch_sub(1, Ordering::Release);
+            }
+        }
+
+        impl<T> SendState<'_, T> {
+            fn poll_send(&mut self, cx: &mut Context<'_>) -> Poll<()> {
+                let mut msg = match self.value.take() {
+                    Some(msg) => msg,
+                    None => return Poll::Ready(()),
+                };
+
+                loop {
+                    // Enqueue before rechecking. `release_if_nonempty` adds nothing when no
+                    // producer is queued, so a reclaim landing between the recheck and the
+                    // registration would otherwise drop its wake-up and park this producer for
+                    // good. Registering first orders this producer's semaphore acquisition ahead
+                    // of the reclaim's, so either the recheck sees the freed slot or the reclaim
+                    // sees this waiter.
+                    let poll = pin!(&mut self.acquire).poll(cx);
+
+                    msg = match self.sender.try_publish(msg) {
+                        Ok(()) => return Poll::Ready(()),
+                        Err(msg) => msg,
+                    };
+
+                    if poll.is_ready() {
+                        self.acquire = self.sender.shared.tx_permits.poll_acquire(1);
+                    } else {
+                        self.value = Some(msg);
+                        return Poll::Pending;
+                    }
+                }
+            }
+        }
+
+        // Announce this producer before it can recheck capacity, so a concurrent reclaim either
+        // sees it here or is seen by that recheck.
+        self.shared.blocked_senders.fetch_add(1, Ordering::Release);
+        let acquire = self.shared.tx_permits.poll_acquire(1);
+        let mut send = SendState {
+            sender: self,
+            acquire,
+            value: Some(Arc::new(value)),
+        };
+        poll_fn(|cx| send.poll_send(cx)).await
+    }
+
+    /// Attempts to broadcast a value to all active receivers without waiting.
+    ///
+    /// # Returns
+    ///
+    /// * `Ok(())`: The value was published, or discarded because no receivers are active.
+    /// * `Err(TrySendError::Full(value))`: The channel already retains `capacity` messages. The
+    ///   value was not published and is returned unchanged.
+    ///
+    /// # Panics
+    ///
+    /// Panics if the internal message version counter overflows.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    /// use asyncband::broadcast::mpmc::TrySendError;
+    ///
+    /// let (tx, mut rx) = mpmc::bounded(1);
+    /// tx.try_send(10).unwrap();
+    /// assert_eq!(tx.try_send(20), Err(TrySendError::Full(20)));
+    ///
+    /// assert_eq!(rx.try_recv(), Ok(10));
+    /// tx.try_send(20).unwrap();
+    /// ```
+    pub fn try_send(&self, value: T) -> Result<(), TrySendError<T>> {
+        // `Arc::new` runs inside the critical section, but only after the capacity check, so a
+        // rejected send never allocates. Unlike `T::clone` and `T::drop` it cannot run user code
+        // that reenters this channel, so it is safe to hold the lock across it. Hoisting it out
+        // measured no faster even with eight producers contending — the allocator's thread-local
+        // cache already makes it cheap — and it measured slower wherever sends block, because a
+        // rejected send would then allocate and free once before `send` boxes the value for real.
+        self.publish(value, Arc::new).map_err(TrySendError::Full)
+    }
+
+    /// Publishes a message that is already boxed, handing it back if the channel is still full.
+    ///
+    /// This is the retry step of a waiting `send`, which boxes once with the channel unlocked and
+    /// then reuses that `Arc` for every attempt rather than reallocating per retry.
+    fn try_publish(&self, msg: Arc<T>) -> Result<(), Arc<T>> {
+        self.publish(msg, |msg| msg)
+    }
+
+    /// The publish step both send paths share.
+    ///
+    /// `into_msg` is called only once this decides the message will actually be retained, which is
+    /// what lets `try_send` defer its allocation past the capacity check while `try_publish` hands
+    /// over an `Arc` it allocated with the channel unlocked.
+    ///
+    /// Publishing and draining the wait set share one critical section, so a receiver can never
+    /// observe an empty buffer and park after this message became visible.
+    fn publish<P>(&self, payload: P, into_msg: impl FnOnce(P) -> Arc<T>) -> Result<(), P> {
+        let mut discarded = None;
+        let wakers = {
+            let mut inner = self.shared.inner.lock();
+
+            if !inner.log.has_receivers() {
+                // Nothing can read this message. The payload leaves the critical section with us
+                // and is dropped below, so `T::drop` never runs under the lock.
+                inner.log.publish_discarded();
+                discarded = Some(payload);
+            } else if inner.log.retained() == self.shared.capacity {
+                // Nothing was published, so there is no wait set to drain.
+                return Err(payload);
+            } else {
+                inner.log.publish_retained(into_msg(payload));
+            }
+
+            inner.waiters.drain()
+        };
+
+        wake_all(wakers);
+        drop(discarded);
+        Ok(())
+    }
+
+    /// Returns the number of messages currently retained by the channel.
+    ///
+    /// This is not the number of messages any single receiver can still read. It is the shared
+    /// backlog kept alive by the slowest active receiver, and it is what this channel measures
+    /// against its [`capacity`](BoundedSender::capacity).
+    ///
+    /// The returned value is an instantaneous snapshot. It is suitable for diagnostics and soft
+    /// flow-control decisions, but concurrent sends and receives may change it immediately.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    ///
+    /// let (tx, mut rx) = mpmc::bounded(4);
+    /// tx.try_send(10).unwrap();
+    /// assert_eq!(tx.retained_message_count(), 1);
+    ///
+    /// assert_eq!(rx.try_recv(), Ok(10));
+    /// assert_eq!(tx.retained_message_count(), 0);
+    /// ```
+    pub fn retained_message_count(&self) -> usize {
+        self.shared.inner.lock().log.retained()
+    }
+
+    /// Returns the number of messages this channel retains before producers wait.
+    ///
+    /// This is the value passed to [`bounded`] and never changes. Pair it with
+    /// [`retained_message_count`](BoundedSender::retained_message_count) to compute headroom.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    ///
+    /// let (tx, _rx) = mpmc::bounded::<i32>(8);
+    /// assert_eq!(tx.capacity(), 8);
+    /// ```
+    pub fn capacity(&self) -> usize {
+        self.shared.capacity
+    }
+
+    /// Creates a new receiver that starts receiving messages from the current tail of the channel.
+    ///
+    /// Subscribing never consumes capacity: the new cursor starts at the tail, so it retains
+    /// nothing that was not already retained.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    /// use asyncband::broadcast::mpmc::TryRecvError;
+    ///
+    /// # #[tokio::main]
+    /// # async fn main() {
+    /// let (tx, _rx) = mpmc::bounded(4);
+    /// tx.send(10).await;
+    ///
+    /// let mut rx = tx.subscribe();
+    /// assert_eq!(rx.try_recv(), Err(TryRecvError::Empty));
+    /// tx.send(20).await;
+    /// assert_eq!(rx.recv().await, Ok(20));
+    /// # }
+    /// ```
+    #[must_use = "the receiver is dropped immediately if it is not retained"]
+    pub fn subscribe(&self) -> BoundedReceiver<T> {
+        let key = self.shared.inner.lock().log.subscribe();
+        BoundedReceiver {
+            shared: self.shared.clone(),
+            key,
+        }
+    }
+}
+
+/// A receiver for a bounded broadcast channel.
+///
+/// Each receiver sees every message sent to the channel while the receiver is active. A receiver
+/// that stops draining holds capacity for the whole channel, so dropping one that will not keep up
+/// is how a caller releases producers.
+pub struct BoundedReceiver<T> {
+    shared: Arc<Shared<T>>,
+    key: SlotId,
+}
+
+impl<T> fmt::Debug for BoundedReceiver<T> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("BoundedReceiver").finish_non_exhaustive()
+    }
+}
+
+impl<T> Drop for BoundedReceiver<T> {
+    fn drop(&mut self) {
+        let (reclaimed, drained_last) = {
+            let mut inner = self.shared.inner.lock();
+            let reclaimed = inner.log.remove_receiver(self.key);
+            let drained_last = !inner.log.has_receivers();
+            (reclaimed, drained_last)
+        };
+
+        if drained_last {
+            self.shared.release_all();
+        } else {
+            self.shared.release_reclaimed(reclaimed.len());
+        }
+
+        // Payload destructors run last, and unlocked.
+        drop(reclaimed);
+    }
+}
+
+impl<T: Clone> BoundedReceiver<T> {
+    /// Receives the next value for this receiver.
+    ///
+    /// # Returns
+    ///
+    /// * `Ok(T)`: The next message.
+    /// * `Err(RecvError::Disconnected)`: All senders have been dropped and this receiver has no
+    ///   remaining messages.
+    ///
+    /// # Cancel safety
+    ///
+    /// This method is cancel safe. If `recv` is used as the event in a `select` statement and some
+    /// other branch completes first, it is guaranteed that no messages were received on this
+    /// channel.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    ///
+    /// # #[tokio::main]
+    /// # async fn main() {
+    /// let (tx, mut rx) = mpmc::bounded(4);
+    /// tx.send(10).await;
+    /// assert_eq!(rx.recv().await, Ok(10));
+    /// # }
+    /// ```
+    pub async fn recv(&mut self) -> Result<T, RecvError> {
+        Recv {
+            receiver: self,
+            token: None,
+        }
+        .await
+    }
+
+    /// Attempts to receive the next value for this receiver without blocking.
+    ///
+    /// # Returns
+    ///
+    /// * `Ok(T)`: The next message.
+    /// * `Err(TryRecvError::Empty)`: No message is currently available.
+    /// * `Err(TryRecvError::Disconnected)`: All senders have been dropped and this receiver has no
+    ///   remaining messages.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    ///
+    /// let (tx, mut rx) = mpmc::bounded(4);
+    /// tx.try_send(10).unwrap();
+    /// assert_eq!(rx.try_recv(), Ok(10));
+    /// ```
+    pub fn try_recv(&mut self) -> Result<T, TryRecvError> {
+        let (msg, reclaimed) =
+            common::try_receive(&self.shared.inner, &self.shared.senders, self.key)?;
+
+        // Release before taking the payload: `take_msg` runs `T::clone` and `T::drop`, and if
+        // either panics the slots this receive already freed would otherwise never be handed to a
+        // parked producer, stalling it permanently.
+        self.shared.release_reclaimed(reclaimed.len());
+        Ok(common::take_msg(msg, reclaimed))
+    }
+}
+
+impl<T> BoundedReceiver<T> {
+    /// Re-subscribes to the channel, returning a new receiver that starts receiving messages from
+    /// the *current* tail of the channel.
+    ///
+    /// The new receiver skips every value already published, including the latest retained value.
+    /// The original receiver is unchanged and continues to retain its own backlog until it
+    /// consumes those messages or is dropped.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    ///
+    /// let (tx, mut rx) = mpmc::bounded(4);
+    /// tx.try_send(1).unwrap();
+    /// tx.try_send(2).unwrap();
+    ///
+    /// let mut rx2 = rx.resubscribe();
+    /// tx.try_send(3).unwrap();
+    ///
+    /// assert_eq!(rx2.try_recv(), Ok(3));
+    /// ```
+    #[must_use = "the receiver is dropped immediately if it is not retained"]
+    pub fn resubscribe(&self) -> Self {
+        let key = self.shared.inner.lock().log.subscribe();
+        Self {
+            shared: self.shared.clone(),
+            key,
+        }
+    }
+
+    /// Returns the number of messages this receiver can still read.
+    ///
+    /// This count is specific to this receiver, unlike
+    /// [`BoundedSender::retained_message_count`], which reports the shared backlog retained by the
+    /// slowest active receiver.
+    ///
+    /// The returned value is an instantaneous snapshot. It is suitable for detecting that this
+    /// receiver is falling behind, but concurrent sends may change it immediately.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use asyncband::broadcast::mpmc;
+    ///
+    /// let (tx, mut rx) = mpmc::bounded(4);
+    /// assert_eq!(rx.unread_message_count(), 0);
+    ///
+    /// tx.try_send(10).unwrap();
+    /// tx.try_send(20).unwrap();
+    /// assert_eq!(rx.unread_message_count(), 2);
+    ///
+    /// assert_eq!(rx.try_recv(), Ok(10));
+    /// assert_eq!(rx.unread_message_count(), 1);
+    /// ```
+    pub fn unread_message_count(&self) -> usize {
+        self.shared.inner.lock().log.unread(self.key)
+    }
+}
+
+struct Recv<'a, T> {
+    receiver: &'a mut BoundedReceiver<T>,
+    token: Option<WakerToken>,
+}
+
+impl<T> Drop for Recv<'_, T> {
+    fn drop(&mut self) {
+        // Ready paths clear the token, so only a cancelled pending receive takes this lock.
+        if self.token.is_none() {
+            return;
+        }
+
+        common::unregister(
+            &self.receiver.shared.inner,
+            &self.receiver.shared.senders,
+            self.receiver.key,
+            &mut self.token,
+        );
+    }
+}
+
+impl<T: Clone> Future for Recv<'_, T> {
+    type Output = Result<T, RecvError>;
+
+    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
+        let Self { receiver, token } = self.get_mut();
+
+        let (msg, reclaimed) = match common::poll_receive(
+            &receiver.shared.inner,
+            &receiver.shared.senders,
+            receiver.key,
+            token,
+            cx,
+        ) {
+            Poll::Pending => return Poll::Pending,
+            Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
+            Poll::Ready(Ok(received)) => received,
+        };
+
+        // Release before taking the payload, for the same reason as `try_recv`: a panicking
+        // `T::clone` must not strand producers on slots this receive already freed.
+        receiver.shared.release_reclaimed(reclaimed.len());
+        Poll::Ready(Ok(common::take_msg(msg, reclaimed)))
+    }
+}
diff --git a/asyncband/src/broadcast/mpmc/bounded/tests.rs b/asyncband/src/broadcast/mpmc/bounded/tests.rs
new file mode 100644
index 0000000..594936c
--- /dev/null
+++ b/asyncband/src/broadcast/mpmc/bounded/tests.rs
@@ -0,0 +1,90 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// These run under Miri via `cargo x miri`, so they stay single-threaded and small. Behavior
+// reachable from the public API is covered in `tests-integration/broadcast_mpmc_bounded_test.rs`.
+
+use std::task::Waker;
+
+use super::*;
+
+#[test]
+#[should_panic(expected = "broadcast bounded channel requires capacity > 0")]
+fn bounded_panics_on_zero_capacity() {
+    let _ = bounded::<()>(0);
+}
+
+#[test]
+#[should_panic(expected = "broadcast channel version counter overflowed")]
+fn send_panics_on_version_overflow() {
+    // The receiver is dropped right away: the doctored counter would make its own drop overflow.
+    let (tx, _) = bounded(1);
+    tx.shared.inner.lock().log.set_tail(u64::MAX);
+    let _ = tx.try_send(());
+}
+
+#[test]
+fn buffer_is_preallocated_and_never_shrinks() {
+    let capacity = 128;
+    let (tx, mut rx) = bounded(capacity);
+    let allocated = tx.shared.inner.lock().log.buffer_capacity();
+    assert!(allocated >= capacity);
+
+    // Fill to capacity, drain completely, and repeat with a much smaller cycle. An elastic backlog
+    // would hand the allocation back after the small cycle; a fixed one must not.
+    for i in 0..capacity {
+        tx.try_send(i).unwrap();
+    }
+    for i in 0..capacity {
+        assert_eq!(rx.try_recv(), Ok(i));
+    }
+    tx.try_send(0).unwrap();
+    assert_eq!(rx.try_recv(), Ok(0));
+
+    assert_eq!(tx.retained_message_count(), 0);
+    assert_eq!(tx.shared.inner.lock().log.buffer_capacity(), allocated);
+}
+
+#[test]
+fn capacity_reports_the_requested_value() {
+    let (tx, _rx) = bounded::<i32>(3);
+    assert_eq!(tx.capacity(), 3);
+}
+
+#[test]
+fn a_large_reclaim_leaves_no_permit_slack() {
+    // Dropping a lagging subscription frees the whole backlog in one step, far more slots than the
+    // single parked producer can use. Permits beyond that producer would sit in the semaphore, and
+    // the next send to block would burn each one on a publish attempt that cannot succeed.
+    let capacity = 64;
+    let (tx, mut fast) = bounded(capacity);
+    let lagging = tx.subscribe();
+    for value in 0..capacity {
+        tx.try_send(value).unwrap();
+    }
+    for _ in 0..capacity {
+        fast.try_recv().unwrap();
+    }
+
+    let mut cx = Context::from_waker(Waker::noop());
+    let mut send = Box::pin(tx.send(capacity));
+    assert!(send.as_mut().poll(&mut cx).is_pending());
+
+    drop(lagging);
+    assert!(send.as_mut().poll(&mut cx).is_ready());
+    assert_eq!(tx.shared.tx_permits.available_permits(), 0);
+}
diff --git a/asyncband/src/broadcast/mpmc/common.rs b/asyncband/src/broadcast/mpmc/common.rs
new file mode 100644
index 0000000..94f0b1e
--- /dev/null
+++ b/asyncband/src/broadcast/mpmc/common.rs
@@ -0,0 +1,513 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Storage, cursors, and the receive step shared by the bounded and unbounded MPMC broadcast
+//! channels.
+//!
+//! Both channels retain the same committed backlog and reclaim it the same way; they differ only
+//! in what a producer does when that backlog is large. That difference stays in the two channel
+//! modules, and so does each channel's own `Recv` future, so neither contract is hidden behind a
+//! shared abstraction. What lives here is the state and the one poll step whose waker protocol is
+//! subtle enough that a second copy would be a second thing to keep correct.
+
+use std::collections::VecDeque;
+use std::mem;
+use std::sync::Arc;
+use std::sync::atomic::AtomicUsize;
+use std::sync::atomic::Ordering;
+use std::task::Context;
+use std::task::Poll;
+
+use super::error::RecvError;
+use super::error::TryRecvError;
+use crate::internal::arena::Arena;
+use crate::internal::arena::SlotId;
+use crate::internal::mutex::Mutex;
+use crate::internal::wake_all;
+use crate::internal::wakerset::WakerSet;
+use crate::internal::wakerset::WakerToken;
+
+/// Retained capacity below which an elastic backlog is never shrunk back.
+pub const MIN_RETAINED_CAPACITY: usize = 64;
+
+/// A received message together with the retained prefix that the receive released.
+///
+/// The two travel together because the caller has to act on both with the channel unlocked, and a
+/// bounded channel has to hand the released capacity back before it touches the payload.
+pub type Received<T> = (Arc<T>, Reclaimed<T>);
+
+/// Messages removed from the shared buffer and waiting to be dropped after it is unlocked.
+///
+/// Keeping the first message out of the `Vec` avoids a heap allocation on the common path where
+/// one receive reclaims exactly one message.
+pub struct Reclaimed<T> {
+    first: Option<Arc<T>>,
+    rest: Vec<Arc<T>>,
+}
+
+impl<T> Reclaimed<T> {
+    fn empty() -> Self {
+        Self {
+            first: None,
+            rest: vec![],
+        }
+    }
+
+    fn first(&self) -> Option<&Arc<T>> {
+        self.first.as_ref()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.first.is_none()
+    }
+
+    /// The number of retained messages this reclaim released.
+    ///
+    /// A bounded channel turns this into the capacity it hands back to blocked producers.
+    pub fn len(&self) -> usize {
+        usize::from(self.first.is_some()) + self.rest.len()
+    }
+}
+
+/// How a backlog manages the allocation behind its retained messages.
+enum Retention {
+    /// Grow on demand, and return a burst allocation once a later cycle stays small.
+    Elastic {
+        /// The largest backlog retained since the buffer was last empty.
+        peak_len: usize,
+    },
+    /// Allocated once for the requested capacity and never shrunk.
+    Fixed,
+}
+
+/// The committed backlog: every message whose version falls in `[head, tail)`, plus one cursor for
+/// each active subscription.
+///
+/// This is the retention and sequencing machinery that stays private to the channel families.
+/// `tail` is the single sequencer: it only advances while the channel lock is held, and a message
+/// is placed in `buffer` in the same critical section, so a later publication can never become
+/// visible ahead of an earlier one.
+pub struct Backlog<T> {
+    /// Messages whose versions are in the range `[head, tail)`.
+    ///
+    /// Each message is held behind an `Arc` so the receive path can move the payload out of the
+    /// critical section. Cloning the `Arc` under the lock keeps `T::clone` — and, for reclaimed
+    /// messages, `T::drop` — outside it, which matters because both are arbitrary user code that
+    /// may call back into this channel.
+    buffer: VecDeque<Arc<T>>,
+    /// The version of the first message in `buffer`.
+    head: u64,
+    /// The number of active receivers whose cursor equals `head`.
+    head_receivers: usize,
+    /// The next message version to assign.
+    tail: u64,
+    /// Cursor for each active receiver.
+    receivers: Arena<u64>,
+    retention: Retention,
+}
+
+impl<T> Backlog<T> {
+    /// A backlog that grows on demand and gives burst allocations back.
+    pub fn elastic() -> Self {
+        Self::new(VecDeque::new(), Retention::Elastic { peak_len: 0 })
+    }
+
+    /// A backlog preallocated for `capacity` retained messages that never shrinks.
+    pub fn fixed(capacity: usize) -> Self {
+        Self::new(VecDeque::with_capacity(capacity), Retention::Fixed)
+    }
+
+    fn new(buffer: VecDeque<Arc<T>>, retention: Retention) -> Self {
+        Self {
+            buffer,
+            head: 0,
+            head_receivers: 0,
+            tail: 0,
+            receivers: Arena::new(),
+            retention,
+        }
+    }
+
+    /// The number of messages the channel currently retains.
+    ///
+    /// This is the shared backlog kept alive by the slowest active subscription, and it is what a
+    /// bounded channel measures its capacity against.
+    pub fn retained(&self) -> usize {
+        self.buffer.len()
+    }
+
+    /// Whether any subscription is active.
+    ///
+    /// A channel with none retains nothing, so a bounded producer never waits on one.
+    pub fn has_receivers(&self) -> bool {
+        !self.receivers.is_empty()
+    }
+
+    /// The number of messages the subscription registered as `key` can still read.
+    pub fn unread(&self, key: SlotId) -> usize {
+        let head = *self
+            .receivers
+            .get(key)
+            .expect("active broadcast receiver must be registered");
+        usize::try_from(self.tail - head).expect("unread broadcast message count exceeds usize")
+    }
+
+    /// Advances the committed tail.
+    ///
+    /// # Panics
+    ///
+    /// Panics if the message version counter overflows.
+    fn advance_tail(&mut self) {
+        self.tail = self
+            .tail
+            .checked_add(1)
+            .expect("broadcast channel version counter overflowed");
+    }
+
+    /// Advances the committed tail for a message no subscription can read.
+    ///
+    /// `head` moves with it so the invariant that `buffer` covers versions `[head, tail)` still
+    /// holds without buffering anything. The buffer is already drained when the last receiver was
+    /// dropped, so there is nothing to clear here. The caller keeps the payload and drops it after
+    /// releasing the channel lock.
+    pub fn publish_discarded(&mut self) {
+        debug_assert!(!self.has_receivers());
+        debug_assert!(self.buffer.is_empty());
+        debug_assert_eq!(self.head_receivers, 0);
+        self.advance_tail();
+        self.head = self.tail;
+    }
+
+    /// Advances the committed tail and retains `msg` for every currently active subscription.
+    ///
+    /// Returns the message when no subscription can read it, so the caller drops it after
+    /// releasing the channel lock rather than running `T::drop` inside the critical section.
+    ///
+    /// # Panics
+    ///
+    /// Panics if the message version counter overflows.
+    #[must_use = "drop the unretained message after releasing the channel lock"]
+    pub fn publish(&mut self, msg: Arc<T>) -> Option<Arc<T>> {
+        if !self.has_receivers() {
+            self.publish_discarded();
+            return Some(msg);
+        }
+
+        self.publish_retained(msg);
+        None
+    }
+
+    /// Advances the committed tail and retains `msg`.
+    ///
+    /// The caller must already have established that a subscription is active, which is what a
+    /// bounded channel does anyway to decide between rejecting and discarding.
+    ///
+    /// # Panics
+    ///
+    /// Panics if the message version counter overflows.
+    pub fn publish_retained(&mut self, msg: Arc<T>) {
+        debug_assert!(self.has_receivers());
+        self.advance_tail();
+        self.buffer.push_back(msg);
+        if let Retention::Elastic { peak_len } = &mut self.retention {
+            *peak_len = (*peak_len).max(self.buffer.len());
+        }
+    }
+
+    fn insert_receiver(&mut self, head: u64) -> SlotId {
+        if head == self.head {
+            self.head_receivers += 1;
+        }
+
+        self.receivers.insert(head)
+    }
+
+    /// Registers a new subscription at the committed tail.
+    ///
+    /// A new cursor never lowers `retained()`, so this can never release capacity.
+    pub fn subscribe(&mut self) -> SlotId {
+        let head = self.tail;
+        self.insert_receiver(head)
+    }
+
+    pub fn remove_receiver(&mut self, key: SlotId) -> Reclaimed<T> {
+        let head = self.receivers.remove(key);
+
+        if head == self.head {
+            self.release_head_receiver()
+        } else {
+            Reclaimed::empty()
+        }
+    }
+
+    fn release_head_receiver(&mut self) -> Reclaimed<T> {
+        self.head_receivers -= 1;
+
+        if self.head_receivers == 0 {
+            self.reclaim_consumed()
+        } else {
+            Reclaimed::empty()
+        }
+    }
+
+    pub fn receive(&mut self, key: SlotId) -> Option<Received<T>> {
+        let head = {
+            let cursor = self
+                .receivers
+                .get_mut(key)
+                .expect("active broadcast receiver must be registered");
+            if *cursor >= self.tail {
+                return None;
+            }
+            let head = *cursor;
+            *cursor += 1;
+            head
+        };
+
+        debug_assert!(head >= self.head);
+        let offset = (head - self.head) as usize;
+        let msg = self.buffer[offset].clone();
+        let reclaimed = if head == self.head {
+            self.release_head_receiver()
+        } else {
+            Reclaimed::empty()
+        };
+        // A reclaim triggered by this receive always begins with this receiver's own message: the
+        // reclaim path runs only for a cursor sitting at `head`, so the first slot drained is
+        // `msg`. `take_msg` relies on this to recognize that it owns the payload.
+        debug_assert!(
+            reclaimed
+                .first()
+                .is_none_or(|first| Arc::ptr_eq(first, &msg))
+        );
+        Some((msg, reclaimed))
+    }
+
+    /// Advances `head` to the slowest active cursor and hands the released prefix to the caller.
+    ///
+    /// `buffer` shrinks here and grows only in [`Backlog::publish`], so this is the one place
+    /// `retained()` can fall. A bounded channel therefore accounts for released capacity at
+    /// exactly the two call sites that reach this: [`Backlog::receive`] and
+    /// [`Backlog::remove_receiver`].
+    fn reclaim_consumed(&mut self) -> Reclaimed<T> {
+        let mut next_head = self.tail;
+        let mut head_receivers = 0;
+
+        for head in self.receivers.values() {
+            if *head < next_head {
+                next_head = *head;
+                head_receivers = 1;
+            } else if *head == next_head {
+                head_receivers += 1;
+            }
+        }
+
+        debug_assert!(next_head >= self.head);
+        let consumed = usize::try_from(next_head - self.head)
+            .expect("retained broadcast message count exceeds usize");
+        // Move reclaimed messages out so their Drop impls run after the channel is unlocked. Keep
+        // the first one separate so the usual one-message reclaim does not allocate another buffer.
+        let first = if consumed == 0 {
+            None
+        } else {
+            self.buffer.pop_front()
+        };
+        // Reclaiming exactly one message is the overwhelmingly common case — a cursor advances by
+        // one at a time — so skip building a `Drain` that would yield nothing.
+        let rest = if consumed > 1 {
+            self.buffer.drain(..consumed - 1).collect()
+        } else {
+            vec![]
+        };
+        let reclaimed = Reclaimed { first, rest };
+        debug_assert_eq!(reclaimed.len(), consumed);
+
+        self.head = next_head;
+        self.head_receivers = head_receivers;
+        self.shrink_buffer();
+        reclaimed
+    }
+
+    /// Returns the allocation grown for a stalled receiver once that backlog is behind us.
+    ///
+    /// Without this, a single burst pins its peak allocation for the lifetime of the channel.
+    /// The decision is deliberately made only when the buffer drains completely, and against the
+    /// peak of the cycle that just ended rather than the current length: a channel that repeatedly
+    /// fills and drains keeps a peak as large as its bursts, so it holds its allocation instead of
+    /// reallocating on every cycle. Only once a full cycle stays small does the buffer give the
+    /// memory back.
+    ///
+    /// A fixed backlog keeps the allocation it was built with, which is the whole point of asking
+    /// for a capacity up front.
+    fn shrink_buffer(&mut self) {
+        let Retention::Elastic { peak_len } = &mut self.retention else {
+            return;
+        };
+
+        if !self.buffer.is_empty() {
+            return;
+        }
+
+        let peak = mem::take(peak_len);
+        let capacity = self.buffer.capacity();
+        if capacity > MIN_RETAINED_CAPACITY && peak <= capacity / 4 {
+            self.buffer.shrink_to(MIN_RETAINED_CAPACITY.max(peak * 2));
+        }
+    }
+
+    #[cfg(test)]
+    pub fn buffer_capacity(&self) -> usize {
+        self.buffer.capacity()
+    }
+
+    /// Doctors the sequencer so a test can reach the overflow guard in `publish`.
+    #[cfg(test)]
+    pub fn set_tail(&mut self, tail: u64) {
+        self.tail = tail;
+    }
+}
+
+/// Buffer, receiver cursors, and parked receivers, all under one lock.
+///
+/// The wait set lives beside the backlog so that publishing a message and draining the waiters
+/// happen in one critical section. That is what makes the park path race-free: a receiver that
+/// finds no message and then registers still holds this lock, so a concurrent send cannot slip
+/// between the two steps and skip the wake-up.
+pub struct Inner<T> {
+    pub log: Backlog<T>,
+    pub waiters: WakerSet,
+}
+
+impl<T> Inner<T> {
+    /// Wraps `log` in the channel lock and registers the subscription every constructor hands out
+    /// alongside its first sender.
+    pub fn with_first_subscription(mut log: Backlog<T>) -> (Mutex<Self>, SlotId) {
+        let key = log.subscribe();
+        let inner = Mutex::new(Self {
+            log,
+            waiters: WakerSet::new(),
+        });
+        (inner, key)
+    }
+}
+
+/// Wakes every parked receiver so it can observe the channel's disconnected state.
+///
+/// Both families call this from the last sender's `Drop`.
+pub fn disconnect<T>(inner: &Mutex<Inner<T>>) {
+    let wakers = {
+        let mut inner = inner.lock();
+        inner.waiters.take_all()
+    };
+    wake_all(wakers);
+}
+
+/// Releases a cancelled receive's waker registration, dropping the waker unlocked.
+pub fn unregister<T>(
+    inner: &Mutex<Inner<T>>,
+    senders: &AtomicUsize,
+    key: SlotId,
+    token: &mut Option<WakerToken>,
+) {
+    let mut inner = inner.lock();
+    if inner.log.unread(key) != 0 || senders.load(Ordering::Acquire) == 0 {
+        // Publication or disconnection detached this registration under the channel lock.
+        *token = None;
+        return;
+    }
+
+    let waker = inner.waiters.unregister(token);
+    drop(inner);
+    drop(waker);
+}
+
+/// Receives without waiting, yielding the message and the prefix the receive released.
+///
+/// The caller owns what happens next: a bounded channel hands the released count back to blocked
+/// producers before it touches the payload.
+pub fn try_receive<T>(
+    inner: &Mutex<Inner<T>>,
+    senders: &AtomicUsize,
+    key: SlotId,
+) -> Result<Received<T>, TryRecvError> {
+    // Check this receiver's cursor while holding `inner` before observing the sender count.
+    // Senders append messages under the same lock before they can be dropped, so an empty result
+    // here means this receiver has no unread buffered message.
+    let mut inner = inner.lock();
+    match inner.log.receive(key) {
+        Some(received) => Ok(received),
+        None if senders.load(Ordering::Acquire) == 0 => Err(TryRecvError::Disconnected),
+        None => Err(TryRecvError::Empty),
+    }
+}
+
+/// The one poll step behind `recv` on both channels.
+///
+/// Checking the backlog and registering a waker under the same lock prevents a publication from
+/// landing between those steps. Publication and disconnection detach all registrations, so their
+/// ready paths clear the token without unregistering it.
+pub fn poll_receive<T>(
+    inner: &Mutex<Inner<T>>,
+    senders: &AtomicUsize,
+    key: SlotId,
+    token: &mut Option<WakerToken>,
+    cx: &mut Context<'_>,
+) -> Poll<Result<Received<T>, RecvError>> {
+    let mut inner = inner.lock();
+    match inner.log.receive(key) {
+        Some(received) => {
+            *token = None;
+            Poll::Ready(Ok(received))
+        }
+        None => {
+            if senders.load(Ordering::Acquire) == 0 {
+                *token = None;
+                return Poll::Ready(Err(RecvError::Disconnected));
+            }
+
+            let retired_waker = inner.waiters.register(token, cx.waker());
+            drop(inner);
+            drop(retired_waker);
+            Poll::Pending
+        }
+    }
+}
+
+/// Drops the reclaimed backlog, then yields the received message, both with the channel unlocked.
+///
+/// A non-empty backlog means this receive drained `msg` from the buffer, so once the backlog is
+/// dropped this receive holds the only reference and the payload can be moved out instead of
+/// cloned. A channel with a single receiver therefore never clones a payload.
+///
+/// Ownership is decided from that bookkeeping rather than by probing the reference count. An
+/// [`Arc::try_unwrap`] on every receive would fail under fan-out, and its failed compare-exchange
+/// writes to a cache line that every receiver draining the message shares.
+///
+/// This runs `T::clone` and `T::drop`, either of which may panic, so a bounded channel must
+/// already have released the reclaimed capacity before calling it.
+pub fn take_msg<T: Clone>(msg: Arc<T>, reclaimed: Reclaimed<T>) -> T {
+    let sole_owner = !reclaimed.is_empty();
+    drop(reclaimed);
+
+    if !sole_owner {
+        return (*msg).clone();
+    }
+
+    // Another receiver can still hold an in-flight reference to the same message, so the clone
+    // remains the fallback.
+    Arc::try_unwrap(msg).unwrap_or_else(|msg| (*msg).clone())
+}
diff --git a/asyncband/src/broadcast/mpmc/error.rs b/asyncband/src/broadcast/mpmc/error.rs
new file mode 100644
index 0000000..f1cbce9
--- /dev/null
+++ b/asyncband/src/broadcast/mpmc/error.rs
@@ -0,0 +1,103 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::any::type_name;
+use std::fmt;
+
+/// Error returned by [`BoundedSender::try_send`].
+///
+/// A bounded broadcast channel is lossless, so a publication that would exceed the requested
+/// capacity is rejected rather than displacing a retained message. The message that could not be
+/// sent can be retrieved again with [`TrySendError::into_inner`].
+///
+/// [`BoundedSender::try_send`]: crate::broadcast::mpmc::BoundedSender::try_send
+#[derive(Clone, PartialEq, Eq)]
+pub enum TrySendError<T> {
+    /// The shared backlog is at capacity, so the message cannot be sent without waiting for the
+    /// slowest active receiver to release a retained message.
+    Full(T),
+}
+
+impl<T> TrySendError<T> {
+    /// Gets a reference to the message that failed to be sent.
+    pub fn as_inner(&self) -> &T {
+        match self {
+            TrySendError::Full(msg) => msg,
+        }
+    }
+
+    /// Consumes the error and returns the message that failed to be sent.
+    pub fn into_inner(self) -> T {
+        match self {
+            TrySendError::Full(msg) => msg,
+        }
+    }
+}
+
+impl<T> fmt::Display for TrySendError<T> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(match self {
+            TrySendError::Full(_) => "sending on a full channel",
+        })
+    }
+}
+
+impl<T> fmt::Debug for TrySendError<T> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let ty = type_name::<T>();
+        match self {
+            TrySendError::Full(_) => write!(f, "TrySendError<{ty}>::Full(..)"),
+        }
+    }
+}
+
+impl<T> std::error::Error for TrySendError<T> {}
+
+/// Error returned by `recv`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RecvError {
+    /// All senders have been dropped, and this receiver has no remaining messages.
+    Disconnected,
+}
+
+impl fmt::Display for RecvError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str("receiving on a disconnected channel")
+    }
+}
+
+impl std::error::Error for RecvError {}
+
+/// Error returned by `try_recv`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TryRecvError {
+    /// No message is currently available, but at least one sender remains.
+    Empty,
+    /// All senders have been dropped, and this receiver has no remaining messages.
+    Disconnected,
+}
+
+impl fmt::Display for TryRecvError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(match self {
+            TryRecvError::Empty => "receiving on an empty channel",
+            TryRecvError::Disconnected => "receiving on a disconnected channel",
+        })
+    }
+}
+
+impl std::error::Error for TryRecvError {}
diff --git a/asyncband/src/broadcast/mpmc/mod.rs b/asyncband/src/broadcast/mpmc/mod.rs
index 9249df2..636f895 100644
--- a/asyncband/src/broadcast/mpmc/mod.rs
+++ b/asyncband/src/broadcast/mpmc/mod.rs
@@ -16,11 +16,38 @@
 // under the License.
 
 //! Multi-producer, multi-consumer broadcast channels.
+//!
+//! Both channels are lossless: every value a channel accepts stays readable by every subscription
+//! that was active when it was accepted, so a receive never reports lag. They differ in what a
+//! producer does when the slowest subscription stops reclaiming. [`bounded`] retains at most the
+//! capacity it was built with and makes producers wait for that subscription. [`unbounded`] never
+//! waits to send and lets the retained backlog grow instead.
+//!
+//! # Delivery and processing
+//!
+//! A receive advances its subscription before returning the value. The channel tracks unread
+//! messages, not application work: retaining a received value or processing it asynchronously
+//! does not hold backlog capacity. There is no acknowledgement or processing-completion barrier.
+//! If cloning a received value panics, that subscription has still advanced past the value.
+//!
+//! Sending with no subscriptions discards the value and succeeds. A later subscription starts
+//! with future publications; it does not replay discarded or previously retained values.
+//!
+//! Operations briefly acquire internal mutexes. No mutex is held across an await point or while
+//! cloning or dropping payloads. The `try_*` methods do not wait for messages or capacity, but may
+//! wait to acquire a mutex. Sending a value does not wait for subscribers to receive or process it.
 
+mod bounded;
+mod common;
+mod error;
 mod unbounded;
 
-pub use self::unbounded::RecvError;
-pub use self::unbounded::TryRecvError;
+pub use self::bounded::BoundedReceiver;
+pub use self::bounded::BoundedSender;
+pub use self::bounded::bounded;
+pub use self::error::RecvError;
+pub use self::error::TryRecvError;
+pub use self::error::TrySendError;
 pub use self::unbounded::UnboundedReceiver;
 pub use self::unbounded::UnboundedSender;
 pub use self::unbounded::unbounded;
diff --git a/asyncband/src/broadcast/mpmc/unbounded/mod.rs b/asyncband/src/broadcast/mpmc/unbounded/mod.rs
index 3ac8e8f..934374d 100644
--- a/asyncband/src/broadcast/mpmc/unbounded/mod.rs
+++ b/asyncband/src/broadcast/mpmc/unbounded/mod.rs
@@ -26,7 +26,8 @@
 //! Published values remain in the shared backlog until every receiver that was eligible for them
 //! has advanced past them or been dropped. Because sending has no capacity limit, one stalled
 //! receiver can make that backlog exhaust available memory.
-//! [`UnboundedSender::retained_message_count`] reports its current length.
+//! [`UnboundedSender::retained_message_count`] reports its current length. Use [`bounded`] when
+//! producers should wait for the slowest receiver instead of growing the backlog.
 //!
 //! # Receivers
 //!
@@ -50,11 +51,11 @@
 //! assert_eq!(late.try_recv(), Ok("after subscription"));
 //! assert_eq!(late.try_recv(), Err(TryRecvError::Empty));
 //! ```
+//!
+//! [`bounded`]: super::bounded
 
-use std::collections::VecDeque;
 use std::fmt;
 use std::future::Future;
-use std::mem;
 use std::pin::Pin;
 use std::sync::Arc;
 use std::sync::atomic::AtomicUsize;
@@ -62,11 +63,14 @@
 use std::task::Context;
 use std::task::Poll;
 
-use crate::internal::arena::Arena;
+use super::common;
+use super::common::Backlog;
+use super::common::Inner;
+use super::error::RecvError;
+use super::error::TryRecvError;
 use crate::internal::arena::SlotId;
 use crate::internal::mutex::Mutex;
 use crate::internal::wake_all;
-use crate::internal::wakerset::WakerSet;
 use crate::internal::wakerset::WakerToken;
 
 #[cfg(test)]
@@ -87,18 +91,9 @@
 /// assert_eq!(receiver.try_recv(), Ok("ready"));
 /// ```
 pub fn unbounded<T: Clone>() -> (UnboundedSender<T>, UnboundedReceiver<T>) {
-    let mut receivers = Arena::new();
-    let key = receivers.insert(0);
+    let (inner, key) = Inner::with_first_subscription(Backlog::elastic());
     let shared = Arc::new(Shared {
-        inner: Mutex::new(Inner {
-            buffer: VecDeque::new(),
-            head: 0,
-            head_receivers: 1,
-            tail: 0,
-            receivers,
-            peak_len: 0,
-            waiters: WakerSet::new(),
-        }),
+        inner,
         senders: AtomicUsize::new(1),
     });
     let sender = UnboundedSender {
@@ -108,219 +103,8 @@
     (sender, receiver)
 }
 
-/// A receive operation reached the end of its subscription.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum RecvError {
-    /// No sender remains and this receiver has consumed its entire backlog.
-    Disconnected,
-}
-
-impl fmt::Display for RecvError {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        f.write_str("receiving on a disconnected channel")
-    }
-}
-
-impl std::error::Error for RecvError {}
-
-/// A non-blocking receive did not yield a value.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum TryRecvError {
-    /// This receiver is caught up, but a sender can still publish more values.
-    Empty,
-    /// No sender remains and this receiver has consumed its entire backlog.
-    Disconnected,
-}
-
-impl fmt::Display for TryRecvError {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        f.write_str(match self {
-            TryRecvError::Empty => "receiving on an empty channel",
-            TryRecvError::Disconnected => "receiving on a disconnected channel",
-        })
-    }
-}
-
-impl std::error::Error for TryRecvError {}
-
-/// Retained capacity below which the shared buffer is never shrunk back.
-const MIN_RETAINED_CAPACITY: usize = 64;
-
-struct Inner<T> {
-    /// Messages whose versions are in the range `[head, tail)`.
-    ///
-    /// Each message is held behind an `Arc` so the receive path can move the payload out of the
-    /// critical section. Cloning the `Arc` under the lock keeps `T::clone` — and, for reclaimed
-    /// messages, `T::drop` — outside it, which matters because both are arbitrary user code that
-    /// may call back into this channel.
-    buffer: VecDeque<Arc<T>>,
-    /// The version of the first message in `buffer`.
-    head: u64,
-    /// The number of active receivers whose cursor equals `head`.
-    head_receivers: usize,
-    /// The next message version to assign.
-    tail: u64,
-    /// Cursor for each active receiver.
-    receivers: Arena<u64>,
-    /// The largest backlog retained since the buffer was last empty.
-    peak_len: usize,
-    /// Receivers parked in [`UnboundedReceiver::recv`].
-    waiters: WakerSet,
-}
-
-/// Messages removed from the shared buffer and waiting to be dropped after it is unlocked.
-///
-/// Keeping the first message out of the `Vec` avoids a heap allocation on the common path where
-/// one receive reclaims exactly one message.
-struct Reclaimed<T> {
-    first: Option<Arc<T>>,
-    rest: Vec<Arc<T>>,
-}
-
-impl<T> Reclaimed<T> {
-    fn empty() -> Self {
-        Self {
-            first: None,
-            rest: vec![],
-        }
-    }
-
-    fn first(&self) -> Option<&Arc<T>> {
-        self.first.as_ref()
-    }
-
-    fn is_empty(&self) -> bool {
-        self.first.is_none()
-    }
-
-    fn drop_messages(self) {
-        let Self { first, rest } = self;
-        drop((first, rest));
-    }
-}
-
-impl<T> Inner<T> {
-    fn insert_receiver(&mut self, head: u64) -> SlotId {
-        if head == self.head {
-            self.head_receivers += 1;
-        }
-
-        self.receivers.insert(head)
-    }
-
-    fn remove_receiver(&mut self, key: SlotId) -> Reclaimed<T> {
-        let head = self.receivers.remove(key);
-
-        if head == self.head {
-            self.release_head_receiver()
-        } else {
-            Reclaimed::empty()
-        }
-    }
-
-    fn release_head_receiver(&mut self) -> Reclaimed<T> {
-        self.head_receivers -= 1;
-
-        if self.head_receivers == 0 {
-            self.reclaim_consumed()
-        } else {
-            Reclaimed::empty()
-        }
-    }
-
-    fn receive(&mut self, key: SlotId) -> Option<(Arc<T>, Reclaimed<T>)> {
-        let head = {
-            let cursor = self
-                .receivers
-                .get_mut(key)
-                .expect("active broadcast receiver must be registered");
-            if *cursor >= self.tail {
-                return None;
-            }
-            let head = *cursor;
-            *cursor += 1;
-            head
-        };
-
-        debug_assert!(head >= self.head);
-        let offset = (head - self.head) as usize;
-        let msg = self.buffer[offset].clone();
-        let reclaimed = if head == self.head {
-            self.release_head_receiver()
-        } else {
-            Reclaimed::empty()
-        };
-        // A reclaim triggered by this receive always begins with this receiver's own message: the
-        // reclaim path runs only for a cursor sitting at `head`, so the first slot drained is
-        // `msg`. `take_msg` relies on this to recognize that it owns the payload.
-        debug_assert!(
-            reclaimed
-                .first()
-                .is_none_or(|first| Arc::ptr_eq(first, &msg))
-        );
-        Some((msg, reclaimed))
-    }
-
-    fn reclaim_consumed(&mut self) -> Reclaimed<T> {
-        let mut next_head = self.tail;
-        let mut head_receivers = 0;
-
-        for head in self.receivers.values() {
-            if *head < next_head {
-                next_head = *head;
-                head_receivers = 1;
-            } else if *head == next_head {
-                head_receivers += 1;
-            }
-        }
-
-        debug_assert!(next_head >= self.head);
-        let consumed = usize::try_from(next_head - self.head)
-            .expect("retained broadcast message count exceeds usize");
-        // Move reclaimed messages out so their Drop impls run after `inner` is unlocked. Keep the
-        // first one separate so the usual one-message reclaim does not allocate another buffer.
-        let first = if consumed == 0 {
-            None
-        } else {
-            self.buffer.pop_front()
-        };
-        let rest = self.buffer.drain(..consumed.saturating_sub(1)).collect();
-        let reclaimed = Reclaimed { first, rest };
-
-        self.head = next_head;
-        self.head_receivers = head_receivers;
-        self.shrink_buffer();
-        reclaimed
-    }
-
-    /// Returns the allocation grown for a stalled receiver once that backlog is behind us.
-    ///
-    /// Without this, a single burst pins its peak allocation for the lifetime of the channel.
-    /// The decision is deliberately made only when the buffer drains completely, and against the
-    /// peak of the cycle that just ended rather than the current length: a channel that repeatedly
-    /// fills and drains keeps a peak as large as its bursts, so it holds its allocation instead of
-    /// reallocating on every cycle. Only once a full cycle stays small does the buffer give the
-    /// memory back.
-    fn shrink_buffer(&mut self) {
-        if !self.buffer.is_empty() {
-            return;
-        }
-
-        let peak = mem::take(&mut self.peak_len);
-        let capacity = self.buffer.capacity();
-        if capacity > MIN_RETAINED_CAPACITY && peak <= capacity / 4 {
-            self.buffer.shrink_to(MIN_RETAINED_CAPACITY.max(peak * 2));
-        }
-    }
-}
-
 struct Shared<T> {
     /// Buffer, receiver cursors, and parked receivers, all under a single lock.
-    ///
-    /// The wait set lives here rather than beside it so that publishing a message and draining the
-    /// waiters happen in one critical section. That is what makes the park path race-free: a
-    /// receiver that finds no message and then registers still holds this lock, so a concurrent
-    /// `send` cannot slip between the two steps and skip the wake-up.
     inner: Mutex<Inner<T>>,
     /// Number of active senders.
     senders: AtomicUsize,
@@ -355,14 +139,7 @@
 impl<T> Drop for UnboundedSender<T> {
     fn drop(&mut self) {
         match self.shared.senders.fetch_sub(1, Ordering::AcqRel) {
-            1 => {
-                // Wake every parked receiver so it can observe the channel's disconnected state.
-                let wakers = {
-                    let mut inner = self.shared.inner.lock();
-                    inner.waiters.take_all()
-                };
-                wake_all(wakers);
-            }
+            1 => common::disconnect(&self.shared.inner),
             _ => {
                 // there are still other senders left, do nothing
             }
@@ -399,32 +176,17 @@
 
         // Publishing and draining the wait set share one critical section, so a receiver can never
         // observe an empty buffer and park after this message became visible.
-        let wakers = {
+        let (unretained, wakers) = {
             let mut inner = self.shared.inner.lock();
-            inner.tail = inner
-                .tail
-                .checked_add(1)
-                .expect("broadcast channel version counter overflowed");
-
-            if inner.receivers.is_empty() {
-                // No receivers means no one will read this message; advance `head` so the
-                // invariant that `buffer` covers versions `[head, tail)` still holds without
-                // buffering anything. The buffer is already drained when the last receiver was
-                // dropped, so there is nothing to clear here.
-                debug_assert!(inner.buffer.is_empty());
-                debug_assert_eq!(inner.head_receivers, 0);
-                inner.head = inner.tail;
-            } else {
-                inner.buffer.push_back(msg);
-                inner.peak_len = inner.peak_len.max(inner.buffer.len());
-            }
-
-            inner.waiters.drain()
+            let unretained = inner.log.publish(msg);
+            let wakers = inner.waiters.drain();
+            (unretained, wakers)
         };
 
         // Notify all waiting receivers. An unsent message is dropped here too, once the lock is
         // released.
         wake_all(wakers);
+        drop(unretained);
     }
 
     /// Returns the number of values in the shared backlog.
@@ -451,7 +213,7 @@
     /// assert_eq!(publisher.retained_message_count(), 0);
     /// ```
     pub fn retained_message_count(&self) -> usize {
-        self.shared.inner.lock().buffer.len()
+        self.shared.inner.lock().log.retained()
     }
 
     /// Subscribes a new receiver for values published from this point forward.
@@ -474,11 +236,11 @@
     /// ```
     #[must_use = "the receiver is dropped immediately if it is not retained"]
     pub fn subscribe(&self) -> UnboundedReceiver<T> {
-        let mut inner = self.shared.inner.lock();
-        let head = inner.tail;
-        let key = inner.insert_receiver(head);
-        let shared = self.shared.clone();
-        UnboundedReceiver { shared, key }
+        let key = self.shared.inner.lock().log.subscribe();
+        UnboundedReceiver {
+            shared: self.shared.clone(),
+            key,
+        }
     }
 }
 
@@ -501,7 +263,7 @@
     fn drop(&mut self) {
         let reclaimed = {
             let mut inner = self.shared.inner.lock();
-            inner.remove_receiver(self.key)
+            inner.log.remove_receiver(self.key)
         };
         drop(reclaimed);
     }
@@ -565,51 +327,15 @@
     /// assert_eq!(receiver.try_recv(), Err(TryRecvError::Disconnected));
     /// ```
     pub fn try_recv(&mut self) -> Result<T, TryRecvError> {
-        let (msg, reclaimed) = self.try_recv_shared()?;
-        Ok(take_msg(msg, reclaimed))
+        let (msg, reclaimed) =
+            common::try_receive(&self.shared.inner, &self.shared.senders, self.key)?;
+        Ok(common::take_msg(msg, reclaimed))
     }
 }
 
-/// Drops the reclaimed backlog, then yields the received message, both with the channel unlocked.
-///
-/// A non-empty backlog means this receive drained `msg` from the buffer, so once the backlog is
-/// dropped this receive holds the only reference and the payload can be moved out instead of
-/// cloned. A channel with a single receiver therefore never clones a payload.
-///
-/// Ownership is decided from that bookkeeping rather than by probing the reference count. An
-/// [`Arc::try_unwrap`] on every receive would fail under fan-out, and its failed compare-exchange
-/// writes to a cache line that every receiver draining the message shares.
-fn take_msg<T: Clone>(msg: Arc<T>, reclaimed: Reclaimed<T>) -> T {
-    let sole_owner = !reclaimed.is_empty();
-    reclaimed.drop_messages();
-
-    if !sole_owner {
-        return (*msg).clone();
-    }
-
-    // Another receiver can still hold an in-flight reference to the same message, so the clone
-    // remains the fallback.
-    Arc::try_unwrap(msg).unwrap_or_else(|msg| (*msg).clone())
-}
-
 impl<T> UnboundedReceiver<T> {
-    fn try_recv_shared(&mut self) -> Result<(Arc<T>, Reclaimed<T>), TryRecvError> {
-        // Check this receiver's cursor while holding `inner` before observing `senders`. Senders
-        // append messages under the same lock before they can be dropped, so an empty result here
-        // means this receiver has no unread buffered message.
-        let mut inner = self.shared.inner.lock();
-        if let Some(received) = inner.receive(self.key) {
-            return Ok(received);
-        }
-
-        if self.shared.senders.load(Ordering::Acquire) == 0 {
-            Err(TryRecvError::Disconnected)
-        } else {
-            Err(TryRecvError::Empty)
-        }
-    }
-
-    /// Creates another receiver at the current publication boundary.
+    /// Re-subscribes to the channel, returning a new receiver that starts receiving messages from
+    /// the *current* tail of the channel.
     ///
     /// The new receiver skips this receiver's unread backlog. The original receiver remains at its
     /// current position and continues retaining those values until it consumes them or is dropped.
@@ -633,11 +359,11 @@
     /// ```
     #[must_use = "the receiver is dropped immediately if it is not retained"]
     pub fn resubscribe(&self) -> Self {
-        let mut inner = self.shared.inner.lock();
-        let head = inner.tail;
-        let key = inner.insert_receiver(head);
-        let shared = self.shared.clone();
-        Self { shared, key }
+        let key = self.shared.inner.lock().log.subscribe();
+        Self {
+            shared: self.shared.clone(),
+            key,
+        }
     }
 
     /// Returns this receiver's unread value count.
@@ -662,12 +388,7 @@
     /// assert_eq!(receiver.unread_message_count(), 1);
     /// ```
     pub fn unread_message_count(&self) -> usize {
-        let inner = self.shared.inner.lock();
-        let head = *inner
-            .receivers
-            .get(self.key)
-            .expect("active broadcast receiver must be registered");
-        usize::try_from(inner.tail - head).expect("unread broadcast message count exceeds usize")
+        self.shared.inner.lock().log.unread(self.key)
     }
 }
 
@@ -683,21 +404,12 @@
             return;
         }
 
-        let mut inner = self.receiver.shared.inner.lock();
-        let cursor = *inner
-            .receivers
-            .get(self.receiver.key)
-            .expect("active broadcast receiver must be registered");
-        if cursor != inner.tail || self.receiver.shared.senders.load(Ordering::Acquire) == 0 {
-            // A publisher or the final sender owns this registration or has already detached it
-            // under the channel lock.
-            self.token = None;
-            return;
-        }
-
-        let waker = inner.waiters.unregister(&mut self.token);
-        drop(inner);
-        drop(waker);
+        common::unregister(
+            &self.receiver.shared.inner,
+            &self.receiver.shared.senders,
+            self.receiver.key,
+            &mut self.token,
+        );
     }
 }
 
@@ -707,26 +419,18 @@
     fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
         let Self { receiver, token } = self.get_mut();
 
-        let received = {
-            let mut inner = receiver.shared.inner.lock();
-            match inner.receive(receiver.key) {
-                Some(received) => received,
-                None => {
-                    if receiver.shared.senders.load(Ordering::Acquire) == 0 {
-                        *token = None;
-                        return Poll::Ready(Err(RecvError::Disconnected));
-                    }
-
-                    let retired_waker = inner.waiters.register(token, cx.waker());
-                    drop(inner);
-                    drop(retired_waker);
-                    return Poll::Pending;
-                }
-            }
+        let (msg, reclaimed) = match common::poll_receive(
+            &receiver.shared.inner,
+            &receiver.shared.senders,
+            receiver.key,
+            token,
+            cx,
+        ) {
+            Poll::Pending => return Poll::Pending,
+            Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
+            Poll::Ready(Ok(received)) => received,
         };
 
-        let (msg, reclaimed) = received;
-        *token = None;
-        Poll::Ready(Ok(take_msg(msg, reclaimed)))
+        Poll::Ready(Ok(common::take_msg(msg, reclaimed)))
     }
 }
diff --git a/asyncband/src/broadcast/mpmc/unbounded/tests.rs b/asyncband/src/broadcast/mpmc/unbounded/tests.rs
index 933ee02..4bf330a 100644
--- a/asyncband/src/broadcast/mpmc/unbounded/tests.rs
+++ b/asyncband/src/broadcast/mpmc/unbounded/tests.rs
@@ -16,13 +16,14 @@
 // under the License.
 
 use super::*;
+use crate::broadcast::mpmc::common::MIN_RETAINED_CAPACITY;
 
 #[test]
 #[should_panic(expected = "broadcast channel version counter overflowed")]
 fn send_panics_on_version_overflow() {
     // The receiver is dropped right away: the doctored counter would make its own drop overflow.
     let (tx, _) = unbounded();
-    tx.shared.inner.lock().tail = u64::MAX;
+    tx.shared.inner.lock().log.set_tail(u64::MAX);
     tx.send(());
 }
 
@@ -34,7 +35,7 @@
     for i in 0..burst {
         tx.send(i);
     }
-    assert!(tx.shared.inner.lock().buffer.capacity() >= burst);
+    assert!(tx.shared.inner.lock().log.buffer_capacity() >= burst);
 
     for i in 0..burst {
         assert_eq!(rx.try_recv(), Ok(i));
@@ -42,12 +43,12 @@
 
     // Draining evaluates the cycle that just peaked, so the burst allocation is still held.
     assert_eq!(tx.retained_message_count(), 0);
-    assert!(tx.shared.inner.lock().buffer.capacity() >= burst);
+    assert!(tx.shared.inner.lock().log.buffer_capacity() >= burst);
 
     // The next cycle stays small, which is what releases the memory.
     tx.send(0);
     assert_eq!(rx.try_recv(), Ok(0));
-    assert!(tx.shared.inner.lock().buffer.capacity() < burst);
+    assert!(tx.shared.inner.lock().log.buffer_capacity() < burst);
 }
 
 #[test]
@@ -65,5 +66,5 @@
     }
 
     // Every cycle peaks at the same size, so the buffer must not rebuild its allocation each time.
-    assert!(tx.shared.inner.lock().buffer.capacity() >= burst);
+    assert!(tx.shared.inner.lock().log.buffer_capacity() >= burst);
 }
diff --git a/asyncband/src/internal/mod.rs b/asyncband/src/internal/mod.rs
index 7b8cb35..7998e25 100644
--- a/asyncband/src/internal/mod.rs
+++ b/asyncband/src/internal/mod.rs
@@ -98,17 +98,20 @@
 pub(crate) mod mutex;
 
 #[cfg(any(
+    feature = "broadcast",
     feature = "mpmc",
     feature = "mutex",
     feature = "rwlock",
     feature = "semaphore",
 ))]
-// MPMC uses waiter notifications; mutexes and rwlocks use acquire/release operations; the public
-// semaphore also exposes permit accounting. Single-primitive builds leave part of this API unused.
+// Broadcast and MPMC use waiter notifications; mutexes and rwlocks use acquire/release operations;
+// the public semaphore also exposes permit accounting. Single-primitive builds leave part of this
+// API unused.
 #[allow(dead_code)]
 pub(crate) mod semaphore;
 
 #[cfg(any(
+    feature = "broadcast",
     feature = "event",
     feature = "mpmc",
     feature = "mpsc",
diff --git a/asyncband/src/internal/semaphore.rs b/asyncband/src/internal/semaphore.rs
index 8c2de0c..ca4c301 100644
--- a/asyncband/src/internal/semaphore.rs
+++ b/asyncband/src/internal/semaphore.rs
@@ -86,19 +86,17 @@
         self.end == WAKE_BATCH_SIZE
     }
 
-    fn wake_all(&mut self) {
-        wake_all(std::iter::from_fn(|| {
-            if self.start == self.end {
-                return None;
-            }
+    fn take_next(&mut self) -> Option<Waker> {
+        if self.start == self.end {
+            self.start = 0;
+            self.end = 0;
+            return None;
+        }
 
-            let index = self.start;
-            self.start += 1;
-            // SAFETY: `index` was within the initialized range before advancing `start`.
-            Some(unsafe { self.wakers[index].assume_init_read() })
-        }));
-        self.start = 0;
-        self.end = 0;
+        let index = self.start;
+        self.start += 1;
+        // SAFETY: `index` was within the initialized range before advancing `start`.
+        Some(unsafe { self.wakers[index].assume_init_read() })
     }
 }
 
@@ -206,7 +204,7 @@
     }
 
     /// Adds `n` permits to the semaphore if there is any waiter.
-    #[cfg(feature = "mpmc")]
+    #[cfg(any(feature = "broadcast", feature = "mpmc"))]
     pub fn release_if_nonempty(&self, n: usize) {
         let waiters = self.waiters.lock();
         if !waiters.is_empty() {
@@ -215,7 +213,7 @@
     }
 
     /// Adds as many permits until there is no waiter.
-    #[cfg(feature = "mpmc")]
+    #[cfg(any(feature = "broadcast", feature = "mpmc"))]
     pub fn notify_all(&self) {
         let mut waiters = self.waiters.lock();
         let mut wakers = vec![];
@@ -237,7 +235,7 @@
             }
         }
         drop(waiters);
-        crate::internal::wake_all(wakers.into_iter());
+        wake_all(wakers.into_iter());
     }
 
     fn insert_permits_with_lock(
@@ -245,51 +243,62 @@
         mut rem: usize,
         waiters: MutexGuard<'_, WaitList<WaitNode>>,
     ) {
-        let mut wakers = WakeBatch::new();
-
+        let mut batch = WakeBatch::new();
         let mut lock = Some(waiters);
-        while rem > 0 {
-            let mut waiters = lock.take().unwrap_or_else(|| self.waiters.lock());
-            while !wakers.is_full() {
-                match waiters.unlink_first_waiter(|node| {
-                    if node.permits <= rem {
-                        rem -= node.permits;
-                        node.permits = 0;
-                        true
-                    } else {
-                        node.permits -= rem;
-                        rem = 0;
-                        false
-                    }
-                }) {
-                    None => break,
-                    Some((id, waiter)) => {
-                        let remove_now = waiter.waker.is_none();
-                        if let Some(waker) = waiter.waker.take() {
-                            wakers.push(waker);
+
+        // One iterator covers the entire release. If a callback panics, `wake_all` keeps pulling
+        // batches during unwinding, so the remaining permits are still distributed and notified.
+        wake_all(std::iter::from_fn(|| {
+            loop {
+                if let Some(waker) = batch.take_next() {
+                    return Some(waker);
+                }
+                if rem == 0 {
+                    return None;
+                }
+
+                let mut waiters = lock.take().unwrap_or_else(|| self.waiters.lock());
+                while !batch.is_full() {
+                    match waiters.unlink_first_waiter(|node| {
+                        if node.permits <= rem {
+                            rem -= node.permits;
+                            node.permits = 0;
+                            true
+                        } else {
+                            node.permits -= rem;
+                            rem = 0;
+                            false
                         }
-                        if remove_now {
-                            waiters.remove_unlinked_waiter(id);
+                    }) {
+                        None => break,
+                        Some((id, waiter)) => {
+                            let remove_now = waiter.waker.is_none();
+                            if let Some(waker) = waiter.waker.take() {
+                                batch.push(waker);
+                            }
+                            if remove_now {
+                                waiters.remove_unlinked_waiter(id);
+                            }
                         }
                     }
                 }
-            }
 
-            if rem > 0 && waiters.is_empty() {
-                // Holding `waiters` serializes all permit additions. Concurrent operations can
-                // only remove permits, so the count cannot grow between this check and fetch_add.
-                let current = self.permits.load(Ordering::Relaxed);
-                assert!(
-                    current.checked_add(rem).is_some(),
-                    "number of added permits ({rem}) would overflow usize::MAX (prev: {current})"
-                );
-                self.permits.fetch_add(rem, Ordering::Release);
-                rem = 0;
-            }
+                if rem > 0 && waiters.is_empty() {
+                    // Retire the remainder before the overflow check so unwinding cannot retry it.
+                    let added = std::mem::take(&mut rem);
+                    // The lock serializes additions; concurrent operations can only remove permits.
+                    let current = self.permits.load(Ordering::Relaxed);
+                    assert!(
+                        current.checked_add(added).is_some(),
+                        "number of added permits ({added}) would overflow usize::MAX (prev: {current})"
+                    );
+                    self.permits.fetch_add(added, Ordering::Release);
+                }
 
-            drop(waiters);
-            wakers.wake_all();
-        }
+                // Neither wake callbacks nor destruction of the taken waker run under this lock.
+                drop(waiters);
+            }
+        }));
     }
 }
 
@@ -463,6 +472,8 @@
 
 #[cfg(test)]
 mod tests {
+    use std::panic;
+    use std::panic::AssertUnwindSafe;
     use std::sync::Arc;
     use std::sync::atomic::AtomicUsize;
     use std::sync::atomic::Ordering;
@@ -492,8 +503,8 @@
     }
 
     #[test]
-    fn release_drains_more_than_one_wake_batch() {
-        const WAITER_COUNT: usize = WAKE_BATCH_SIZE + 3;
+    fn release_distributes_permits_to_all_waiters() {
+        const WAITER_COUNT: usize = 35;
 
         let semaphore = Semaphore::new(0);
         let counter = Arc::new(WakeCounter(AtomicUsize::new(0)));
@@ -515,4 +526,64 @@
         }
         assert_eq!(semaphore.waiters.lock().occupied_len(), 0);
     }
+
+    #[test]
+    fn panicking_wakes_preserve_permits_and_the_first_panic() {
+        const WAITER_COUNT: usize = 65;
+
+        struct TrackedWake {
+            count: AtomicUsize,
+            panic_message: Option<&'static str>,
+        }
+
+        impl Wake for TrackedWake {
+            fn wake(self: Arc<Self>) {
+                self.count.fetch_add(1, Ordering::Relaxed);
+                if let Some(message) = self.panic_message {
+                    panic::panic_any(message);
+                }
+            }
+        }
+
+        let semaphore = Semaphore::new(0);
+        let trackers = (0..WAITER_COUNT)
+            .map(|index| {
+                Arc::new(TrackedWake {
+                    count: AtomicUsize::new(0),
+                    panic_message: if index == 0 {
+                        Some("first wake panic")
+                    } else if index == WAITER_COUNT / 2 {
+                        Some("later wake panic")
+                    } else {
+                        None
+                    },
+                })
+            })
+            .collect::<Vec<_>>();
+        let mut acquires = trackers
+            .iter()
+            .map(|tracker| {
+                let mut acquire = semaphore.poll_acquire(1);
+                assert!(
+                    acquire
+                        .poll_once(&Waker::from(tracker.clone()))
+                        .is_pending()
+                );
+                acquire
+            })
+            .collect::<Vec<_>>();
+
+        let payload = panic::catch_unwind(AssertUnwindSafe(|| semaphore.release(WAITER_COUNT + 2)))
+            .expect_err("the original wake panic must reach the caller");
+        assert_eq!(payload.downcast_ref::<&str>(), Some(&"first wake panic"));
+
+        for tracker in trackers {
+            assert_eq!(tracker.count.load(Ordering::Relaxed), 1);
+        }
+        for acquire in &mut acquires {
+            assert!(acquire.poll_once(Waker::noop()).is_ready());
+        }
+        assert_eq!(semaphore.available_permits(), 2);
+        assert_eq!(semaphore.waiters.lock().occupied_len(), 0);
+    }
 }
diff --git a/asyncband/src/lib.rs b/asyncband/src/lib.rs
index ae99158..afd6faf 100644
--- a/asyncband/src/lib.rs
+++ b/asyncband/src/lib.rs
@@ -75,7 +75,7 @@
 //! |                            | [`oneshot`]                                   | `oneshot`      | Send one value from one sender to one receiver.                                                               |
 //! |                            | [`mpmc`]                                      | `mpmc`         | Distribute each value to exactly one of multiple competing receivers.      |
 //! |                            | [`mpsc`]                                      | `mpsc`         | Send each value from multiple producers to one receiver with bounded backpressure or an unbounded queue.      |
-//! |                            | [`broadcast`]                                 | `broadcast`    | Deliver every value to receivers active at send time; retain an unbounded backlog until each consumes or drops. |
+//! |                            | [`broadcast`]                                 | `broadcast`    | Deliver every value to active receivers with bounded backpressure or unbounded retention. |
 //! |                            | [`watch`]                                     | `watch`        | Publish cloneable latest state from one or more senders; receivers independently coalesce intermediate updates. |
 //! | Object reuse               | [`pool`]                                      | `pool`         | Reuse objects through bounded or unbounded pool variants.                                                     |
 //! | Sync interop               | [`FutureExt`](blocking::FutureExt)            | `blocking`     | Drive one runtime-agnostic future from a blocking thread.                                                     |
diff --git a/benchmarks/asyncband/broadcast/mpmc/bounded.rs b/benchmarks/asyncband/broadcast/mpmc/bounded.rs
new file mode 100644
index 0000000..aabae2e
--- /dev/null
+++ b/benchmarks/asyncband/broadcast/mpmc/bounded.rs
@@ -0,0 +1,176 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Every benchmark here must return the channel to a steady state on each iteration: the retained
+// backlog back where it started, no parked producer left behind, and no permit slack in the
+// producer wait queue. Unlike the unbounded channel the hazard is not unbounded memory but a
+// wedged timed loop — a send that never gets its capacity back would hang the bench, not slow it.
+
+use std::pin::pin;
+
+use asyncband::broadcast::mpmc;
+use divan::Bencher;
+use divan::black_box;
+
+use crate::support::bench_context;
+use crate::support::defer_input_drop;
+use crate::support::poll_pending;
+use crate::support::poll_pinned_ready;
+
+const RECEIVER_COUNTS: &[usize] = &[1, 8, 32];
+const BLOCKED_SENDER_COUNTS: &[usize] = &[1, 8, 32];
+const CAPACITY: usize = 64;
+
+#[divan::bench]
+fn send_without_receivers(bencher: Bencher) {
+    // No subscription means nothing is retained, so this measures the discard path, which never
+    // allocates and never waits.
+    let (tx, rx) = mpmc::bounded(CAPACITY);
+    drop(rx);
+
+    bencher.bench_local(|| tx.try_send(black_box(1)));
+}
+
+#[divan::bench]
+fn try_send_and_try_recv(bencher: Bencher) {
+    let (tx, mut rx) = mpmc::bounded(CAPACITY);
+
+    bencher.bench_local(|| {
+        tx.try_send(black_box(1)).unwrap();
+        black_box(rx.try_recv().unwrap())
+    });
+}
+
+#[divan::bench]
+fn try_send_when_full(bencher: Bencher) {
+    let (tx, _rx) = mpmc::bounded(1);
+    tx.try_send(0).unwrap();
+
+    // The rejected value comes straight back, so the channel stays exactly as full as it started.
+    bencher.bench_local(|| black_box(tx.try_send(black_box(1))).is_err());
+}
+
+#[divan::bench(args = RECEIVER_COUNTS)]
+fn try_send_and_drain_fanout(bencher: Bencher, receiver_count: usize) {
+    let (tx, rx) = mpmc::bounded(CAPACITY);
+    let mut receivers = Vec::with_capacity(receiver_count);
+    receivers.push(rx);
+    for _ in 1..receiver_count {
+        receivers.push(tx.subscribe());
+    }
+
+    // One message in, every receiver drains it out: the last one to read pays the reclaim scan and
+    // the capacity release, and the channel is empty again for the next iteration.
+    bencher.bench_local(|| {
+        tx.try_send(black_box(1)).unwrap();
+        for receiver in &mut receivers {
+            black_box(receiver.try_recv().unwrap());
+        }
+    });
+}
+
+#[divan::bench(args = BLOCKED_SENDER_COUNTS)]
+fn reclaim_wakes_blocked_senders(bencher: Bencher, sender_count: usize) {
+    let mut context = bench_context();
+
+    // Measures the whole backpressure cycle: park `sender_count` producers on a full channel, free
+    // one slot, and let exactly one of them through. Each iteration ends with the same number of
+    // producers parked and the same backlog, so the loop is stationary.
+    bencher
+        .with_inputs(|| {
+            let (tx, rx) = mpmc::bounded(1);
+            tx.try_send(0).unwrap();
+            (tx, rx)
+        })
+        .bench_local_refs(|(tx, rx)| {
+            let mut sends = (0..sender_count)
+                .map(|value| Box::pin(tx.send(value)))
+                .collect::<Vec<_>>();
+            for send in &mut sends {
+                poll_pending(send.as_mut(), &mut context);
+            }
+
+            // Releasing one slot wakes the queue; one producer republishes and the rest re-park.
+            black_box(rx.try_recv().unwrap());
+            for send in &mut sends {
+                if send.as_mut().poll(&mut context).is_ready() {
+                    break;
+                }
+            }
+
+            // Drain the republished message so the next iteration starts from the same state.
+            black_box(rx.try_recv().unwrap());
+            drop(sends);
+            tx.try_send(0).unwrap();
+        });
+}
+
+#[divan::bench]
+fn cancel_blocked_send(bencher: Bencher) {
+    let mut context = bench_context();
+    let (tx, _rx) = mpmc::bounded(1);
+    tx.try_send(0).unwrap();
+
+    // Park a producer and immediately cancel it: measures registering and unlinking one waiter.
+    bencher.bench_local(|| {
+        let send = pin!(tx.send(black_box(1)));
+        poll_pending(send, &mut context);
+    });
+}
+
+#[divan::bench]
+fn deliver_to_waiting_receiver(bencher: Bencher) {
+    let mut context = bench_context();
+    let (tx, mut rx) = mpmc::bounded(CAPACITY);
+
+    bencher.bench_local(|| {
+        let mut recv = pin!(rx.recv());
+        poll_pending(recv.as_mut(), &mut context);
+        tx.try_send(black_box(1)).unwrap();
+        black_box(poll_pinned_ready(recv, &mut context).unwrap())
+    });
+}
+
+#[divan::bench(args = [1, 2, 32, 256], sample_size = 64)]
+fn drop_lagging_receiver_wakes_senders(bencher: Bencher, backlog: usize) {
+    bencher
+        .with_inputs(|| {
+            let (sender, mut fast) = mpmc::bounded(backlog);
+            let slow = sender.subscribe();
+            for value in 0..backlog {
+                sender.try_send(value).unwrap();
+                assert_eq!(fast.try_recv().unwrap(), value);
+            }
+            let mut context = bench_context();
+            let mut sends = (0..backlog)
+                .map(|value| {
+                    let sender = sender.clone();
+                    Box::pin(async move { sender.send(value).await })
+                })
+                .collect::<Vec<_>>();
+            for send in &mut sends {
+                poll_pending(send.as_mut(), &mut context);
+            }
+            (slow, fast, sends)
+        })
+        .bench_local_values(|(slow, fast, sends)| {
+            // The fast subscription stays alive so this measures reclaim, not last-receiver exit.
+            // Preparing the backlog, parking senders, and disposing of futures are outside timing.
+            drop(slow);
+            defer_input_drop((fast, sends), ())
+        });
+}
diff --git a/benchmarks/asyncband/broadcast/mpmc/mod.rs b/benchmarks/asyncband/broadcast/mpmc/mod.rs
index 78ef889..e0ac834 100644
--- a/benchmarks/asyncband/broadcast/mpmc/mod.rs
+++ b/benchmarks/asyncband/broadcast/mpmc/mod.rs
@@ -15,4 +15,5 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod bounded;
 mod unbounded;
diff --git a/benchmarks/asyncband/semaphore/release.rs b/benchmarks/asyncband/semaphore/release.rs
index d5423f4..4c400fa 100644
--- a/benchmarks/asyncband/semaphore/release.rs
+++ b/benchmarks/asyncband/semaphore/release.rs
@@ -15,10 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::sync::Arc;
+
 use asyncband::semaphore::Semaphore;
 use divan::Bencher;
 use divan::black_box;
 
+use crate::support::bench_context;
+use crate::support::defer_input_drop;
+use crate::support::poll_pending;
+
 #[divan::bench]
 fn fulfill_debt_repeatedly(bencher: Bencher) {
     const CYCLES: usize = 64;
@@ -42,3 +48,25 @@
             black_box(semaphore)
         });
 }
+
+// The first two sizes distinguish a single handoff from fan-out; larger sizes measure bulk release.
+#[divan::bench(args = [1, 2, 32, 256], sample_size = 64)]
+fn release_to_waiters(bencher: Bencher, waiter_count: usize) {
+    bencher
+        .with_inputs(|| {
+            let semaphore = Arc::new(Semaphore::new(0));
+            let mut context = bench_context();
+            let mut waiters = (0..waiter_count)
+                .map(|_| Box::pin(semaphore.clone().acquire_owned(1)))
+                .collect::<Vec<_>>();
+            for waiter in &mut waiters {
+                poll_pending(waiter.as_mut(), &mut context);
+            }
+            (semaphore, waiters)
+        })
+        .bench_local_values(|(semaphore, waiters)| {
+            // Only release and wake callbacks are timed; registration and future cleanup are not.
+            semaphore.release(black_box(waiter_count));
+            defer_input_drop((semaphore, waiters), ())
+        });
+}
diff --git a/benchmarks/ecosystem/broadcast/mpmc/adapters.rs b/benchmarks/ecosystem/broadcast/mpmc/adapters.rs
index 7b98870..658707b 100644
--- a/benchmarks/ecosystem/broadcast/mpmc/adapters.rs
+++ b/benchmarks/ecosystem/broadcast/mpmc/adapters.rs
@@ -15,8 +15,11 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::future::Future;
 use std::task::Context;
 
+use asyncband::blocking::FutureExt;
+
 use crate::support::poll_ready;
 
 pub struct Asyncband;
@@ -128,3 +131,111 @@
         poll_ready(receiver.recv_direct(), context).unwrap()
     }
 }
+
+/// A lossless bounded broadcast channel: every accepted value reaches every active subscription,
+/// and a full channel makes producers wait rather than displacing anything.
+///
+/// `tokio::sync::broadcast` deliberately has no implementation here — see the note in `bounded.rs`.
+pub trait BoundedBroadcastMpmc: Send + Sync + 'static {
+    type Sender: Clone + Send + Sync + 'static;
+    type Receiver: Send + 'static;
+
+    fn channel(capacity: usize, receiver_count: usize) -> (Self::Sender, Vec<Self::Receiver>);
+    fn try_send(sender: &Self::Sender, value: usize);
+    fn send_async(sender: &Self::Sender, value: usize) -> impl Future<Output = ()> + Send;
+    fn recv_async(receiver: &mut Self::Receiver) -> impl Future<Output = usize> + Send;
+
+    fn send_ready(sender: &Self::Sender, value: usize, context: &mut Context<'_>) {
+        poll_ready(Self::send_async(sender, value), context);
+    }
+
+    fn send_blocking(sender: &Self::Sender, value: usize) {
+        FutureExt::block_on(Self::send_async(sender, value));
+    }
+
+    fn try_recv(receiver: &mut Self::Receiver) -> Option<usize>;
+
+    fn recv_ready(receiver: &mut Self::Receiver, context: &mut Context<'_>) -> usize {
+        poll_ready(Self::recv_async(receiver), context)
+    }
+
+    fn recv_blocking(receiver: &mut Self::Receiver) -> usize {
+        FutureExt::block_on(Self::recv_async(receiver))
+    }
+}
+
+impl BoundedBroadcastMpmc for Asyncband {
+    type Receiver = asyncband::broadcast::mpmc::BoundedReceiver<usize>;
+    type Sender = asyncband::broadcast::mpmc::BoundedSender<usize>;
+
+    fn channel(capacity: usize, receiver_count: usize) -> (Self::Sender, Vec<Self::Receiver>) {
+        let (sender, receiver) = asyncband::broadcast::mpmc::bounded(capacity);
+        let mut receivers = Vec::with_capacity(receiver_count);
+        receivers.push(receiver);
+        for _ in 1..receiver_count {
+            receivers.push(sender.subscribe());
+        }
+        (sender, receivers)
+    }
+
+    fn try_send(sender: &Self::Sender, value: usize) {
+        sender.try_send(value).unwrap();
+    }
+
+    async fn send_async(sender: &Self::Sender, value: usize) {
+        sender.send(value).await;
+    }
+
+    async fn recv_async(receiver: &mut Self::Receiver) -> usize {
+        receiver.recv().await.unwrap()
+    }
+
+    fn try_recv(receiver: &mut Self::Receiver) -> Option<usize> {
+        match receiver.try_recv() {
+            Ok(value) => Some(value),
+            Err(asyncband::broadcast::mpmc::TryRecvError::Empty) => None,
+            Err(asyncband::broadcast::mpmc::TryRecvError::Disconnected) => {
+                panic!("asyncband channel closed during benchmark")
+            }
+        }
+    }
+}
+
+impl BoundedBroadcastMpmc for AsyncBroadcast {
+    type Receiver = async_broadcast::Receiver<usize>;
+    type Sender = async_broadcast::Sender<usize>;
+
+    fn channel(capacity: usize, receiver_count: usize) -> (Self::Sender, Vec<Self::Receiver>) {
+        let (sender, receiver) = async_broadcast::broadcast(capacity);
+        let mut receivers = Vec::with_capacity(receiver_count);
+        receivers.push(receiver);
+        for _ in 1..receiver_count {
+            let receiver = receivers[0].clone();
+            receivers.push(receiver);
+        }
+        (sender, receivers)
+    }
+
+    fn try_send(sender: &Self::Sender, value: usize) {
+        sender.try_broadcast(value).unwrap();
+    }
+
+    async fn send_async(sender: &Self::Sender, value: usize) {
+        sender
+            .broadcast_direct(value)
+            .await
+            .expect("async-broadcast lost every receiver during benchmark");
+    }
+
+    async fn recv_async(receiver: &mut Self::Receiver) -> usize {
+        receiver.recv_direct().await.unwrap()
+    }
+
+    fn try_recv(receiver: &mut Self::Receiver) -> Option<usize> {
+        match receiver.try_recv() {
+            Ok(value) => Some(value),
+            Err(async_broadcast::TryRecvError::Empty) => None,
+            Err(error) => panic!("unexpected async-broadcast receive error: {error}"),
+        }
+    }
+}
diff --git a/benchmarks/ecosystem/broadcast/mpmc/bounded.rs b/benchmarks/ecosystem/broadcast/mpmc/bounded.rs
new file mode 100644
index 0000000..d4cf7f9
--- /dev/null
+++ b/benchmarks/ecosystem/broadcast/mpmc/bounded.rs
@@ -0,0 +1,98 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Asyncband and async-broadcast are compared here because both are lossless and both make
+// producers wait at capacity, so a small channel measures the same contract on each side.
+//
+// `tokio::sync::broadcast` is deliberately absent. It overwrites at capacity and reports `Lagged`
+// rather than waiting, so it has no lossless bounded path to compare: it would be measuring the
+// cheaper workload of dropping messages. It appears in `unbounded.rs` instead, where every peer is
+// given room for the whole batch and the comparison is over their shared non-blocking path.
+//
+// Sweep capacity and producer/subscription counts independently. Capacity one measures the
+// per-message handoff; larger backlogs allow several messages to be outstanding. How effectively
+// that headroom is used depends on scheduling and the slowest subscription, not just fanout.
+
+use divan::Bencher;
+use divan::black_box;
+use divan::counter::ItemsCount;
+
+use super::adapters::AsyncBroadcast;
+use super::adapters::Asyncband;
+use super::adapters::BoundedBroadcastMpmc;
+use super::support::BATCH_MESSAGES;
+use super::support::BOUNDED_SHAPES;
+use super::support::BoundedConcurrent;
+use super::support::BoundedShape;
+use super::support::BoundedTasks;
+use super::support::ROUND_TRIP_CAPACITY;
+use crate::support::bench_context;
+
+// Send-then-receive pairing keeps at most one message retained, so these never reach capacity.
+#[divan::bench(types = [Asyncband, AsyncBroadcast], sample_size = 512)]
+fn try_round_trip<C: BoundedBroadcastMpmc>(bencher: Bencher) {
+    let (sender, mut receivers) = C::channel(ROUND_TRIP_CAPACITY, 1);
+    let mut receiver = receivers.pop().unwrap();
+
+    bencher.bench_local(|| {
+        C::try_send(&sender, black_box(usize::MAX));
+        black_box(C::try_recv(&mut receiver).unwrap())
+    });
+}
+
+#[divan::bench(types = [Asyncband, AsyncBroadcast], sample_size = 512)]
+fn ready_round_trip<C: BoundedBroadcastMpmc>(bencher: Bencher) {
+    let mut context = bench_context();
+    let (sender, mut receivers) = C::channel(ROUND_TRIP_CAPACITY, 1);
+    let mut receiver = receivers.pop().unwrap();
+
+    bencher.bench_local(|| {
+        C::send_ready(&sender, black_box(usize::MAX), &mut context);
+        black_box(C::recv_ready(&mut receiver, &mut context))
+    });
+}
+
+// Keep one fixture per sample so workers from other fixtures are not alive during timing.
+#[divan::bench(
+    types = [Asyncband, AsyncBroadcast],
+    args = BOUNDED_SHAPES,
+    sample_count = 10,
+    sample_size = 1,
+    counter = ItemsCount::new(BATCH_MESSAGES),
+)]
+fn concurrent<C: BoundedBroadcastMpmc>(bencher: Bencher, shape: BoundedShape) {
+    bencher
+        .with_inputs(|| BoundedConcurrent::new::<C>(shape))
+        .bench_local_refs(BoundedConcurrent::run);
+}
+
+#[divan::bench(
+    types = [Asyncband, AsyncBroadcast],
+    args = BOUNDED_SHAPES,
+    sample_count = 10,
+    sample_size = 1,
+    counter = ItemsCount::new(BATCH_MESSAGES),
+)]
+fn scheduled<C: BoundedBroadcastMpmc>(bencher: Bencher, shape: BoundedShape) {
+    let runtime = tokio::runtime::Builder::new_multi_thread()
+        .worker_threads(4)
+        .build()
+        .unwrap();
+    bencher
+        .with_inputs(|| BoundedTasks::new::<C>(&runtime, shape))
+        .bench_local_refs(|tasks| tasks.run(&runtime));
+}
diff --git a/benchmarks/ecosystem/broadcast/mpmc/mod.rs b/benchmarks/ecosystem/broadcast/mpmc/mod.rs
index 5e86ce6..dd09282 100644
--- a/benchmarks/ecosystem/broadcast/mpmc/mod.rs
+++ b/benchmarks/ecosystem/broadcast/mpmc/mod.rs
@@ -16,5 +16,6 @@
 // under the License.
 
 mod adapters;
+mod bounded;
 mod support;
 mod unbounded;
diff --git a/benchmarks/ecosystem/broadcast/mpmc/support.rs b/benchmarks/ecosystem/broadcast/mpmc/support.rs
index 55c016a..cd6e83b 100644
--- a/benchmarks/ecosystem/broadcast/mpmc/support.rs
+++ b/benchmarks/ecosystem/broadcast/mpmc/support.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use std::fmt;
 use std::marker::PhantomData;
 use std::sync::Arc;
 use std::sync::Barrier;
@@ -22,14 +23,79 @@
 use std::thread::JoinHandle;
 
 use divan::black_box;
+use tokio::runtime::Runtime;
+use tokio::task::JoinSet;
 
+use super::adapters::BoundedBroadcastMpmc;
 use super::adapters::BroadcastMpmc;
 
 pub const BATCH_MESSAGES: usize = 4096;
 pub const PRODUCER_COUNTS: &[usize] = &[1, 2, 4, 8];
 pub const RECEIVER_COUNTS: &[usize] = &[1, 2, 4, 8, 32];
+/// Capacity for the round-trip benches, which pair every send with a receive and so never fill
+/// the channel.
 pub const ROUND_TRIP_CAPACITY: usize = 64;
 
+/// One bounded workload: the channel capacity, how many producers publish, and how many
+/// subscriptions read.
+///
+/// Capacity bounds the shared backlog, independently of the subscription count. Capacity one
+/// forces the next publication to wait until every subscription advances; larger capacities let
+/// producers run ahead by that many messages, subject to scheduling and consumer progress.
+#[derive(Clone, Copy)]
+pub struct BoundedShape {
+    pub capacity: usize,
+    pub producers: usize,
+    pub receivers: usize,
+}
+
+impl BoundedShape {
+    const fn new(capacity: usize, producers: usize, receivers: usize) -> Self {
+        Self {
+            capacity,
+            producers,
+            receivers,
+        }
+    }
+}
+
+impl fmt::Display for BoundedShape {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "cap {} {} producers {} receivers",
+            self.capacity, self.producers, self.receivers
+        )
+    }
+}
+
+pub const BOUNDED_SHAPES: &[BoundedShape] = &[
+    BoundedShape::new(1, 1, 1),
+    BoundedShape::new(1, 1, 8),
+    BoundedShape::new(1, 8, 1),
+    BoundedShape::new(1, 8, 8),
+    BoundedShape::new(2, 1, 1),
+    BoundedShape::new(2, 1, 8),
+    BoundedShape::new(2, 8, 1),
+    BoundedShape::new(2, 8, 8),
+    BoundedShape::new(8, 1, 1),
+    BoundedShape::new(8, 1, 8),
+    BoundedShape::new(8, 8, 1),
+    BoundedShape::new(8, 8, 8),
+    BoundedShape::new(64, 1, 1),
+    BoundedShape::new(64, 1, 8),
+    BoundedShape::new(64, 1, 32),
+    BoundedShape::new(64, 8, 1),
+    BoundedShape::new(64, 8, 8),
+    BoundedShape::new(64, 8, 32),
+    BoundedShape::new(1024, 1, 1),
+    BoundedShape::new(1024, 1, 8),
+    BoundedShape::new(1024, 1, 32),
+    BoundedShape::new(1024, 8, 1),
+    BoundedShape::new(1024, 8, 8),
+    BoundedShape::new(1024, 8, 32),
+];
+
 fn recv<C: BroadcastMpmc>(receiver: &mut C::Receiver) -> usize {
     C::try_recv(receiver).expect("the published benchmark batch must be ready")
 }
@@ -157,3 +223,119 @@
         }
     }
 }
+
+/// Concurrent producers and subscribers on native threads. Each subscriber drains the full batch.
+/// Construction is outside timing; `run` includes barrier release, transfers, checksum validation,
+/// and worker joins.
+pub struct BoundedConcurrent {
+    start: Arc<Barrier>,
+    workers: Vec<JoinHandle<()>>,
+}
+
+impl BoundedConcurrent {
+    pub fn new<C: BoundedBroadcastMpmc>(shape: BoundedShape) -> Self {
+        let BoundedShape {
+            capacity,
+            producers,
+            receivers,
+        } = shape;
+        assert_eq!(BATCH_MESSAGES % producers, 0);
+        let (sender, receivers) = C::channel(capacity, receivers);
+        let start = Arc::new(Barrier::new(producers + receivers.len() + 1));
+        let messages_per_producer = BATCH_MESSAGES / producers;
+        let mut workers = Vec::with_capacity(producers + receivers.len());
+
+        for mut receiver in receivers {
+            let start = start.clone();
+            workers.push(thread::spawn(move || {
+                start.wait();
+                let mut checksum = 0usize;
+                for _ in 0..BATCH_MESSAGES {
+                    checksum = checksum.wrapping_add(C::recv_blocking(&mut receiver));
+                }
+                assert_eq!(checksum, BATCH_MESSAGES * (BATCH_MESSAGES - 1) / 2);
+            }));
+        }
+        for producer in 0..producers {
+            let sender = sender.clone();
+            let start = start.clone();
+            workers.push(thread::spawn(move || {
+                start.wait();
+                let first = producer * messages_per_producer;
+                for value in first..first + messages_per_producer {
+                    C::send_blocking(&sender, black_box(value));
+                }
+            }));
+        }
+
+        Self { start, workers }
+    }
+
+    pub fn run(&mut self) {
+        self.start.wait();
+        for worker in self.workers.drain(..) {
+            worker.join().expect("bounded benchmark worker panicked");
+        }
+    }
+}
+
+/// The same bounded workload on async tasks. Construction and spawning are outside timing; `run`
+/// includes barrier release, transfers, checksum validation, and task joins.
+pub struct BoundedTasks {
+    start: Arc<tokio::sync::Barrier>,
+    tasks: JoinSet<()>,
+}
+
+impl BoundedTasks {
+    pub fn new<C: BoundedBroadcastMpmc>(runtime: &Runtime, shape: BoundedShape) -> Self {
+        let BoundedShape {
+            capacity,
+            producers,
+            receivers,
+        } = shape;
+        assert_eq!(BATCH_MESSAGES % producers, 0);
+        let (sender, receivers) = C::channel(capacity, receivers);
+        let start = Arc::new(tokio::sync::Barrier::new(producers + receivers.len() + 1));
+        let mut tasks = JoinSet::new();
+
+        for mut receiver in receivers {
+            let start = start.clone();
+            tasks.spawn_on(
+                async move {
+                    start.wait().await;
+                    let mut checksum = 0usize;
+                    for _ in 0..BATCH_MESSAGES {
+                        checksum = checksum.wrapping_add(C::recv_async(&mut receiver).await);
+                    }
+                    assert_eq!(checksum, BATCH_MESSAGES * (BATCH_MESSAGES - 1) / 2);
+                },
+                runtime.handle(),
+            );
+        }
+        for producer in 0..producers {
+            let sender = sender.clone();
+            let start = start.clone();
+            tasks.spawn_on(
+                async move {
+                    start.wait().await;
+                    let first = producer * (BATCH_MESSAGES / producers);
+                    for value in first..first + BATCH_MESSAGES / producers {
+                        C::send_async(&sender, black_box(value)).await;
+                    }
+                },
+                runtime.handle(),
+            );
+        }
+
+        Self { start, tasks }
+    }
+
+    pub fn run(&mut self, runtime: &Runtime) {
+        runtime.block_on(async {
+            self.start.wait().await;
+            while let Some(result) = self.tasks.join_next().await {
+                result.expect("bounded benchmark task panicked");
+            }
+        });
+    }
+}
diff --git a/tests-integration/tests/broadcast_mpmc_bounded_test.rs b/tests-integration/tests/broadcast_mpmc_bounded_test.rs
new file mode 100644
index 0000000..5acfd17
--- /dev/null
+++ b/tests-integration/tests/broadcast_mpmc_bounded_test.rs
@@ -0,0 +1,820 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::future::Future;
+use std::sync::Arc;
+use std::sync::Barrier;
+use std::task::Context;
+use std::task::Wake;
+use std::task::Waker;
+use std::thread;
+
+use asyncband::blocking::FutureExt;
+use asyncband::broadcast::mpmc::*;
+use tests_integration::WakeCounter;
+use tests_integration::assert_completes_without_deadlock;
+use tests_integration::poll_once;
+use tests_integration::waker_on_wake;
+
+/// A payload whose destructor re-enters the channel it was sent through.
+struct Reentrant {
+    value: u64,
+    channel: Option<BoundedSender<Reentrant>>,
+}
+
+impl Clone for Reentrant {
+    fn clone(&self) -> Self {
+        Self {
+            value: self.value,
+            channel: self.channel.clone(),
+        }
+    }
+}
+
+impl Drop for Reentrant {
+    fn drop(&mut self) {
+        if let Some(channel) = &self.channel {
+            // Deadlocks if the channel still holds its lock while dropping reclaimed messages.
+            let _ = channel.retained_message_count();
+        }
+    }
+}
+
+/// A payload that panics while a shared receive clones it.
+#[derive(Debug)]
+struct PanicOnClone {
+    value: u64,
+    panic: bool,
+}
+
+impl Clone for PanicOnClone {
+    fn clone(&self) -> Self {
+        if self.panic {
+            panic!("panic while cloning a broadcast message");
+        }
+        Self {
+            value: self.value,
+            panic: self.panic,
+        }
+    }
+}
+
+/// A payload that panics while the channel drops a message it reclaimed.
+///
+/// Clones disarm themselves, so only the copy the channel retains is dangerous. That lets a test
+/// drain a receiver normally and still blow up inside the reclaim.
+struct PanicOnDrop {
+    armed: bool,
+}
+
+impl Clone for PanicOnDrop {
+    fn clone(&self) -> Self {
+        Self { armed: false }
+    }
+}
+
+impl Drop for PanicOnDrop {
+    fn drop(&mut self) {
+        if self.armed {
+            panic!("panic while dropping a broadcast message");
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Fanout and subscription
+// ---------------------------------------------------------------------------------------------
+
+#[tokio::test]
+async fn bounded_delivers_every_message_to_every_receiver() {
+    let (tx, mut rx1) = bounded(4);
+    let mut rx2 = tx.subscribe();
+
+    tx.send(10).await;
+    tx.send(20).await;
+
+    assert_eq!(rx1.recv().await, Ok(10));
+    assert_eq!(rx1.recv().await, Ok(20));
+    assert_eq!(rx2.recv().await, Ok(10));
+    assert_eq!(rx2.recv().await, Ok(20));
+}
+
+#[test]
+fn bounded_slow_receiver_keeps_every_message_under_backpressure() {
+    let (tx, mut fast) = bounded(2);
+    let mut slow = tx.subscribe();
+
+    tx.try_send(1).unwrap();
+    tx.try_send(2).unwrap();
+
+    // The fast subscription draining does not release anything, because the slow one has read
+    // nothing — being bounded must not turn into dropping what the slow subscription still owes.
+    assert_eq!(fast.try_recv(), Ok(1));
+    assert_eq!(fast.try_recv(), Ok(2));
+    assert_eq!(tx.try_send(3), Err(TrySendError::Full(3)));
+
+    // One read by the slow subscription frees exactly one slot.
+    assert_eq!(slow.try_recv(), Ok(1));
+    tx.try_send(3).unwrap();
+
+    // Every value accepted while both were active reaches both, in order.
+    assert_eq!(slow.try_recv(), Ok(2));
+    assert_eq!(slow.try_recv(), Ok(3));
+    assert_eq!(fast.try_recv(), Ok(3));
+    assert_eq!(tx.retained_message_count(), 0);
+}
+
+#[test]
+fn bounded_subscribe_starts_at_the_committed_tail() {
+    let (tx, _rx) = bounded(4);
+    tx.try_send(1).unwrap();
+
+    let mut late = tx.subscribe();
+    assert_eq!(late.try_recv(), Err(TryRecvError::Empty));
+
+    tx.try_send(2).unwrap();
+    assert_eq!(late.try_recv(), Ok(2));
+}
+
+#[test]
+fn bounded_resubscribe_keeps_the_original_receivers_backlog() {
+    let (tx, mut rx) = bounded(4);
+    tx.try_send(1).unwrap();
+    tx.try_send(2).unwrap();
+
+    let mut rx2 = rx.resubscribe();
+    tx.try_send(3).unwrap();
+
+    assert_eq!(rx2.try_recv(), Ok(3));
+    assert_eq!(rx.try_recv(), Ok(1));
+    assert_eq!(rx.try_recv(), Ok(2));
+    assert_eq!(rx.try_recv(), Ok(3));
+}
+
+#[test]
+fn bounded_unread_message_count_tracks_each_receiver() {
+    let (tx, mut rx1) = bounded(4);
+    let rx2 = tx.subscribe();
+
+    assert_eq!(rx1.unread_message_count(), 0);
+
+    tx.try_send(1).unwrap();
+    tx.try_send(2).unwrap();
+    assert_eq!(rx1.unread_message_count(), 2);
+    assert_eq!(rx2.unread_message_count(), 2);
+
+    assert_eq!(rx1.try_recv(), Ok(1));
+    assert_eq!(rx1.unread_message_count(), 1);
+    assert_eq!(rx2.unread_message_count(), 2);
+}
+
+// ---------------------------------------------------------------------------------------------
+// Strict capacity
+// ---------------------------------------------------------------------------------------------
+
+#[test]
+fn try_send_rejects_at_capacity_and_returns_the_value() {
+    let (tx, mut rx) = bounded(2);
+
+    tx.try_send(1).unwrap();
+    tx.try_send(2).unwrap();
+    assert_eq!(tx.try_send(3), Err(TrySendError::Full(3)));
+
+    // The rejected value is handed back untouched, and nothing was published.
+    assert_eq!(tx.retained_message_count(), 2);
+    assert_eq!(rx.try_recv(), Ok(1));
+    assert_eq!(rx.try_recv(), Ok(2));
+    assert_eq!(rx.try_recv(), Err(TryRecvError::Empty));
+}
+
+#[test]
+fn capacity_counts_the_shared_backlog_not_receivers() {
+    let (tx, _rx) = bounded(2);
+    let _extra = (0..8).map(|_| tx.subscribe()).collect::<Vec<_>>();
+
+    // Eight more subscriptions do not consume capacity; only unread messages do.
+    tx.try_send(1).unwrap();
+    tx.try_send(2).unwrap();
+    assert_eq!(tx.try_send(3), Err(TrySendError::Full(3)));
+    assert_eq!(tx.capacity(), 2);
+    assert_eq!(tx.retained_message_count(), 2);
+}
+
+#[test]
+fn retained_message_count_tracks_the_slowest_receiver() {
+    let (tx, mut rx1) = bounded(4);
+    let mut rx2 = tx.subscribe();
+
+    tx.try_send(1).unwrap();
+    tx.try_send(2).unwrap();
+    assert_eq!(tx.retained_message_count(), 2);
+
+    // Draining one receiver does not release what the other has not read.
+    assert_eq!(rx1.try_recv(), Ok(1));
+    assert_eq!(rx1.try_recv(), Ok(2));
+    assert_eq!(tx.retained_message_count(), 2);
+
+    assert_eq!(rx2.try_recv(), Ok(1));
+    assert_eq!(tx.retained_message_count(), 1);
+    assert_eq!(rx2.try_recv(), Ok(2));
+    assert_eq!(tx.retained_message_count(), 0);
+}
+
+// ---------------------------------------------------------------------------------------------
+// Backpressure and capacity release
+// ---------------------------------------------------------------------------------------------
+
+#[test]
+fn send_waits_while_the_slowest_subscription_holds_capacity() {
+    let (tx, mut rx1) = bounded(1);
+    let mut rx2 = tx.subscribe();
+    tx.try_send(1).unwrap();
+
+    let mut send = Box::pin(tx.send(2));
+    assert!(poll_once(send.as_mut()).is_pending());
+
+    // The fast receiver draining is not enough while the slow one still retains the message.
+    assert_eq!(rx1.try_recv(), Ok(1));
+    assert!(poll_once(send.as_mut()).is_pending());
+
+    assert_eq!(rx2.try_recv(), Ok(1));
+    assert!(poll_once(send.as_mut()).is_ready());
+    assert_eq!(rx1.try_recv(), Ok(2));
+}
+
+#[test]
+fn receive_that_vacates_the_head_wakes_a_blocked_sender() {
+    let (tx, mut rx) = bounded(1);
+    tx.try_send(0).unwrap();
+
+    let tracker = Arc::new(WakeCounter::default());
+    let waker = Waker::from(tracker.clone());
+    let mut send = Box::pin(tx.send(1));
+    assert!(
+        send.as_mut()
+            .poll(&mut Context::from_waker(&waker))
+            .is_pending()
+    );
+    assert_eq!(tracker.count(), 0);
+
+    assert_eq!(rx.try_recv(), Ok(0));
+    assert_eq!(tracker.count(), 1);
+    assert!(poll_once(send.as_mut()).is_ready());
+}
+
+#[test]
+fn parked_recv_that_reclaims_wakes_a_blocked_sender() {
+    let (tx, mut rx) = bounded(1);
+    tx.try_send(0).unwrap();
+
+    let tracker = Arc::new(WakeCounter::default());
+    let waker = Waker::from(tracker.clone());
+    let mut send = Box::pin(tx.send(1));
+    assert!(
+        send.as_mut()
+            .poll(&mut Context::from_waker(&waker))
+            .is_pending()
+    );
+
+    // Reclaim through the `recv` future rather than `try_recv`: it is a separate call site, and a
+    // release wired into only one of them would strand this producer.
+    let mut recv = Box::pin(rx.recv());
+    assert_eq!(poll_once(recv.as_mut()), std::task::Poll::Ready(Ok(0)));
+    drop(recv);
+
+    assert_eq!(tracker.count(), 1);
+    assert!(poll_once(send.as_mut()).is_ready());
+}
+
+#[test]
+fn wakes_blocked_senders_as_capacity_frees() {
+    let (tx, mut rx) = bounded(1);
+    tx.try_send(0).unwrap();
+
+    let mut first = Box::pin(tx.send(1));
+    let mut second = Box::pin(tx.send(2));
+    assert!(poll_once(first.as_mut()).is_pending());
+    assert!(poll_once(second.as_mut()).is_pending());
+
+    // One freed slot admits exactly one producer.
+    assert_eq!(rx.try_recv(), Ok(0));
+    assert!(poll_once(first.as_mut()).is_ready());
+    assert!(poll_once(second.as_mut()).is_pending());
+
+    assert_eq!(rx.try_recv(), Ok(1));
+    assert!(poll_once(second.as_mut()).is_ready());
+    assert_eq!(rx.try_recv(), Ok(2));
+}
+
+#[test]
+fn dropping_a_lagging_receiver_wakes_blocked_senders() {
+    let (tx, mut rx1) = bounded(1);
+    let rx2 = tx.subscribe();
+    tx.try_send(0).unwrap();
+
+    let mut send = Box::pin(tx.send(1));
+    assert_eq!(rx1.try_recv(), Ok(0));
+    assert!(poll_once(send.as_mut()).is_pending());
+
+    // `rx2` is the one holding the backlog; dropping it releases the slot.
+    drop(rx2);
+    assert_eq!(tx.retained_message_count(), 0);
+    assert!(poll_once(send.as_mut()).is_ready());
+}
+
+#[test]
+fn dropping_the_last_receiver_wakes_every_blocked_sender() {
+    const BLOCKED: usize = 3;
+
+    let (tx, rx) = bounded(2);
+    tx.try_send(0).unwrap();
+    tx.try_send(1).unwrap();
+
+    // More blocked producers than the drop will reclaim slots. Once no receiver remains every
+    // send succeeds unconditionally, so waking only `reclaimed` of them would strand the rest.
+    let trackers = (0..BLOCKED)
+        .map(|_| Arc::new(WakeCounter::default()))
+        .collect::<Vec<_>>();
+    let mut sends = (0..BLOCKED)
+        .map(|value| Box::pin(tx.send(10 + value as i32)))
+        .collect::<Vec<_>>();
+
+    for (send, tracker) in sends.iter_mut().zip(&trackers) {
+        let waker = Waker::from(tracker.clone());
+        assert!(
+            send.as_mut()
+                .poll(&mut Context::from_waker(&waker))
+                .is_pending()
+        );
+    }
+
+    drop(rx);
+
+    for (index, tracker) in trackers.iter().enumerate() {
+        assert!(
+            tracker.count() > 0,
+            "blocked sender {index} was never woken after the last receiver was dropped"
+        );
+    }
+    for send in &mut sends {
+        assert!(poll_once(send.as_mut()).is_ready());
+    }
+}
+
+#[test]
+fn sends_never_block_once_all_receivers_are_gone() {
+    let (tx, rx) = bounded(1);
+    tx.try_send(0).unwrap();
+    drop(rx);
+
+    assert_eq!(tx.retained_message_count(), 0);
+    tx.try_send(1).unwrap();
+    tx.try_send(2).unwrap();
+
+    let mut send = Box::pin(tx.send(3));
+    assert!(poll_once(send.as_mut()).is_ready());
+    assert_eq!(tx.retained_message_count(), 0);
+}
+
+#[test]
+fn subscribing_while_producers_are_blocked_does_not_release_capacity() {
+    let (tx, _rx) = bounded(1);
+    tx.try_send(0).unwrap();
+
+    let mut send = Box::pin(tx.send(1));
+    assert!(poll_once(send.as_mut()).is_pending());
+
+    // A new cursor starts at the tail, so it cannot lower the retained backlog.
+    let _late = tx.subscribe();
+    assert_eq!(tx.retained_message_count(), 1);
+    assert!(poll_once(send.as_mut()).is_pending());
+}
+
+// ---------------------------------------------------------------------------------------------
+// Cancellation
+// ---------------------------------------------------------------------------------------------
+
+#[test]
+fn cancelled_send_publishes_nothing() {
+    let (tx, mut rx) = bounded(1);
+    tx.try_send(0).unwrap();
+
+    let mut send = Box::pin(tx.send(1));
+    assert!(poll_once(send.as_mut()).is_pending());
+    drop(send);
+
+    // The cancelled value never entered the committed order, so the next receive sees only what
+    // was already published, and the one after it is a fresh send.
+    assert_eq!(rx.try_recv(), Ok(0));
+    tx.try_send(2).unwrap();
+    assert_eq!(rx.try_recv(), Ok(2));
+    assert_eq!(rx.try_recv(), Err(TryRecvError::Empty));
+}
+
+#[test]
+fn cancelled_notified_sender_passes_capacity_to_the_next_sender() {
+    let (tx, mut rx) = bounded(1);
+    tx.try_send(0).unwrap();
+
+    let mut first = Box::pin(tx.send(1));
+    let mut second = Box::pin(tx.send(2));
+    assert!(poll_once(first.as_mut()).is_pending());
+    assert!(poll_once(second.as_mut()).is_pending());
+
+    assert_eq!(rx.try_recv(), Ok(0));
+    drop(first);
+
+    assert!(poll_once(second.as_mut()).is_ready());
+    assert_eq!(rx.try_recv(), Ok(2));
+}
+
+#[test]
+fn cancelled_recv_releases_its_waker() {
+    let (tx, mut rx) = bounded(4);
+
+    let tracker = Arc::new(WakeCounter::default());
+    let waker = Waker::from(tracker.clone());
+    let baseline = Arc::strong_count(&tracker);
+
+    let mut recv = Box::pin(rx.recv());
+    assert!(
+        recv.as_mut()
+            .poll(&mut Context::from_waker(&waker))
+            .is_pending()
+    );
+    assert_eq!(Arc::strong_count(&tracker), baseline + 1);
+
+    drop(recv);
+    assert_eq!(Arc::strong_count(&tracker), baseline);
+
+    tx.try_send(1).unwrap();
+    assert_eq!(tracker.count(), 0);
+}
+
+// ---------------------------------------------------------------------------------------------
+// Disconnection
+// ---------------------------------------------------------------------------------------------
+
+#[test]
+fn dropping_a_woken_recv_keeps_another_receivers_waiter() {
+    let (tx, mut rx1) = bounded::<i32>(2);
+    let mut rx2 = tx.subscribe();
+    let first = Arc::new(WakeCounter::default());
+    let waker = Waker::from(first.clone());
+    let mut context = Context::from_waker(&waker);
+    let mut recv1 = Box::pin(rx1.recv());
+
+    assert!(recv1.as_mut().poll(&mut context).is_pending());
+
+    tx.try_send(1).unwrap();
+    assert_eq!(first.count(), 1);
+    assert_eq!(rx2.try_recv(), Ok(1));
+
+    let second = Arc::new(WakeCounter::default());
+    let waker = Waker::from(second.clone());
+    let mut context = Context::from_waker(&waker);
+    let mut recv2 = Box::pin(rx2.recv());
+    assert!(recv2.as_mut().poll(&mut context).is_pending());
+
+    // `recv1` was already woken, so dropping it must not release the slot `recv2` now owns.
+    drop(recv1);
+    tx.try_send(2).unwrap();
+
+    assert_eq!(second.count(), 1);
+}
+
+#[tokio::test]
+async fn bounded_recv_drains_buffered_messages_before_reporting_disconnection() {
+    let (tx, mut rx) = bounded(4);
+    tx.try_send(1).unwrap();
+    tx.try_send(2).unwrap();
+    drop(tx);
+
+    assert_eq!(rx.recv().await, Ok(1));
+    assert_eq!(rx.recv().await, Ok(2));
+    assert_eq!(rx.recv().await, Err(RecvError::Disconnected));
+}
+
+#[tokio::test]
+async fn bounded_recv_reports_disconnection_without_any_message() {
+    let (tx, mut rx) = bounded::<i32>(4);
+    drop(tx);
+    assert_eq!(rx.recv().await, Err(RecvError::Disconnected));
+}
+
+#[test]
+fn bounded_parked_recv_wakes_when_the_last_sender_drops() {
+    let (tx, mut rx) = bounded::<i32>(4);
+    let second_tx = tx.clone();
+
+    let tracker = Arc::new(WakeCounter::default());
+    let waker = Waker::from(tracker.clone());
+    let mut recv = Box::pin(rx.recv());
+    assert!(
+        recv.as_mut()
+            .poll(&mut Context::from_waker(&waker))
+            .is_pending()
+    );
+
+    drop(tx);
+    assert_eq!(tracker.count(), 0);
+
+    drop(second_tx);
+    assert_eq!(tracker.count(), 1);
+    assert_eq!(
+        poll_once(recv.as_mut()),
+        std::task::Poll::Ready(Err(RecvError::Disconnected))
+    );
+}
+
+// ---------------------------------------------------------------------------------------------
+// Panic safety
+// ---------------------------------------------------------------------------------------------
+
+#[test]
+fn panicking_wake_does_not_strand_senders_after_a_large_reclaim() {
+    let (tx, mut fast) = bounded(40);
+    let slow = tx.subscribe();
+    for value in 0..40 {
+        tx.try_send(value).unwrap();
+        fast.try_recv().unwrap();
+    }
+
+    let trackers = (0..40)
+        .map(|_| Arc::new(WakeCounter::default()))
+        .collect::<Vec<_>>();
+    let wakers = trackers
+        .iter()
+        .enumerate()
+        .map(|(index, tracker)| {
+            let tracker = tracker.clone();
+            waker_on_wake(move || {
+                tracker.wake();
+                assert_ne!(index, 0, "first sender wake panics");
+            })
+        })
+        .collect::<Vec<_>>();
+    let mut sends = (40..80)
+        .map(|value| Box::pin(tx.send(value)))
+        .collect::<Vec<_>>();
+    for (send, waker) in sends.iter_mut().zip(&wakers) {
+        assert!(
+            send.as_mut()
+                .poll(&mut Context::from_waker(waker))
+                .is_pending()
+        );
+    }
+
+    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(slow)));
+    assert!(result.is_err());
+    assert_eq!(tx.retained_message_count(), 0);
+    for tracker in trackers {
+        assert_eq!(tracker.count(), 1);
+    }
+    // Every send fits without another receive. All must have been notified, not merely made
+    // ready for a poll that an executor would otherwise have no reason to perform.
+    for send in &mut sends {
+        assert!(poll_once(send.as_mut()).is_ready());
+    }
+    for value in 40..80 {
+        assert_eq!(fast.try_recv(), Ok(value));
+    }
+}
+
+#[test]
+fn bounded_panicking_clone_leaves_the_channel_consistent() {
+    let (tx, mut rx1) = bounded(4);
+    let mut rx2 = tx.subscribe();
+
+    tx.try_send(PanicOnClone {
+        value: 1,
+        panic: true,
+    })
+    .unwrap();
+    tx.try_send(PanicOnClone {
+        value: 2,
+        panic: false,
+    })
+    .unwrap();
+
+    // Two receivers share the payload, so this receive has to clone it.
+    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+        rx1.try_recv().map(|msg| msg.value)
+    }));
+    assert!(result.is_err());
+
+    // The failed receive still consumed the message for `rx1`, and left the channel usable for
+    // both receivers.
+    assert_eq!(rx1.try_recv().unwrap().value, 2);
+    assert_eq!(rx2.try_recv().unwrap().value, 1);
+    assert_eq!(rx2.try_recv().unwrap().value, 2);
+    assert_eq!(tx.retained_message_count(), 0);
+    assert_eq!(rx1.try_recv().unwrap_err(), TryRecvError::Empty);
+}
+
+#[test]
+fn panicking_payload_destructor_still_releases_capacity() {
+    let (tx, mut rx1) = bounded(3);
+    let rx2 = tx.subscribe();
+
+    // Only the first retained message is armed: the reclaim drops the whole prefix, and a second
+    // panic while the first one unwinds would abort the process instead of failing the test.
+    for index in 0..3 {
+        tx.try_send(PanicOnDrop { armed: index == 0 }).unwrap();
+    }
+    // `rx1` reads clones, which are disarmed; the armed originals stay retained for `rx2`.
+    for _ in 0..3 {
+        rx1.try_recv().unwrap();
+    }
+
+    let mut send = Box::pin(tx.send(PanicOnDrop { armed: false }));
+    assert!(poll_once(send.as_mut()).is_pending());
+
+    // Dropping `rx2` reclaims all three retained messages and their destructors panic. The
+    // capacity they released must already have reached the parked producer by then.
+    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(rx2)));
+    assert!(result.is_err());
+
+    assert!(
+        poll_once(send.as_mut()).is_ready(),
+        "a panicking payload destructor must not strand a producer on capacity it already freed"
+    );
+}
+
+#[test]
+fn bounded_message_destructors_run_outside_the_channel_lock() {
+    assert_completes_without_deadlock(|| {
+        let (tx, mut rx1) = bounded(8);
+        let rx2 = tx.subscribe();
+
+        for value in 0..4 {
+            tx.try_send(Reentrant {
+                value,
+                channel: Some(tx.clone()),
+            })
+            .unwrap();
+        }
+
+        // Reclaim through a receive, and then through receiver drops.
+        assert_eq!(rx1.try_recv().unwrap().value, 0);
+        drop(rx2);
+        assert_eq!(rx1.try_recv().unwrap().value, 1);
+        drop(rx1);
+
+        // With no receiver, both send paths discard the payload immediately.
+        tx.try_send(Reentrant {
+            value: 4,
+            channel: Some(tx.clone()),
+        })
+        .unwrap();
+        FutureExt::block_on(tx.send(Reentrant {
+            value: 5,
+            channel: Some(tx.clone()),
+        }));
+    });
+}
+
+#[test]
+fn cancelling_a_blocked_send_drops_its_payload_outside_the_channel_lock() {
+    assert_completes_without_deadlock(|| {
+        let (tx, mut rx) = bounded(1);
+        tx.try_send(Reentrant {
+            value: 0,
+            channel: None,
+        })
+        .unwrap();
+
+        let mut send = Box::pin(tx.send(Reentrant {
+            value: 1,
+            channel: Some(tx.clone()),
+        }));
+        assert!(poll_once(send.as_mut()).is_pending());
+        drop(send);
+
+        assert_eq!(rx.try_recv().unwrap().value, 0);
+        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
+        tx.try_send(Reentrant {
+            value: 2,
+            channel: None,
+        })
+        .unwrap();
+        assert_eq!(rx.try_recv().unwrap().value, 2);
+    });
+}
+
+// ---------------------------------------------------------------------------------------------
+// Concurrency
+// ---------------------------------------------------------------------------------------------
+
+#[test]
+fn dropping_the_last_receiver_never_strands_a_racing_producer() {
+    const ROUNDS: u64 = 150;
+    const PRODUCERS: u64 = 4;
+
+    // Bound each race independently of the total time spent starting threads across all rounds.
+    for round in 0..ROUNDS {
+        assert_completes_without_deadlock(move || {
+            let (tx, rx) = bounded(1);
+            tx.try_send(0).unwrap();
+            let start = Arc::new(Barrier::new(PRODUCERS as usize + 1));
+
+            let producers = (0..PRODUCERS)
+                .map(|producer| {
+                    let tx = tx.clone();
+                    let start = start.clone();
+                    thread::spawn(move || {
+                        start.wait();
+                        FutureExt::block_on(tx.send(round * 10 + producer + 1));
+                    })
+                })
+                .collect::<Vec<_>>();
+
+            // Race removal against senders entering the wait path after all workers are ready.
+            start.wait();
+            drop(rx);
+
+            for producer in producers {
+                producer.join().unwrap();
+            }
+        });
+    }
+}
+
+#[test]
+fn bounded_concurrent_producers_commit_one_order_seen_by_every_receiver() {
+    assert_completes_without_deadlock(|| {
+        const PRODUCERS: u64 = 4;
+        const PER_PRODUCER: u64 = 128;
+        const RECEIVERS: usize = 4;
+        const TOTAL: u64 = PRODUCERS * PER_PRODUCER;
+
+        // Capacity is below the message count, exercising backpressure while every subscription
+        // checks the same committed order rather than merely the same set of messages.
+        let (tx, rx) = bounded(8);
+        let mut receivers = vec![rx];
+        receivers.extend((1..RECEIVERS).map(|_| tx.subscribe()));
+
+        let drains = receivers
+            .into_iter()
+            .map(|mut receiver| {
+                thread::spawn(move || {
+                    let mut seen = Vec::with_capacity(TOTAL as usize);
+                    for _ in 0..TOTAL {
+                        seen.push(
+                            FutureExt::block_on(receiver.recv()).expect("sender dropped early"),
+                        );
+                    }
+                    seen
+                })
+            })
+            .collect::<Vec<_>>();
+
+        let producers = (0..PRODUCERS)
+            .map(|worker| {
+                let tx = tx.clone();
+                thread::spawn(move || {
+                    for value in 0..PER_PRODUCER {
+                        FutureExt::block_on(tx.send(worker * PER_PRODUCER + value));
+                    }
+                })
+            })
+            .collect::<Vec<_>>();
+
+        for producer in producers {
+            producer.join().unwrap();
+        }
+        drop(tx);
+
+        let orders = drains
+            .into_iter()
+            .map(|drain| drain.join().unwrap())
+            .collect::<Vec<_>>();
+
+        // Every subscription saw the identical sequence.
+        for (index, order) in orders.iter().enumerate().skip(1) {
+            assert_eq!(
+                order, &orders[0],
+                "subscription {index} observed a different committed order"
+            );
+        }
+
+        // And that sequence is every published value exactly once — no gap, no duplicate.
+        let mut sorted = orders[0].clone();
+        sorted.sort_unstable();
+        assert_eq!(sorted, (0..TOTAL).collect::<Vec<_>>());
+    });
+}
diff --git a/tests-integration/tests/traits_test.rs b/tests-integration/tests/traits_test.rs
index f421a63..00d44dc 100644
--- a/tests-integration/tests/traits_test.rs
+++ b/tests-integration/tests/traits_test.rs
@@ -104,8 +104,11 @@
     assert_send_and_sync::<RwLockWriteGuard<'_, i64>>();
     assert_send_and_sync::<broadcast::mpmc::UnboundedSender<i64>>();
     assert_send_and_sync::<broadcast::mpmc::UnboundedReceiver<i64>>();
+    assert_send_and_sync::<broadcast::mpmc::BoundedSender<i64>>();
+    assert_send_and_sync::<broadcast::mpmc::BoundedReceiver<i64>>();
     assert_send_and_sync::<broadcast::mpmc::RecvError>();
     assert_send_and_sync::<broadcast::mpmc::TryRecvError>();
+    assert_send_and_sync::<broadcast::mpmc::TrySendError<i64>>();
     assert_send_and_sync::<oneshot::SendError<i64>>();
     assert_send_and_sync::<oneshot::Sender<i64>>();
     assert_send_and_sync::<Closed>();
@@ -192,8 +195,11 @@
     assert_unpin::<RwLockWriteGuard<'_, i64>>();
     assert_unpin::<broadcast::mpmc::UnboundedSender<i64>>();
     assert_unpin::<broadcast::mpmc::UnboundedReceiver<i64>>();
+    assert_unpin::<broadcast::mpmc::BoundedSender<i64>>();
+    assert_unpin::<broadcast::mpmc::BoundedReceiver<i64>>();
     assert_unpin::<broadcast::mpmc::RecvError>();
     assert_unpin::<broadcast::mpmc::TryRecvError>();
+    assert_unpin::<broadcast::mpmc::TrySendError<i64>>();
     assert_unpin::<oneshot::Sender<i64>>();
     assert_unpin::<oneshot::SendError<i64>>();
     assert_unpin::<oneshot::Receiver<i64>>();