Module async/channel

async/channel
Stability: unstable — `Channel.recv` answers `Option(T)`, where `.None` means the channel closed, and whether that should be a `Result` the way `try_recv`'s answer now is remains open. (`Receiver.recv`, the split's consumer, IS already a `Result(T, TryRecvError)`.) The 1 ms timer tick is GONE as of 2026-09-11 — both sides are waker-based now — which changes handoff latency but no signature. `new`, `send`, `try_send`, `try_recv`, `close`, `is_closed` and `len` are intended to keep their names and meanings, as are `receiver`, `pair`, `Receiver.sender` and `Sender.clone`. — stable modules only change additively; this one may still change.

Async (event-loop) channel — deliver values BETWEEN TASKS on one thread's event loop without parking it (plans/archive/STD_API_AUDIT.md §7 P0 item 6 / D7). std/sync/channel's blocking recv() inside a future parks the entire single-threaded loop — every task, including the sender that would have unblocked it. This channel's send/recv are futures instead: the waiting task suspends and the loop keeps running.

NOT thread-safe — same-thread tasks only (no Send bound, no atomics, by design: Yo's async runtime is per-thread). Across OS threads use std/sync/channel.

A blocked send/recv is WAKER-BASED: the task publishes a waker on the side it is waiting for and suspends, and the other side wakes it when it makes progress — a recv that takes a value wakes one blocked sender, a send that buffers one wakes one blocked receiver, and close wakes everybody so they can observe the closure. So a hand-off costs a loop turn rather than a millisecond; the fast path (space or data already available) still never suspends at all. Until 2026-09-11 both sides re-checked on a 1 ms timer tick, which put a millisecond floor under every hand-off and capped a producer/consumer pair at ~1000 values per second (plans/backlog/WAKER_BASED_SCHEDULING.md stage 3).

{ Channel } :: import "std/async/channel";
ch := Channel(i32).new(usize(8));
producer := io.spawn(io.async((io : Io) => {
  _s := io.await(ch.send(i32(42), io), io);
  return(());
}), io);
v := io.await(ch.recv(io), io); // .Some(42)

Two APIs over one channel

Channel(T) is the FUSED handle both ends share, closed by hand. Sender(T)/Receiver(T) add a LIFETIME on top of it: the channel counts its live senders, so dropping the last one closes it and a suspended recv resolves to Disconnected instead of waiting for a value nobody will send. Dropping the Receiver makes every further send fail. Exactly one Receiver exists per channel, which is what makes "the receiver is gone" a fact a sender can act on.

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

A Sender captured by an io.async body is released when the task and its future are done, so a producer task's sender closes the channel like any other. The same is now true of a Thread.spawn capture on the blocking channel; it was not until 2026-09-11 (issues/fixed/spawn-closure-captures-never-dropped-leak.md).

Stability

unstable — Channel.recv answers Option(T), where .None means the channel closed, and whether that should be a Result the way try_recv's answer now is remains open. (Receiver.recv, the split's consumer, IS already a Result(T, TryRecvError).) The 1 ms timer tick is GONE as of 2026-09-11 — both sides are waker-based now — which changes handoff latency but no signature.

new, send, try_send, try_recv, close, is_closed and len are intended to keep their names and meanings, as are receiver, pair, Receiver.sender and Sender.clone.

Types

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

The sending half of an async channel pair — the same Sender/Receiver split std/sync/channel has, for tasks on one event loop.

Cloneable: every clone is another producer over the same channel, and the channel counts them. When the LAST Sender is dropped the channel closes itself, so a Receiver learns that no more values are coming without anyone remembering to call close() — a recv that would otherwise suspend forever resolves to Disconnected instead.

Type Parameters

NameTypeNotes
TTypecomptime

Trait Implementations

impl(generic(T : Type), 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, io : Io) -> Impl(Future(Result(unit, T))))

