Module sync/channel

sync/channel
Stability: unstable — the shapes are settled and the ownership model just changed. `try_recv -> Result(T, TryRecvError)` landed in #506 and the blocking core (`send` / `recv` / `close` / `len`) has not moved since; the `Sender`/`Receiver` split landed 2026-09-11 and is what this release is asking for use on before freezing. Still missing, all additive: Rust's `recv_timeout`, an iterator over received values, and an unbounded variant (`plans/STD_API_STABILIZATION_FINDINGS.md` item 10). This is the BLOCKING channel — it parks OS threads and needs no `Io`. The async one is `std/async/channel`. — stable modules only change additively; this one may still change.

Bounded multi-producer multi-consumer (MPMC) channel. Uses blocking send/recv via condition variables — does not require Io.

Channel uses atomic object with atomic reference counting, so it can be safely shared across threads without Arc wrapping.

Example

{ Channel } :: import "std/sync/channel";
{ Thread } :: import "std/thread";

ch := Channel(i32).new(usize(10));
t := Thread(unit).spawn((io) => {
  ch.send(i32(42));
});
val := ch.recv();   // blocks until data available
assert((val.unwrap() == i32(42)), "received value");
t.join();

Two APIs over one queue, and why both exist

Channel(T) is the FUSED handle: one object that both ends hold, closed by hand. It is genuinely multi-consumer — several threads may recv from it — and that is what std/thread's pool internals and the cross-thread tests use it for, so it stays.

Sender(T)/Receiver(T) add the piece a fused handle cannot express: a LIFETIME. The queue counts its live senders, so dropping the last one closes the channel and a Receiver finds out that no more values are coming — no close() call to remember, and no consumer parked forever on a queue whose producers have all finished. Dropping the Receiver symmetrically makes every further send fail instead of blocking on a queue nobody will drain. The price is the single consumer: exactly one Receiver exists per queue, which is what makes "the receiver is gone" a fact worth acting on. The split is a layer ON TOP of Channel, sharing the same buffer, mutex and condvars.

{ Channel } :: import "std/sync/channel";

rx := Channel(i32).receiver(usize(4));   // queue + its one consumer
{
  tx := rx.sender();                     // a counted producer
  tx.send(i32(1));
};                                       // last sender dropped -> closed
rx.recv().unwrap();                      // 1
rx.recv();                               // .Err(Disconnected)

Producers on other threads

A Sender MOVED INTO a Thread.spawn closure disposes when the thread finishes, so it closes the channel like any other sender:

rx := Channel(i32).receiver(usize(16));
{
  tx := rx.sender();
  w := Thread(unit).spawn((io) => {
    tx.send(i32(7));                     // the capture is released when
  });                                    // the thread's body returns
  w.join();
};                                       // and tx itself drops here
rx.recv().unwrap();                      // 7
rx.recv();                               // .Err(Disconnected)

This did NOT work before 2026-09-11: a spawn closure's captures were never released at all, so such a sender's dispose never ran and the count never reached zero (issues/fixed/spawn-closure-captures-never-dropped-leak.md). Minting each producer's Sender inside its own thread, with one keeper in the parent, is still a fine pattern and is what you want when the senders outnumber the threads:

rx := Channel(i32).receiver(usize(16));
{
  keeper := rx.sender();                 // holds the count above zero
  w := Thread(unit).spawn((io) => {
    tx := rx.sender();                   // this thread's producer
    tx.send(i32(7));
  });                                    // tx drops when the body ends
  w.join();
};                                       // keeper drops -> closed

Stability

unstable — the shapes are settled and the ownership model just changed. try_recv -> Result(T, TryRecvError) landed in #506 and the blocking core (send / recv / close / len) has not moved since; the Sender/Receiver split landed 2026-09-11 and is what this release is asking for use on before freezing. Still missing, all additive: Rust's recv_timeout, an iterator over received values, and an unbounded variant (plans/STD_API_STABILIZATION_FINDINGS.md item 10).

This is the BLOCKING channel — it parks OS threads and needs no Io. The async one is std/async/channel.

Types

TryRecvError

Why a non-blocking receive came back empty-handed — Rust's TryRecvError.

