By Christian Findlay

Fibers and Concurrency

The native target provides Fiber<T> computations and buffered Channel<T> communication. A normal native fiber is backed one-to-one by a pthread; Osprey does not expose a separate OS-thread API. Managed values captured by spawn or sent through a channel may be co-owned across threads as specified by fiber boundary ownership. The WebAssembly target excludes this pthread runtime.

Default and ML syntax lower these forms to the same Expr::Spawn, Expr::Await, Expr::Yield, Expr::Send, and Expr::Recv nodes. See ML Flavor Syntax for ML parsing.

Sleep [CONCURRENCY-SLEEP]

sleep(milliseconds: int) -> Unit blocks the current native thread for the given number of milliseconds. A duration less than or equal to zero returns immediately. The runtime's integer status is not exposed.

Spawn and await [CONCURRENCY-SPAWN-AWAIT]

spawn expression captures the expression's free values, schedules it, and returns Fiber<T>, where T is the expression type. await fiber blocks until that computation finishes and returns its T value. Fiber<T> has no public record constructor; spawn is the construction operation.

fn work(value: int) -> int = value + 1

let task = spawn work(41)
let answer = await(task) ?: 0
work : int -> int
work value = value + 1

task = spawn (work 41)
answer = (await task) ?: 0

await returns the fiber's complete source type and never erases a Result channel. A fiber is inside the arithmetic totality guarantee: arithmetic in a spawned body cannot trap or wrap silently, and a fault reaches the enclosing Arith handler through the serialized [EFFECTS-FIBER-PERFORM] round trip, or the program is rejected (ARITH-TOTAL). Each spawn site allocates a distinct capture cell. Pointer and floating-point values likewise return with their source type.

A Fiber<T> handle is reusable: awaiting the same completed fiber more than once MUST return the same T value on every call. For a managed T, every await produces an independently owned reference. One caller releasing its value MUST NOT invalidate a later await. Once every spawned computation is quiescent, normal program teardown MUST release the runtime's completed-result roots after language-owned values have dropped; it MUST NOT release a cached result while another fiber can still await it.

Buffered channels [CONCURRENCY-CHANNEL]

Channel(capacity) creates a FIFO channel whose positive integer capacity is the number of buffered values. Zero and negative capacities are rejected; the runtime does not implement rendezvous channels.

Operation Type Behavior
Channel(capacity) int -> Channel<T> Create a positive-capacity buffer; T is inferred from use.
send(channel, value) (Channel<T>, T) -> Unit Block while the buffer is full, then append value.
recv(channel) Channel<T> -> T Block while the buffer is empty, then remove its oldest value.

A channel handle is MONOMORPHIC in T. Binding one with let does not generalize its element type, so every send and recv on that handle agrees on one T; sending a string and receiving an int from the same channel is a type error, not two independent instantiations. Fiber<T> is fixed the same way by the thunk that produced it.

recv returns T with its FULL representation, and the guarantee does not depend on how the handle was reached: a channel read through a let binding, an alias, a function parameter or a function return all deliver the same value. A nested List<List<U>> comes back whole rather than as its outer shape with the element type erased, and a collection crosses the wire in the runtime representation a receiver can read — a backend may not put a construction-time layout on a channel that only its own scope knows how to interpret.

One route does NOT carry T, and it is REJECTED rather than guessed at: a handle stored in a field whose DECLARED type is a type variable — type Box<t> = Box { slot: t } — reaches recv with nothing to unbox by, because the declaration is all the field read has. recv and await refuse such a program and name the field. They used to fall back to the uniform wire word, which is a plausible WRONG VALUE and not an error: a Channel<List<List<int>>> read out of such a field answered its outer shape as an integer, so reading a row out of it produced 0 where the answer was 3 — exit status 0, no diagnostic, nothing to notice. The same rule and the same refusal apply to Fiber<T>.

A managed T handed to send is OWNED by the channel until a recv takes it. A send the runtime rejects owns nothing and releases the value again; a value still buffered when the program ends is released at teardown. Neither an unreceived send nor a rejected one may leave a live object behind — the ARC corpus is run with leak accounting armed and holds every program to zero.

send is not a Result: its native status is internal to the runtime call and the language expression evaluates to Unit. recv directly returns T. A backend MUST preserve a Result<U, E> element as a complete value; a backend without a shape-aware channel ABI MUST reject Channel<Result<U, E>> at compilation. It must never unwrap or reinterpret the channel element.

let channel = Channel(3)

let producer = spawn {
    send(channel, 1)
    send(channel, 2)
    send(channel, 3)
}

let consumer = spawn {
    print("got ${recv(channel)}")
    print("got ${recv(channel)}")
    print("got ${recv(channel)}")
}

await(producer)
await(consumer)
channel = Channel 3

producer = spawn
    send channel 1
    send channel 2
    send channel 3

consumer = spawn
    print "got ${recv channel}"
    print "got ${recv channel}"
    print "got ${recv channel}"

await producer
await consumer

Yield and completion [CONCURRENCY-YIELD]

yield value evaluates value, offers the current pthread's remaining time slice to the scheduler, and returns the same value with the same type. Bare yield returns Unit. In deterministic mode the scheduler hand-off is skipped, but the value is still returned. The legacy fiber_yield(int) -> int built-in uses the same runtime operation.

fiberDone(fiber: Fiber<T>) -> int is a non-blocking completion probe. It returns 1 after completion, 0 while a normal threaded fiber is running, and the native runtime returns -1 for an invalid handle.

Deterministic execution [CONCURRENCY-DETERMINISTIC]

Native code may declare the runtime control function:

extern fn fiber_set_deterministic_mode(enabled: bool) -> int

When enabled before spawning, spawn queues computations without creating pthreads. await executes queued computations in spawn order through the requested fiber. This mode is sequential, not an interleaving scheduler; yield therefore cannot switch fibers. fiberDone returns 1 for a queued fiber because its following await is what drives execution.

Cancellation and scopes — design direction [CONCURRENCY-CANCEL-DESIGN]

The shipped runtime has no cancellation: a spawned fiber always runs to completion, await blocks until it does, and fiberDone is the only probe. The normative target that changes this — lexical scopes that own their fibers, cancel delivered only at suspension points, finally finalizers, join returning an Outcome<T>, and deadline/racing forms — is specified in Structured Concurrency and delivered by plan 0026. Until that plan lands, nothing in this section's target exists in the compiler.

Reserved select syntax [CONCURRENCY-SELECT-REJECT]

Both parsers reserve and lower select, but channel selection has no runtime semantics. The type checker rejects every select expression with `select` is not supported before code generation. Use explicit send, recv, and await operations.