Sender::send in src/lib.rs contains an unsafe block in the DISCONNECTED arm that transmutes a raw pointer (*mut Producer<T>) into the bytes of a value-level Consumer<T>. The author's intent, visible in the surrounding comment at lines 386-390, was a value transmute. The shipped code is one level of indirection off.
The resulting Consumer<T> has its internal Arc::ptr set to the address of the producer field on the Sender, not the real ArcInner<Buffer<T>>. Every subsequent consumer.try_pop() walks Buffer<T> fields at offsets that lie inside the Sender<T> struct (over send_new, inner) and adjacent memory, an out-of-bounds read. When the fake Consumer<T> is dropped at the end of the unsafe block, its Drop calls Arc::drop_in_place on a non-ArcInner address: it decrements bytes that the type system treats as strong_count: AtomicUsize but that are actually the real Arc::ptr value of the Sender, and at zero count it calls dealloc(Layout::for_value(...)) on an address the allocator never returned.
Reachable from 100% safe Rust through the canonical channel pattern: a tx.send(msg) that races with rx.drop(). This is consistent with the SIGSEGV that issue #3 reports in your own test suite.
23a9ce7)```rust
// src/lib.rs:384-401
DISCONNECTED => {
self.inner.counter.store (DISCONNECTED, Ordering::SeqCst);
// We want to guarantee if a message was not received that we get it
// back; since spsc::{Producer,Consumer} have the same
// internal representation (as a singleton struct containing Arc
//
In bounded-spsc-queue-0.4.0, both Producer
The author's intent (per the comment at lines 386-390) was a value-level transmute:
let producerval: spsc::Producer
Reachability The branch is not reachable single-threaded. Receiver::drop (line 332) stores connected = false before setting counter = DISCONNECTED; Sender::send (line 359) early-returns on connected == false. The trigger is a TOCTOU race:
Sender's self.inner.connected.load(SeqCst) reads true. Receiver-drop runs: stores connected = false and counter.compareexchange(, DISCONNECTED, SeqCst, SeqCst). Sender's self.inner.counter.fetch_add(1, SeqCst) (line 379) sees DISCONNECTED and enters the unsafe block. Under heavy contention this reproduces ~3/10 trials in release mode.
Proof of concept (race shape) // Cargo.toml: unbounded-spsc = "0.2" use std::thread; use unbounded_spsc::channel;
fn main() {
for trial in 0..500 {
let (tx, rx) = channel::
Release-mode (no sanitizer): Segmentation fault (core dumped) reliably within a few trials. The non-segfaulting trials are masked by the separate sendnew.send(newconsumer).unwrap() panic, see Secondary defect below. -Zsanitizer=address -Zbuild-std (nightly): ASan reports stack-buffer-overflow / stack-use-after-scope from the fake-Consumer's try_pop walking off the Sender frame. This matches the SIGSEGV reported in your own issue #3.
Smoking-gun upstream evidence src/lib.rs:975 in the project's test suite carries a TODO:
// TODO: failures // - failed with assertion on line 394 in send fn // assert!(second.isnone()) That is the assertion site of the transmute block (line 396 in 0.2.0 / master). You have observed trypop() returning a non-None value where logically there should be none, which is exactly what reading random bytes from the Sender's send_new / inner fields produces, and the symptom has been marked as a flaky test rather than recognised as UB.
Impact Reachable from 100% safe Rust. Concrete UB primitives:
OOB read of bytes adjacent to the Sender
self.sendnew.send(newconsumer).unwrap();
When the Sender's message queue is full, a fresh boundedspscqueue::Channel is allocated and the new Consumer
The fix is to return Err(SendError(t)) instead of unwrapping, same shape as the channel-closed result the function already returns on the connected-false path. This is not a memory-safety defect, only a panic, but it lives on the same TX/RX-race code path and a single coordinated patch can address both. Filing it here so we cover the full call site in one cycle.
Suggested patch (primary defect) Replace the pointer-as-value transmute with a value-level read and a ManuallyDrop to suppress the alias's Producer::drop on subsequent exit:
unsafe { use core::mem::ManuallyDrop;
// Sound value-level transmute: Producer<T> and Consumer<T> are both
// newtypes around Arc<Buffer<T>>, so the value layouts match.
// ptr::read takes ownership of the Producer's bytes without running
// Producer's Drop.
let producer_val: spsc::Producer<T> = std::ptr::read(self.producer.get());
let consumer : spsc::Consumer<T> = std::mem::transmute(producer_val);
let first = consumer.try_pop();
let second = consumer.try_pop();
assert!(second.is_none());
if let Some(t) = first {
return Err(SendError(t));
}
// consumer drops here; the same memory backs `producer`, so suppress
// the double Producer drop:
let _ = ManuallyDrop::new(consumer);
} Cleaner: restructure Sender<T> to hold producer and consumer in a private enum Endpoint<T> so no transmute is required, or use the boundedspscqueue::Producer<T>::reclaim() escape hatch if available.
Suggested patch (secondary defect) if let Err(std::sync::mpsc::SendError()) = self.sendnew.send(new_consumer) { // Receiver has been dropped: take the message back as the public // SendError, the same way the connected==false early-return does. return Err(SendError(t)); } Regression test (release-mode, race shape)
fn racedisconnectdoesnotcorruptsenderorabort() {
for _ in 0..200 {
let (tx, rx) = unboundedspsc::channel::
Researcher Berkant Koc me@berkoc.com PGP: 0C588DFD76204987284213EA0AC529C41F8AA5D6
{
"github_reviewed_at": "2026-05-29T19:05:21Z",
"severity": "MODERATE",
"cwe_ids": [
"CWE-125",
"CWE-415",
"CWE-704",
"CWE-787"
],
"github_reviewed": true,
"nvd_published_at": null
}