try_recv used to answer Option(T), which collapsed these two into one .None: a caller could not tell "nothing yet, try again" from "the sender is gone, stop trying", so every polling loop over a closed channel spun forever or exited early by guessing.

Variants

VariantFieldsDescription
Empty

The channel is open but has nothing buffered right now. Retry later.

Disconnected

The channel is closed AND drained. No value will ever arrive; stop polling.

Trait Implementations

impl(generic(T : Type), where(T <: ToString), T : (ToString))
impl(generic(T : Type), where(T <: ToString), T : (ToString), Format)
format : fn(self : Self, spec : str) -> String

Render self under spec. An unrecognised spec degrades to the plain to_string() rendering rather than failing.

Parameters

NameTypeNotes
selfSelf
specstr

Returns: String

impl(TryRecvError, ToString(...))
to_string : (TryRecvError) fn(self : TryRecvError) -> String

Render self as the text a USER should read — Rust's Display::fmt, not its Debug. Hand-written (or generated by derive(Error) from a per-variant format string) whenever the structural form would be wrong.

Parameters

NameTypeNotes
selfTryRecvError

Returns: String

impl(TryRecvError, Eq(TryRecvError))
Methods
== : (TryRecvError) fn(lhs : TryRecvError, rhs : TryRecvError) -> bool

Parameters

NameTypeNotes
lhsTryRecvError
rhsTryRecvError

Returns: bool

!= : (TryRecvError) fn(lhs : TryRecvError, rhs : TryRecvError) -> bool

Parameters

NameTypeNotes
lhsTryRecvError
rhsTryRecvError

Returns: bool

Sender type-function
fn(T : Type) -> Type

The sending half of a Channel(T).receiver/pair split — Rust's mpsc::Sender<T>.

Cloneable: every clone is another producer over the SAME queue, and the queue counts them. When the LAST Sender is dropped the channel closes itself, which is the whole point of the split — a Receiver learns that no more values are coming without anyone remembering to call close(), and a blocked recv wakes up rather than parking forever.

atomic(ref(...)) for the same reason Channel is: the handle is meant to be moved into another thread, and its reference count has to be atomic for that to be sound.

Type Parameters

NameTypeNotes
TTypecomptime

Trait Implementations

impl(generic(T : Type), where(T <: (Send, Acyclic)), Sender(T), Acyclic())
impl(generic(T : Type), where(T <: (Send, Acyclic)), Sender(T), ...)
_attach : (fn(ch : Channel(T)) -> Self)

Attach the consumer to ch. Internal: Channel.receiver and Channel.pair are the only callers.

Returns: Self

send : (fn(self : Self, value : T) -> Result(unit, T))

Send a value, blocking while the buffer is full.

.Err(value) — Rust's SendError(T) — once the channel is closed or the Receiver has been dropped; the value that could not be sent comes back rather than being dropped on the floor.

Returns: Result(unit, T)

try_send : (fn(self : Self, value : T) -> Result(unit, T))

Send without blocking: .Err(value) if the buffer is full, the channel is closed, or the Receiver is gone.

Returns: Result(unit, T)

is_closed : (fn(self : Self) -> bool)

True once the channel is closed — including by the last Sender being dropped. Buffered values may still be waiting; try_recv answering Disconnected is the closed-AND-drained test.

Returns: bool

len : (fn(self : Self) -> usize)

Number of values currently buffered (a snapshot, like Channel.len).

Returns: usize

close : (fn(self : Self) -> unit)

Stop accepting values now. Buffered values still come out of recv, then it reports Disconnected.

Returns: unit

impl(generic(T : Type), where(T <: (Send, Acyclic)), Sender(T), Clone(...))
clone : (fn(inout(self) : Self) -> Self)

Create an independent clone of self.

Returns: Self

impl(generic(T : Type), where(T <: (Send, Acyclic)), Sender(T), Dispose(...))
dispose : (fn(self : Self) -> unit)

Release the resources self owns — a file descriptor, a socket, a lock, a buffer the allocator handed out. Called automatically when the last reference to the value goes away, so an implementor never calls it directly and must tolerate being the only one who ever does.

It must be safe to run exactly once: the runtime calls it at refcount zero, and a type that also exposes an explicit close/release is responsible for making the second call a no-op.

Returns: unit

Receiver type-function
fn(T : Type) -> Type