Send a value, suspending while the buffer is full. .Err(value) once the channel is closed or the Receiver is gone (Rust's SendError(T)).

Returns: Impl(Future(Result(unit, T)))

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

Send without suspending: .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.

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), Sender(T), Clone(...))
clone : (fn(inout(self) : Self) -> Self)

Create an independent clone of self.

Returns: Self

impl(generic(T : Type), 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 an async channel pair.

Deliberately NOT cloneable, so that "the receiver is gone" is a fact a sender can act on; Channel(T).receiver mints exactly one per channel.

Type Parameters

NameTypeNotes
TTypecomptime

Trait Implementations

impl(generic(T : Type), 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 channel, counted like every other Sender.

This is how the first sender is created after Channel(T).receiver(...) and how a fan-in of producer tasks is set up. A sender minted after the channel has already closed does not reopen it — its sends fail.

Returns: Sender(T)

recv : (fn(self : Self, io : Io) -> Impl(Future(Result(T, TryRecvError))))

Receive the oldest value, suspending 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. Same TryRecvError as try_recv rather than a second error type, and Empty is impossible here: a suspending receive that found the channel empty and open would have kept waiting.

Values already buffered when the last sender went away are still handed out, one per call, BEFORE the disconnect is reported.

Returns: Impl(Future(Result(T, TryRecvError)))

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

Receive without suspending: .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.

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), 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

A bounded FIFO channel for same-thread async tasks.

Type Parameters

NameTypeNotes
TTypecomptime

Trait Implementations

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

A channel holding at most capacity buffered values (must be > 0).

Returns: Self

send : (fn(self : Self, value : T, io : Io) -> Impl(Future(Result(unit, T))))

Send a value, suspending while the buffer is full. .Err(value) once the channel is closed or the Receiver is gone (Rust's SendError(T)).

Returns: Impl(Future(Result(unit, T)))

recv : (fn(self : Self, io : Io) -> Impl(Future(Option(T))))

Receive the oldest value, suspending 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. Same TryRecvError as try_recv rather than a second error type, and Empty is impossible here: a suspending receive that found the channel empty and open would have kept waiting.

Values already buffered when the last sender went away are still handed out, one per call, BEFORE the disconnect is reported.

Returns: Impl(Future(Option(T)))

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

Send without suspending: .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 suspending: .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 channel's Receiver handle is gone: every further send fails, and a task suspended on a full buffer finds out on its next tick. Internal — Receiver's dispose is the only caller.

Returns: unit

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

Wake the longest-waiting receiver, if any. Called wherever a value becomes available.

Returns: unit

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

Wake the longest-waiting sender, if any. Called wherever a slot frees up.

Returns: unit

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

Wake every suspended sender and drop the queue.

Returns: unit

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

Wake every suspended task on both sides.

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.

Returns: usize

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

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

rx := Channel(i32).receiver(usize(4));
tx := rx.sender();
_s := io.await(tx.send(i32(1), io), io);
v := io.await(rx.recv(io), io).unwrap();

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

A Receiver with no Sender yet is not closed, so recv on it suspends; mint the senders before receiving.

Returns: Receiver(T)

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

Create a channel and hand back both halves at once — the shape of Rust's mpsc::sync_channel(capacity) (this channel is bounded, like the blocking one).

The tuple keeps both handles alive for as long as the tuple binding lives (measured 2026-09-10): Yo has no destructuring binding, so tx := pair.0 copies a handle out rather than moving it, and the tuple holds its own reference to each half. Dropping tx early therefore does NOT drop the last sender, and the auto-close fires only when the whole scope — Receiver included — goes away. Use this form when the producer tasks and the consumer share a scope, or call close() explicitly; use receiver() + sender() when the receiver must learn that the producers are finished.

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

impl(generic(T : Type), Channel(T), Stream(...))
Item : T
next : (fn(self : Self, io : Io) -> Impl(Future(Option(T), Io)))

Yield the next item, or .None once the stream is finished.

Parameters

NameTypeNotes
ioIo

Returns: Impl(Future(Option(T), Io))

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

Methods
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

== : (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