Status
Accepted
Context
Slug has structured concurrency and thread nurseries, but no first-class mechanism for coordination across tasks/threads.
Slug values are immutable, making message passing a natural fit. We want:
- Cross-task communication without shared mutable state as the default
- Backpressure and safe shutdown (no unbounded queues by accident)
- Structured concurrency alignment: cancellation must unblock waiting operations
- Ergonomics with
matchandstruct: avoid sentinelniland boolean flags - A small, teachable surface area that remains compatible with a future VM
Decision
Introduce channels as the primary cross-task communication primitive and introduce select as an expression for waiting on multiple channel operations (and timeouts) without requiring additional “try” APIs.
Channel operations are provided via the slug.channel module to avoid global namespace collisions.
1) Channel API
Channels are created and used via runtime-provided functions in slug.channel:
var ch = import("slug.channel")
ch.chan() // rendezvous, capacity 0
ch.chan(n) // buffered, capacity n (n >= 0)
ch.send(ch, v) // blocking, cancellable
ch.recv(ch) // blocking, cancellable
ch.close(ch) // idempotent
Rules:
chan()defaults to capacity 0 (rendezvous). This is the explicit, backpressure-first default.chan(n)creates a buffered channel with capacityn.- Channels may be passed freely between tasks.
2) recv result shape: Full{value} and Empty
To integrate cleanly with struct and match, recv(ch) returns one of:
Full{ value }when a value is receivedEmptywhen the channel is closed and drained
Example:
match ch.recv(c) {
Full{value} => { ... }
Empty => { ... }
}
This avoids nil sentinels and avoids boolean “ok flags” while remaining easy to pattern match.
3) Close semantics
close(ch)is idempotent (closing an already-closed channel is a no-op).-
After
close(ch):recv(ch)continues to yield buffered values (if any), then yieldsEmptyforever.send(ch, v)is a RuntimeError (sending on a closed channel is a programmer error).
4) Cancellation semantics (structured concurrency contract)
All blocking operations must be cancellable:
- If the current task is cancelled while blocked in
send,recv, orselect, the operation aborts immediately and raises the task’s cancellation RuntimeError (consistent with existing task/nursery failure behavior).
This ensures nursery-driven cancellation reliably unblocks waiting operations and prevents deadlocks during shutdown.
5) select as an expression
Introduce select as an expression that waits for one of several cases to become ready and evaluates to the value produced by the selected case body.
Each select case produces a case value, which is then passed into the case body using Slug’s existing call-chaining (/>) semantics.
Syntax
var result = select {
recv c1 /> match {
Full{value} => value
Empty => :done
}
recv c2 /> println()
after 1000 /> println("timeout")
_ /> println("default")
}
Conceptually, a select expression chooses the first ready case, produces that case’s value, and pipes it into the associated body expression as a call chain (see ADR-001).
6) Case forms
select {
recv <chanExpr> /> <expr>
send <chanExpr>, <valueExpr> /> <expr>
after <milliseconds> /> <expr>
_ /> <expr>
}
Each case consists of:
- A case header, which determines readiness and produces a value
- A case body expression, which receives that value via
/>
Notes:
aftercurrently accepts a number representing milliseconds.
7) Case values and call-chaining semantics
Each select case produces a case value, which is passed into the case body using Slug’s existing call-chaining operator (/>).
Conceptually:
<case header> /> <fn> ...
is evaluated as:
fn(<case value>)
and the result of that call-chain becomes the value of the select expression.
Case value definitions
-
Receive case
recv cProduces the result of
ch.recv(c):Full{value}when a value is receivedEmptywhen the channel is closed and drained
-
Send case
send c, vProduces the evaluated value
vthat was sent. -
Timeout case
after nProduces the evaluated timeout value
n(milliseconds). -
Default case
_Produces
nil.
Case body contract
The expression following /> must evaluate to a callable function fn that accepts at least one parameter.
Evaluation proceeds as follows:
- The case header is evaluated and produces a case value.
- The expression following
/>is evaluated to a function valuefn. fnis invoked with the case value as its first argument.- The result of that invocation becomes the value of the
selectexpression.
If the expression after /> is:
- not callable, or
- callable but has arity 0
a RuntimeError is raised.
Examples
Valid:
recv c /> fn(msg) { ... }
after 500 /> fn(ms) { ... }
_ /> fn(_) { :default }
Using a named function value:
var handle = fn(msg) { ... }
var andThen = fn(msg) { ... }
select {
recv c /> handle /> andThen
}
Invalid:
recv c /> fn() { ... } // arity 0
recv c /> 123 // not callable
8) Readiness rules (case headers)
-
recv chis ready if:- a value is available, or
- the channel is closed (then it yields
Empty).
-
send ch, vis ready if:- there is buffer space available, or
- a receiver is waiting (capacity 0 rendezvous).
after nis ready oncenmilliseconds have elapsed._is used only when no other case is ready.
9) Selection when multiple cases are ready
If multiple cases are ready simultaneously, the runtime chooses one fairly:
- either randomized among ready cases, or
- round-robin with a rotating starting index.
The runtime must document which fairness strategy it uses; starvation-prone “first case always wins” behavior is not permitted.
10) Evaluation rules
- Channel expressions and send-value expressions are evaluated once per
selectevaluation. - Only the selected case body is executed.
- The value of that body becomes the value of the
selectexpression. recur()inside a case body behaves exactly as it does elsewhere;selectintroduces no special recursion semantics.
11) No trySend / tryRecv
Slug will not add trySend / tryRecv as separate APIs.
Non-blocking behavior is expressed via select with _:
var msg = select {
recv c /> match { Full{value} => value; Empty => Empty }
_ /> Empty
}
12) Language surface area
- Channel operations are provided via the
slug.channelmodule. selectintroduces a new expression form (select { ... }) and reserves the case markersrecv,send,after, and_only within aselectblock.
Consequences
Positive
- Provides a first-class, explicit mechanism for cross-task communication.
- Rendezvous-by-default enforces backpressure and avoids accidental queue growth.
Full{value}/Emptyintegrates cleanly withstruct+match.selectenables fan-in, timeouts, and coordinated shutdown without extra APIs.- Cancellation-aware blocking aligns tightly with structured concurrency and nursery cancellation.
Negative
selectadds grammar and implementation complexity.- Requires careful documentation of fairness and readiness semantics.
- Sending on closed channels is a RuntimeError and must be handled thoughtfully.
Neutral
- Channels become a core runtime capability but remain minimal and VM-portable.
- Buffered capacity and fairness strategy are runtime-defined but semantically stable.
- Higher-level abstractions (streams, actors) are explicitly left to libraries.