The receiving half of a Channel(T).receiver/pair split — Rust's mpsc::Receiver<T>.

Deliberately NOT cloneable, so that "the receiver is gone" is a fact a sender can act on. (The underlying Channel is MPMC and a second consumer is expressible by sharing the Channel itself; what a Receiver adds is the single-owner lifetime, and cloning it would take that away.)

Type Parameters

NameTypeNotes
TTypecomptime

Trait Implementations

impl(generic(T : Type), where(T <: (Send, Acyclic)), Receiver(T), Acyclic())
impl(generic(T : Type), where(T <: (Send, Acyclic)), Receiver(T), ...)
_attach : (fn(ch : Channel(T)) -> Self)

Attach the consumer to ch. Internal: Channel.receiver and Channel.pair are the only callers.

Returns: Self

sender : (fn(self : Self) -> Sender(T))

Mint a producer for this queue, counted like every other Sender.

This is how the first sender is created after Channel(T).receiver(...), and how a fan-in is set up: one sender() per producer, or one plus clone()s. Each of them keeps the channel open until it is dropped.

A sender minted after the channel has already closed does NOT reopen it — _closed is one-way — so its sends fail; mint the producers while the previous ones are still alive.

Returns: Sender(T)

recv : (fn(self : Self) -> Result(T, TryRecvError))

Receive the oldest value, blocking while the channel is empty.

.Err(TryRecvError.Disconnected) once the channel is closed AND drained — which happens on its own when the last Sender is dropped. It is the same TryRecvError try_recv reports rather than a second error type, and Empty is impossible here: a blocking receive that found the channel empty and open would have kept waiting.

Values already queued when the last sender went away are still handed out, one per call, BEFORE the disconnect is reported — closing a channel must not lose what it already accepted (Rust guarantees the same).

Returns: Result(T, TryRecvError)

try_recv : (fn(self : Self) -> Result(T, TryRecvError))

Receive without blocking: .Ok(value), Empty while the channel is open but has nothing buffered, Disconnected once it is closed and drained.

Returns: Result(T, TryRecvError)

is_closed : (fn(self : Self) -> bool)

True once the channel is closed — including by the last Sender being dropped. Buffered values may still be waiting; try_recv answering Disconnected is the closed-AND-drained test.

Returns: bool

len : (fn(self : Self) -> usize)

Number of values currently buffered (a snapshot, like Channel.len).

Returns: usize

is_empty : (fn(self : Self) -> bool)

True while nothing is buffered (a snapshot).

Returns: bool

close : (fn(self : Self) -> unit)

Stop accepting values now. Buffered values still come out of recv, then it reports Disconnected.

Returns: unit

impl(generic(T : Type), where(T <: (Send, Acyclic)), Receiver(T), Dispose(...))
dispose : (fn(self : Self) -> unit)

Release the resources self owns — a file descriptor, a socket, a lock, a buffer the allocator handed out. Called automatically when the last reference to the value goes away, so an implementor never calls it directly and must tolerate being the only one who ever does.

It must be safe to run exactly once: the runtime calls it at refcount zero, and a type that also exposes an explicit close/release is responsible for making the second call a no-op.

Returns: unit

Channel type-function
fn(T : Type) -> Type

Thread-safe bounded channel for passing values between threads. Uses atomic reference counting — implements Send and can be safely shared across threads without Arc wrapping. T must implement Send (values transfer between threads) and Acyclic (atomic reference counting is only sound for acyclic data).

Type Parameters

NameTypeNotes
TTypecomptime

Trait Implementations

impl(generic(T : Type), where(T <: (Send, Acyclic)), Channel(T), Acyclic())
impl(generic(T : Type), where(T <: (Send, Acyclic)), Channel(T), ...)
new : (fn(capacity : usize) -> Self)

Create a bounded channel with the given capacity. Capacity must be > 0.

Returns: Self

send : (fn(self : Self, value : T) -> Result(unit, T))

Send a value, blocking while the buffer is full.

.Err(value) — Rust's SendError(T) — once the channel is closed or the Receiver has been dropped; the value that could not be sent comes back rather than being dropped on the floor.

Returns: Result(unit, T)

recv : (fn(self : Self) -> Option(T))

Receive the oldest value, blocking while the channel is empty.

.Err(TryRecvError.Disconnected) once the channel is closed AND drained — which happens on its own when the last Sender is dropped. It is the same TryRecvError try_recv reports rather than a second error type, and Empty is impossible here: a blocking receive that found the channel empty and open would have kept waiting.

Values already queued when the last sender went away are still handed out, one per call, BEFORE the disconnect is reported — closing a channel must not lose what it already accepted (Rust guarantees the same).

Returns: Option(T)

try_send : (fn(self : Self, value : T) -> Result(unit, T))

Send without blocking: .Err(value) if the buffer is full, the channel is closed, or the Receiver is gone.

Returns: Result(unit, T)

try_recv : (fn(self : Self) -> Result(T, TryRecvError))

Receive without blocking: .Ok(value), Empty while the channel is open but has nothing buffered, Disconnected once it is closed and drained.

Returns: Result(T, TryRecvError)

close : (fn(self : Self) -> unit)

Stop accepting values now. Buffered values still come out of recv, then it reports Disconnected.

Returns: unit

_mark_receiver_dropped : (fn(self : Self) -> unit)

Record that this queue's Receiver handle is gone: every further send fails, and senders parked on a full buffer wake up to find out.

Internal — Receiver's dispose is the only caller. The flag is set and the waiters are woken with the mutex HELD, for the same reason close does it: a sender that has evaluated its wait predicate but not yet parked still holds the mutex, so a broadcast issued outside it could land before the park and be lost.

Returns: unit

is_closed : (fn(self : Self) -> bool)

True once the channel is closed — including by the last Sender being dropped. Buffered values may still be waiting; try_recv answering Disconnected is the closed-AND-drained test.

Returns: bool

len : (fn(self : Self) -> usize)

Number of values currently buffered (a snapshot, like Channel.len).

Returns: usize

is_empty : (fn(self : Self) -> bool)

True while nothing is buffered (a snapshot).

Returns: bool

impl(generic(T : Type), where(T <: (Send, Acyclic)), Channel(T), Dispose(...))
dispose : (fn(self : Self) -> unit)

Release the resources self owns — a file descriptor, a socket, a lock, a buffer the allocator handed out. Called automatically when the last reference to the value goes away, so an implementor never calls it directly and must tolerate being the only one who ever does.

It must be safe to run exactly once: the runtime calls it at refcount zero, and a type that also exposes an explicit close/release is responsible for making the second call a no-op.

Returns: unit

impl(generic(T : Type), where(T <: (Send, Acyclic)), Channel(T), ...)
receiver : (fn(capacity : usize) -> Receiver(T))

Create a bounded queue and its single consumer — the tuple-free half of the Sender/Receiver split. Mint producers with Receiver.sender() and Sender.clone().

{ Channel } :: import "std/sync/channel";
rx := Channel(i32).receiver(usize(4));
tx := rx.sender();
tx.send(i32(1));
v := rx.recv().unwrap();

This is the form to use when the end of the stream matters, because each handle is its own binding and therefore has its own lifetime: dropping the last Sender closes the channel while the Receiver is still alive to notice. pair below cannot do that — see its note.

Exactly one Receiver exists per queue by construction, which is what makes "the receiver is gone" a fact a sender can act on.

A Receiver that has no Sender yet is not closed — nothing has been dropped — so recv on it blocks, exactly as Channel.recv on a queue nobody sends to does. Mint the senders before receiving.

Returns: Receiver(T)

pair : (fn(capacity : usize) -> Tuple(Sender(T), Receiver(T)))

Create a bounded queue and hand back both halves at once — the shape of Rust's mpsc::sync_channel(capacity).

pair := Channel(i32).pair(usize(4));
tx := pair.0;
rx := pair.1;

The tuple keeps both handles alive for as long as the tuple binding lives (measured 2026-09-10). Yo has no destructuring binding, so pair stays in scope beside tx and rx and holds its own reference to each half; tx := pair.0 copies a handle rather than moving it out. The consequence is specific: dropping tx early does NOT drop the last sender, so the auto-close does not fire until the whole scope — the Receiver included — goes away. Use this form when the producers and the consumer share a scope and the count of values is known, or call close() explicitly; use receiver() + sender() when the receiver needs to learn that the producers are finished.

Returns: Tuple(Sender(T), Receiver(T))