fuwaroid

    Lightweight single-writer concurrency

    fuwaroid
    actor
    concurrency
    async
    moonbitlang
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    7 hours ago
    Downloads
    71

    Dependencies

    #colmugx/fuwaroid

    Lightweight actor-style concurrency primitives for moonbitlang/async: private state, a typed mailbox, and one serial loop. The host owns the lifecycle; the library owns the mailbox semantics — nothing else. The runtime underneath is single-threaded and cooperative: many Fuwaroids make concurrent progress, which is not multicore parallelism.

    #Why "Fuwaroid"

    ふわり (fuwari) is the Japanese mimetic word for the way something weightless moves and settles — a feather alighting, no impact, no weight. -oid makes it a creature of that quality (as in android, humanoid). A Fuwaroid is thus "a thing that floats lightly".

    That is precisely this library's concurrency. Tasks never grip OS threads and never block — they float suspended and drift between suspension points. A Fuwaroid is one such light entity: a little state and a mailbox. It sleeps weightlessly until a message arrives (queue.get() suspends), settles the message in one synchronous step, and drifts back to sleep. No locks, no blocking, no machinery — and the library itself stays featherweight: two small files over one dependency.

    #The model

    tell(cmd) ───────────────┐ ask(query) ───────────┐ callers │ (with timeout) │ ▼ ▼ ┌──────────────────────────────────────┐ host task group ─────────►│ mailbox (typed, FIFO, bounded or not)│ (owns the lifetime) └──────────────────┬───────────────────┘ │ one serial loop ▼ yields every 32 complete messages state = on_cmd(state, cmd) ← sync (state, reply) = on_query(state, query) ← sync │ async work: ctx.group.spawn_bg(work) … work finishes → ctx.address.tell(result)

    Commands and queries are separate types — on_cmd folds state, on_query reads it and replies — so neither handler carries unreachable arms for the other flow.

    #Guarantees

    1. Handlers are synchronous by signature. A callback runs to return without another mailbox message interleaving it, so the loop applies each returned state in one serial step. Because handlers are plain synchronous functions and ask is an async method, the compiler rejects an ask call from inside a handler — report results back with tell instead. The generic API cannot prove that State has no aliases or that a callback is pure; callers must keep actor state owned by the loop and avoid mutating it from spawned work or replies.
    2. Fair scheduling happens between complete messages. After every fuwaroid_yield_batch (32) complete messages the loop yields the scheduler, so an instance that keeps its own mailbox non-empty cannot monopolize the cooperative runtime: other instances, timers and cancellation all get scheduled. This is a scheduling opportunity, not a real-time guarantee: no millisecond bound is promised, and one long CPU-bound handler still blocks the whole runtime for its duration (measured in docs/concurrency-benchmarks.md §2.5: a 2,000-iteration arithmetic handler drops end-to-end throughput from 1.87M to 423k msg/s).
    3. Async work lives outside the Fuwaroid. Spawn it on the host group from a handler; report the outcome back with tell. The loop is the only place that applies a handler's returned state. The library does not guarantee reliable delivery of background results: a report that arrives after close, or into a full bounded mailbox, is refused, and the host must collect that refusal itself (see Background work).
    4. Lifecycle: close, cancel, join. close is idempotent: it refuses new messages and drains the already-queued ones in FIFO order, then the loop exits; close returning does not mean the drain completed. Host cancellation outranks draining: the loop stops at the next cancellation boundary instead of finishing the backlog. join waits for the loop task to terminate and returns the recorded StopReason; close(); join() is the way to confirm a graceful drain.
    5. Stranded asks fail fast. If the loop stops abnormally (host cancellation or another terminal mailbox error), asks that were already accepted get Stopped immediately instead of hanging until their timeout. Cancellation of the asker itself propagates unchanged.
    6. Backpressure is explicit. The mailbox is this library's own Mailbox: Unbounded, or Bounded(n) whose sends are refused synchronously with MailboxFull — nothing is silently dropped, and there is no drop-style mailbox. An invalid capacity (Bounded(0) or a negative value) aborts at spawn time.

    These semantics are also the forward story for MoonBit's eventual multithreaded runtime: serial processing plus message-only communication is exactly the shape that survives real threads unchanged.

    #API

    SymbolMeaning
    Fuwaroid::spawn(group~, init~, on_cmd~, on_query~, mailbox?)Start a Fuwaroid on the host's long-lived task group. mailbox? defaults to Mailbox::Unbounded.
    Fuwaroid::spawn_fold(group~, init~, on_cmd~, mailbox?)Command-only variant; the handle is Fuwaroid[Cmd, Unit, Unit] and ask(()) degenerates to a served-receipt barrier.
    f.tell(cmd)Fire-and-forget; Result[Unit, SendRefusal] right after the mailbox decision.
    f.ask(query, timeout_ms~)Request-response; TimedOut / NotDelivered / Stopped failures; caller cancellation propagates.
    f.close()Graceful mailbox stop: refuse new, drain queued, exit the loop. Idempotent; returns before the drain completes.
    f.join()Wait for the loop task to terminate; returns StopReason (Graceful, Cancelled, or Failed(Error)). Multi-waiter safe, immediate once terminated. Only waits for the loop — never for or against background work.
    f.snapshot()Synchronous, read-only diagnostic counters (Snapshot); never raises, never suspends.
    Mailbox, StopReason, Snapshot, SendRefusal, AskFailureConfiguration and result vocabulary, all owned by this library.
    Ctx { group, address }All a handler may touch: the host group and its own address. A handler cannot call ask at all (handlers are synchronous, ask is async); reporting uses tell.
    Supervisor(group~)Bind a supervisor to an existing host task group of any result type. No runtime is created; the host stays the structured-concurrency owner. Opt-in and fully separate from every Fuwaroid path.
    sup.spawn(f, label?)Spawn an owned task on the host group; Result[SupervisedTask[X], SpawnRefusal] — refusal is explicit once shutdown began, never a silent drop.
    handle.cancel()Cooperative cancellation REQUEST (Running -> Cancelling, idempotent, forward-only). Returning does not mean the task stopped.
    handle.wait()Result delivery delegated to Task::wait: the value, the ORIGINAL error, or @async.TaskCancelled for a cancelled target. Multi-waiter and late-wait safe.
    handle.snapshot()Synchronous { id, label, status, error_text } view.
    sup.cancel_and_wait(tasks~, timeout_ms~)Cancel the SELECTED tasks and wait for exactly them under ONE overall deadline: Settled, or DeadlineExceeded(snapshots) with the unresolved tasks in their true status. Unselected tasks and admission are untouched.
    sup.shutdown(timeout_ms~)Close admission forever, then cancel and bounded-settle everything live. Idempotent; a deadline exceeded can be retried and finally reports Settled.
    sup.snapshot()Synchronous { lifecycle, tasks } view (Open / Closing / Closed).
    SupervisedTaskStatus, SettleOutcome, SupervisorLifecycle, SupervisedTaskSnapshot, SupervisorSnapshot, SpawnRefusalSupervisor vocabulary, all owned by this library.

    #Usage

    // A counter Fuwaroid with a query flow.
    enum CounterCmd {
    Add(Int)
    }

    enum CounterQuery {
    GetCount
    }

    struct Count {
    mut n : Int
    }

    async test "counter" {
    @async.with_task_group(group => {
    let f = @fuwaroid.Fuwaroid::spawn(
    group~, // the host's long-lived task group — never a per-call one
    init=Count::{ n: 0 },
    on_cmd=(_, state, cmd) => {
    match cmd {
    CounterCmd::Add(k) => { state.n = state.n + k; state }
    }
    },
    on_query=(_, state, _query) => (state, state.n),
    )
    let _ = f.tell(CounterCmd::Add(2))
    // FIFO: the reply certifies Add(2) was already served.
    let count = f.ask(CounterQuery::GetCount, timeout_ms=1000) // Ok(2)
    debug_inspect(count, content="Ok(2)")
    })
    }

    #Background work

    The async-work pattern (the reason this library exists). A refused report is kept as host-owned evidence — the library does not guarantee reliable delivery of background results, and a refusal must never be retried into the same (possibly closed or full) mailbox:

    enum WorkCmd {
    StartWork
    Report(Int)
    }

    fn run_delegation() -> Int {
    42 // stand-in for IO, a subprocess, anything slow
    }

    async test "background work" {
    let refusals : Array[@fuwaroid.SendRefusal] = []
    @async.with_task_group(group => {
    let f = @fuwaroid.Fuwaroid::spawn_fold(
    group~,
    init=0,
    on_cmd=(ctx, state, cmd) => {
    match cmd {
    StartWork => {
    ctx.group.spawn_bg(() => {
    let outcome = run_delegation() // IO, subprocess, anything slow
    match ctx.address.tell(Report(outcome)) {
    Ok(_) => () // admitted; a later message folds it in
    // Refused: record it somewhere the host owns. The report is
    // lost — state changes only via messages that are admitted.
    Err(refusal) => refusals.push(refusal)
    }
    })
    state // unchanged here — the report arrives as a message
    }
    Report(n) => state + n
    }
    },
    )
    let _ = f.tell(StartWork)
    f.close()
    let _ = f.join() // Graceful — the drain completed
    debug_inspect(refusals, content="[MailboxClosed]")
    })
    }

    After close, background work still runs under the host group's lifetime; its late tell observes Err(MailboxClosed) — exactly the refusal the example collects. join neither waits for nor cancels such work.

    #Lifecycle: close, cancel, join

    async test "close then join" {
    @async.with_task_group(group => {
    let f = @fuwaroid.Fuwaroid::spawn_fold(
    group~,
    init=0,
    on_cmd=(_, state, _cmd) => state + 1,
    )
    let _ = f.tell(1)
    f.close() // refuse new; drain what is left; the loop then exits
    let reason = f.join() // wait for the loop task and learn why it ended
    debug_inspect(reason, content="Graceful")
    })
    }

    • close is idempotent and returns before the drain completes; the loop drains the backlog in FIFO order, yielding between batches while doing so.
    • join returns the recorded StopReason: Graceful after a closed-and-empty drain, Cancelled when the host cancelled the loop, and Failed(original error) after an ordinary terminal error. In moonbitlang/async 0.22.x cancellation is a runtime signal of its own, distinct from Error — Fuwaroid never wraps it into a fake error. The cancelled loop task ends cancelled (an external Task::wait observer sees @async.TaskCancelled), and join reports the recorded Cancelled. Multiple waiters are fine, and a join after termination returns immediately. If the waiter itself is cancelled, that cancellation propagates unchanged, including when the loop has already terminated.
    • Abnormal cleanup closes admission and enters Closing, then abandons queued messages in batches of 32 under cancellation protection. It yields between batches so other tasks can run; join completes only after all stranded reply queues are closed and the stop reason is recorded.
    • Cancellation outranks draining. The loop is spawned no_wait on the host group. close() followed by an immediate return from the host scope cancels the still-running loop — the backlog is NOT fully processed and join (observed from another scope) reports Cancelled. To confirm a graceful drain, close();join() before leaving the scope.
    • An ask issued just before close proves ordering by FIFO: its reply certifies every message enqueued before it was already served.

    #Supervisor: bounded task settlement

    Supervisor is introduced in Fuwaroid 0.2.0.

    Supervisor is a separate, lightweight structured-concurrency utility that lives next to Fuwaroid, not inside it:

    Fuwaroid serializes mutable state. Supervisor owns cancellable async work. Neither is an actor system.

    It answers one concrete lifecycle need: cancel these tasks, then bound this settlement operation by one overall deadline, reporting any stragglers in their real status instead of waiting on each selected task indefinitely. This is the seam a cleanup path needs when it would otherwise end with an unbounded cancel -> Task::wait sequence inside a cancellation-shielded scope.

    async test "supervisor settles a selected foreground set" {
    @async.with_task_group(group => {
    let sup = @fuwaroid.Supervisor(group~)
    let foreground = []
    for i in 0..<2 {
    match sup.spawn(() => i, label="fg\{i}") {
    Ok(handle) => foreground.push(handle)
    Err(_) => abort("open supervisor refuses nothing")
    }
    }
    // Background work is spawned the same way and simply never selected;
    // it keeps running while the foreground set settles.
    let outcome = sup.cancel_and_wait(
    tasks=foreground.clamped_view(),
    timeout_ms=5000,
    )
    debug_inspect(outcome, content="Settled")
    // Later: stop owning tasks altogether.
    debug_inspect(sup.shutdown(timeout_ms=5000), content="Settled")
    })
    }

    Semantics that matter:

    • Ownership without ceremony. A supervisor is bound to an existing host TaskGroup and creates no runtime. Spawned tasks are real children of the host group: when the host scope ends, remaining tasks are cancelled by the group exactly like any other child. Ordinary failures keep the host group's own fail-fast behavior — the supervisor never swallows them.
    • cancel is a request, not a verdict. It records Cancelling synchronously and idempotently, and never demotes a terminal status. The race outcome is honest: a worker that finishes before observing the request ends Completed (or Failed), not Cancelled.
    • wait is Task::wait. Results, original error identity and @async.TaskCancelled for a cancelled target come straight from the underlying task; handles stay waitable after the supervisor's live registry has reaped their task, with any number of waiters.
    • One deadline for the whole settlement operation. cancel_and_wait computes deadline = now + timeout_ms once; every selected task shares it. Waiting for N tasks never costs N × timeout inside Supervisor. On expiry, DeadlineExceeded carries snapshots of exactly the still-nonterminal selected tasks in their TRUE status (Running / Cancelling) — it never fabricates a terminal status, never clears the live registry, and the stragglers may still terminate later.
    • The deadline works from shielded contexts. Settlement runs in a local task group of waiter helpers plus one timer, so it never relies on "cancel the caller to stop the wait" — it is safe to call from cleanup code running under @async.protect_from_cancel. Cancelling the settlement caller tears down only the helpers; a waiter helper's cancellation never reaches its target.
    • Shutdown is admission control plus settlement. The first shutdown closes admission synchronously and forever (Open ->Closing -> Closed; spawn refuses with SupervisorClosed from that moment), then cancels and bounded-settles exactly the tasks that were live at entry. Repeated shutdowns are safe; after a deadline exceeded they can wait again for the stragglers, and the registry converges by itself once those terminate.

    Structured-exit limitation. Supervisor bounds its own settlement operation; it does not detach tasks from the host TaskGroup and cannot weaken that group's structured-exit rule. If unresolved supervised tasks remain when the owning with_task_group body returns, the host group will still wait for those children to terminate. A DeadlineExceeded result therefore does not mean the owning task group itself has a bounded exit time.

    Cooperative-runtime limitation — read this before relying on the deadline. MoonBit's async runtime is single-threaded and cooperative: cancellation and timers are observed only at suspension points, and a worker that never yields cannot be interrupted at all. Bounded settlement is therefore not hard real-time preemption, and nothing can force-kill a coroutine. What Supervisor guarantees: as long as the scheduler still gets execution opportunities, waiting on a suspended or cancellation-nonresponsive task does not block the lifecycle forever — the deadline expires and the stragglers are reported as they are.

    Not an actor framework: no actor registry, no supervision tree, no restart, no linking, no remote addressing — and no application metadata (session ids, receipts, business tags) inside the supervisor. A task is id (unique and monotonic within the supervisor's lifetime), an optional label, and a lifecycle status; that is the whole identity.

    #Mailbox configuration

    async test "bounded mailbox" {
    @async.with_task_group(group => {
    let f = @fuwaroid.Fuwaroid::spawn_fold(
    group~,
    init=0,
    on_cmd=(_, state, _cmd) => state + 1,
    mailbox=@fuwaroid.Mailbox::Bounded(1),
    )
    @async.pause() // let the loop reach its mailbox park (its steady state)
    // A Bounded mailbox never waits for capacity: a send that finds it
    // full is refused synchronously with MailboxFull.
    let mut refused = 0
    for i in 0..<3 {
    match f.tell(i) {
    Ok(_) => ()
    Err(refusal) => {
    debug_inspect(refusal, content="MailboxFull")
    refused = refused + 1
    }
    }
    }
    if refused == 0 {
    fail("Bounded(1) never refused within the window")
    }
    // A refused send admitted nothing, so retrying it is side-effect free;
    // each pause lets the loop serve queued messages and recover capacity.
    let mut served = false
    for _ in 0..<100 {
    match f.ask((), timeout_ms=10000) {
    Ok(_) => { served = true; break }
    Err(NotDelivered(MailboxFull)) => @async.pause()
    Err(other) => fail("unexpected ask failure: \{Repr(other)}")
    }
    }
    if !served {
    fail("the barrier was never admitted")
    }
    // The barrier's reply certifies every admitted message was served.
    let snap = f.snapshot()
    if snap.accepted != snap.processed || snap.abandoned != 0L {
    fail("conservation violated after the barrier")
    }
    })
    }

    Two measured facts matter for capacity planning:

    • The effective admission window is capacity + 1. While the loop is parked in queue.get() — its steady state while idle — the first send is handed to that parked reader point-to-point, bypassing the buffer. One uninterrupted producer turn can therefore admit up to capacity + 1 messages in flight (1 handoff slot + capacity buffered); the next send is the one refused with MailboxFull.
    • Bounded(n) requires a positive integer n. Bounded(0) and negative capacities are a configuration error: spawn aborts synchronously with a fail-fast message — capacities are never clamped, remapped, or silently treated as rendezvous.

    Mailbox::Unbounded (the default) admits every message and grows without bound; budget your producers (see the queue-growth measurements in docs/concurrency-benchmarks.md §2.4).

    #Fair scheduling

    The loop yields the scheduler after every 32 complete messages (the default fuwaroid_yield_batch, adjudicated from measured data — see docs/concurrency-benchmarks.md). What the measurements show:

    • With 16 hot self-chaining instances, a cold instance's observed latency stays in the hundreds of microseconds at batch 32 (P50 212µs, max 276µs), versus 52µs/77µs at batch 1 and 737µs/1,484µs at batch 128.
    • Batch 32 delivers ~24x the drain throughput of batch 1 (1.80M vs 74.5k msg/s end-to-end) while loaded ask P99 stays at 51µs (batch 1: 3,611µs).

    These are measurements of this library on one machine, not promises: the scheduler is cooperative, there is no preemption, and a single long handler blocks the runtime for its whole duration (§2.5 of the benchmark report). If you need hard latency bounds, keep handlers short and bounded.

    #Diagnostics

    f.snapshot() is synchronous and read-only; every field is a copied value (no mutable internal state, no State/Cmd/Query/Reply leak). All counters are Int64 and only ever increase.

    FieldMeaning
    lifecycleRunningClosing (a close was requested) → Stopped(StopReason); forward-only, never moves back.
    acceptedAdmissions ACCEPTED by the mailbox — commands and queries alike.
    processedMessages whose handler RETURNED and whose state was committed. Not background-work completion.
    rejected_full / rejected_closedAdmissions refused because the bounded mailbox was full / the mailbox was closed.
    abandonedMessages accepted but never served when the loop stopped abnormally; 0 after a graceful drain.
    outstandingaccepted − processed − abandoned at snapshot time: requests in flight. NOT the underlying buffer length.

    Once the loop has terminated the conservation law closes: accepted == processed + abandoned + outstanding holds for every observed snapshot.

    #Migrating from the pre-R1 API

    The R1 API narrowing is breaking. R1 is not yet released (moon.mod still says 0.1.0); there is no compatibility layer.

    Pre-R1Now
    mailbox? : @aqueue.Kindmailbox? : Mailbox — this library's own vocabulary. @aqueue.Kind is no longer accepted in any public signature (compile error).
    @aqueue.Blocking(n)Mailbox::Bounded(n). n must be positive; Bounded(0) / negative values abort at spawn.
    @aqueue.DiscardOldest(n) / DiscardLatest(n)No equivalent and no wrapper. There is no drop-style mailbox: choose refusal semantics explicitly (Bounded refuses; handle SendRefusal::MailboxFull).
    fold handle Fuwaroid[Cmd, Cmd, Unit], barrier ask(cmd)Handle is Fuwaroid[Cmd, Unit, Unit]; barrier is tell(cmd); ask(()). The query type is Unit, so passing a command as the barrier argument is a compile error.
    no termination waitjoin() returns StopReason; close(); join() confirms the drain.
    no diagnosticssnapshot() returns Snapshot counters.

    #Migrating to 0.2.0 (moonbitlang/async 0.22.x)

    0.2.0 tracks moonbitlang/async 0.22.x, whose cancellation is a compiler cancellation signal — propagated like an error but NOT capturable by catch and NOT an Error value. StopReason therefore changes shape (breaking, no compatibility layer):

    0.1.x0.2.0
    StopReason::Stopped(error) — one abnormal constructor; host cancellation surfaced as a stored cancellation ErrorStopReason::Cancelled for a host-cancelled loop (the cancelled loop task ends cancelled; Task::wait observers see @async.TaskCancelled), and StopReason::Failed(original error) for ordinary terminal errors
    join on a cancelled loop returned Stopped(cancel error)join returns Cancelled; TaskCancelled itself is never stored or returned as the reason

    #Purity

    Error classification (classify_ask_error) and capacity validation (mailbox_capacity_error) are pure and table-tested. The lifecycle itself performs queue close/drain operations and preserves the terminal disposition: only QueueAlreadyClosed is a graceful mailbox stop; a foreign queue error keeps its original identity as StopReason::Failed(original), while the host's cancellation — a runtime signal distinct from Error in async 0.22.x — is recorded as StopReason::Cancelled, never disguised as an error. Callback serialization does not make generic state ownership or callback purity a type-enforced property.

    #License

    Apache-2.0

    StoppedError

    pub(all) suberror StoppedError {
    StoppedError
    }

    Internal marker closed into reply queues of asks stranded by an abnormal loop stop, so their waiters fail fast instead of timing out.

    AskFailure

    pub(all) enum AskFailure {
    NotDelivered(SendRefusal)
    TimedOut
    Stopped
    } derive(Eq,
    Debug
    )

    Why an ask did not produce a reply.

    AskFailure::equal

    fn AskFailure::equal(AskFailure, AskFailure) -> Bool

    AskFailure::not_equal

    fn AskFailure::not_equal(x : AskFailure, y : AskFailure) -> Bool

    Ctx

    pub struct Ctx[Cmd, Query, Reply] {
    group :
    TaskGroup
    [Unit]
    address : Fuwaroid[Cmd, Query, Reply]
    }

    What a handler may touch: the host group (to spawn async work) and this Fuwaroid's own address (to report work outcomes back). Never the raw mailbox, never another Fuwaroid's state.

    Envelope

    pub enum Envelope[Cmd, Query, Reply] {
    Tell(Cmd)
    Ask(Query,
    Queue
    [Reply])
    }

    Internal mailbox vocabulary. Public only because it appears in the (private) field type of the public Fuwaroid handle — visibility rule: consumers never construct these; the mailbox itself is private.

    Fuwaroid

    pub struct Fuwaroid[Cmd, Query, Reply] {
    // private fields
    }

    Single-writer concurrency over moonbitlang/async: private state, a typed mailbox, one serial loop. A Fuwaroid is a light entity that floats suspended until a message arrives, settles it in one synchronous step, and drifts back to sleep — no OS threads, no locks, no blocking (see README for the name).

    The execution model:
    • Handlers (on_cmd / on_query) are SYNCHRONOUS by signature, so the loop never awaits between callback entry and return; callback invocations are serialized and no other message can interleave them. This does not make a generic State immutable or prevent aliases. Callers must treat the state as actor-owned and must not retain or mutate it from spawned work or replies.
    • Async work (IO, subprocesses, timers) never runs inside a handler: spawn it on the host group from the handler and report the outcome back with tell. The loop is the only place where a handler return value advances its state; the ownership rule is a caller contract, not a compiler-enforced property of the generic API.
    • Between complete messages (handler returned, state committed) the loop yields the scheduler every fuwaroid_yield_batch messages, so an instance that keeps its own mailbox non-empty cannot monopolize the cooperative runtime: other instances, timers and cancellation all get scheduled. A handler is never suspended midway.
    • Every stop — graceful closed-and-empty drain, host cancellation observed at a yield/get boundary, or any other terminal mailbox error — goes through ONE cleanup path (fuwaroid_cleanup): admission is closed, stranded asks are failed with the stop marker, the termination reason is recorded, and only then does the loop task terminate (returning normally after a graceful drain, re-raising the original error for an ordinary failure, and terminating CANCELLED — observable as @async.TaskCancelled to Task::wait — after a host cancellation, whose signal in async 0.22.x is distinct from Error).
    • join waits for the loop task itself to terminate and returns the recorded stop reason; it never waits for or cancels background work a handler spawned on the host group.
    • The loop is spawned no_wait on the HOST's long-lived task group. There is no actor system, no registry, no supervision tree: the host owns the lifetime (structured concurrency). Handlers cannot raise (their types say so); failure is an ordinary message or terminal state — never a restart that resets state, which is what ledgers of in-flight work require.

    Fuwaroid::ask

    async fn[Cmd, Query, Reply] Fuwaroid::ask(self : Fuwaroid[Cmd, Query, Reply], query : Query, timeout_ms~ : Int) -> Result[Reply, AskFailure]

    Send one query and wait for the handler's reply, bounded by timeout_ms. Calling ask from inside a handler of the same Fuwaroid is a compile error (E4149): handlers are synchronous functions and ask is async, so the self-ask scenario — the loop busy serving the current message until the timeout fires — is excluded by the type system rather than merely discouraged. Cancellation of the CALLER propagates (it is not converted into a failure value).

    Fuwaroid::close

    fn[Cmd, Query, Reply] Fuwaroid::close(self : Fuwaroid[Cmd, Query, Reply]) -> Unit

    Graceful mailbox stop: the mailbox accepts no new messages, everything already queued (commands AND queries) is still processed in FIFO order, then the loop exits. A reply to an ask issued just before close proves every earlier message was already served (FIFO). close does not cancel or await work that a handler already spawned on the host group; such work may observe MailboxClosed when it reports back. The host group owns the lifetime and waits for all of its children when it terminates.

    Closing twice is idempotent (the second close is a no-op on the already-closed queue). Returning does NOT mean the drain completed: the backlog is drained by the loop task, which yields between batches while doing so. To wait for the loop task to actually terminate and learn why, use join. The lifecycle phase moves to Closing here (forward-only: a late close cannot un-stop an already Stopped instance) and to Stopped(reason) when the unified cleanup completes.

    Fuwaroid::join

    async fn[Cmd, Query, Reply] Fuwaroid::join(self : Fuwaroid[Cmd, Query, Reply]) -> StopReason

    Wait for this Fuwaroid's loop task to terminate and return the recorded stop reason.

    • Waiting uses Task::wait, so it is multi-waiter safe and returns immediately once the loop has terminated. Only the loop task is awaited: background work a handler spawned on the host group is neither waited for nor cancelled by join.
    • Waiter cancellation (A): the waiter's own cancellation is the runtime's cancellation signal, NOT an Errorcatch cannot capture it, so it propagates out of join unchanged and is never converted into a StopReason. The pre-check below makes even an ALREADY-cancelled waiter observe itself when the loop already terminated: Task::wait has a synchronous fast path for terminated targets that performs no cancellation check, so the pre-check raises the pending signal first via @async.pause().
    • Cancelled loop task (B): Task::wait reports a cancelled TARGET as the ordinary @async.TaskCancelled error. That value describes the waited task, not this actor's stop, so it is never stored or returned as the reason: the loop's own cancellation branch already recorded Cancelled during its protected cleanup, and join returns exactly that.
    • Failed loop task (C): the target's ordinary error maps to the recorded Failed(original) — the original identity wins; a recorded-but-unexplained failure falls back to the secondary cleanup evidence, then to the raw task error (only reachable through a cleanup-path bug).
    • Graceful completion (D): the task ending Done returns the recorded reason, Graceful.
    • A missing internal task is an invariant violation and aborts.

    Note: when the loop task fails with a NON-cancellation error, the host task group's fail-fast still applies independently of join a joiner living in the same group is cancelled with the group before it can observe Failed; a joiner in a different scope observes it.

    Fuwaroid::snapshot

    fn[Cmd, Query, Reply] Fuwaroid::snapshot(self : Fuwaroid[Cmd, Query, Reply]) -> Snapshot

    Synchronous, read-only diagnostic snapshot (field criteria on Snapshot). Never raises, never suspends: the read happens in one uninterrupted scheduling turn, so the counters are mutually consistent and outstanding = accepted − processed − abandoned holds for every observed snapshot.

    Fuwaroid::spawn

    fn[State, Cmd, Query, Reply] Fuwaroid::spawn(group~ :
    TaskGroup
    [Unit], init~ : State, on_cmd~ : (Ctx[Cmd, Query, Reply], State, Cmd) -> State, on_query~ : (Ctx[Cmd, Query, Reply], State, Query) -> (State, Reply), mailbox? : Mailbox) -> Fuwaroid[Cmd, Query, Reply]

    Spawn a Fuwaroid on the host's long-lived task group. Commands and queries are SEPARATE types: on_cmd folds a command into the state, on_query folds a query and produces its reply — neither handler has unreachable arms for the other flow. Both run on the single loop, serially, in mailbox FIFO order, and may not raise. Use Fuwaroid::spawn_fold when there is no query flow at all.

    mailbox configures admission with this library's own Mailbox vocabulary (see its documentation for bounded refusal and the measured capacity+1 admission window); an invalid Bounded capacity aborts synchronously here — spawn stays a non-raising API.

    Fuwaroid::spawn_fold

    fn[State, Cmd] Fuwaroid::spawn_fold(group~ :
    TaskGroup
    [Unit], init~ : State, on_cmd~ : (Ctx[Cmd, Unit, Unit], State, Cmd) -> State, mailbox? : Mailbox) -> Fuwaroid[Cmd, Unit, Unit]

    Fuwaroid::spawn without a query flow: the state simply folds over the command stream. The handle is Fuwaroid[Cmd, Unit, Unit]: ask((), timeout_ms=...) degenerates to a served-receipt (Ok(())) through a no-op query handler — the barrier is NOT a command, it never reaches on_cmd; its only value is FIFO proof: the reply certifies every message enqueued before it was already served. (The legacy ask(cmd) barrier misuse is now a compile error: the query type is Unit.)

    Fuwaroid::tell

    fn[Cmd, Query, Reply] Fuwaroid::tell(self : Fuwaroid[Cmd, Query, Reply], cmd : Cmd) -> Result[Unit, SendRefusal]

    Fire-and-forget send; returns immediately after the mailbox accept decision. Ok only means the command was enqueued. Order of processing is mailbox FIFO; the interleaving of concurrent tells is the caller's scheduling. Every outcome is counted on the instance's diagnostics: an accepted admission bumps accepted, a full refusal rejected_full, a closed refusal rejected_closed.

    Lifecycle

    pub(all) enum Lifecycle {
    Running
    Closing
    Stopped(StopReason)
    } derive(
    Debug
    )

    Lifecycle phase of a Fuwaroid's loop. Phases only move FORWARD: RunningClosing (admission was closed) → Stopped (the unified stop/cleanup path completed). An abnormal stop closes admission and enters Closing before yielding during cleanup; Stopped carries the recorded StopReason after cleanup. The phase never moves backwards: a late close cannot un-stop an instance, and a second close cannot leave Closing once Stopped was reached.

    Mailbox

    pub(all) enum Mailbox {
    Unbounded
    Bounded(Int)
    } derive(Eq,
    Debug
    )

    Mailbox configuration for Fuwaroid::spawn: this library owns the mailbox vocabulary, @aqueue.Kind is no longer part of any public signature.

    • Unbounded: admits every message; the queue grows without bound.
    • Bounded(n): at most n messages are buffered; a send that finds the mailbox full is refused SYNCHRONOUSLY with MailboxFull — it never waits for capacity. n must be a positive integer: zero (rendezvous) and negative capacities are a configuration error and fail-fast (abort) at spawn time, they are not clamped or remapped.

    Measured admission-window semantics: while the loop is parked in queue.get() — its steady state while idle — the first send is handed to that parked reader point-to-point, bypassing the buffer. One uninterrupted producer turn can therefore admit up to capacity + 1 messages in flight (1 handoff slot + capacity buffered); the next send is the one refused with MailboxFull.

    Mailbox::equal

    fn Mailbox::equal(Mailbox, Mailbox) -> Bool

    Mailbox::not_equal

    fn Mailbox::not_equal(x : Mailbox, y : Mailbox) -> Bool

    Mailbox::to_repr

    SendRefusal

    pub(all) enum SendRefusal {
    MailboxClosed
    MailboxFull
    } derive(Eq,
    Debug
    )

    Why a tell was not accepted.

    SendRefusal::equal

    fn SendRefusal::equal(SendRefusal, SendRefusal) -> Bool

    SendRefusal::not_equal

    fn SendRefusal::not_equal(x : SendRefusal, y : SendRefusal) -> Bool

    SettleOutcome

    pub(all) enum SettleOutcome {
    Settled
    DeadlineExceeded(Array[SupervisedTaskSnapshot])
    } derive(Eq,
    Debug
    )

    SettleOutcome::equal

    SettleOutcome::not_equal

    fn SettleOutcome::not_equal(x : SettleOutcome, y : SettleOutcome) -> Bool

    Snapshot

    pub(all) struct Snapshot {
    lifecycle : Lifecycle
    accepted : Int64
    processed : Int64
    rejected_full : Int64
    rejected_closed : Int64
    abandoned : Int64
    outstanding : Int64
    } derive(
    Debug
    )

    Synchronous, read-only diagnostic snapshot of one Fuwaroid. Every field is a copied value: a snapshot never leaks mutable internal state, the business State, nor Cmd/Query/Reply values.

    Field semantics:
    • lifecycle: the forward-only phase, see Lifecycle.
    • accepted: admissions ACCEPTED by the mailbox — commands and queries alike, one count per successful tell/ask admission.
    • processed: messages whose handler RETURNED and whose state was committed on the loop. This is NOT background-work completion: work a handler spawned on the host group may still be running (or have been refused by the closing mailbox) long after processed was bumped.
    • rejected_full / rejected_closed: admission attempts refused because the bounded mailbox was full / because the mailbox was closed — one count per refused tell or ask.
    • abandoned: messages accepted but never served when the loop stopped abnormally — commands and queries alike, counted by the unified cleanup path over exactly the envelopes its stranded-ask drain removes. Once the loop has terminated the conservation law closes: abandoned == accepted − processed for an abnormal stop and abandoned == 0 for a graceful one.
    • outstanding: derived at snapshot time as accepted − processed − abandoned — accepted messages whose fate (served or abandoned) is not decided yet. It counts REQUESTS in flight; it does NOT pretend to be the underlying buffer length (a parked-reader handoff can hold one message beyond the configured capacity, and this field says nothing about buffer occupancy).

    Counter policy: every counter is Int64 and only ever increases (each counts one monotone event kind); they never silently wrap or reset. Overflow handling is by design "unreachable, documented": at 2^63 admissions the cooperative runtime would have exhausted every realizable resource long before, and per-increment checked arithmetic on the message hot path is not worth its cost.

    Snapshot::to_repr

    SpawnRefusal

    pub(all) enum SpawnRefusal {
    SupervisorClosed
    } derive(Eq,
    Debug
    )

    SpawnRefusal::equal

    SpawnRefusal::not_equal

    fn SpawnRefusal::not_equal(x : SpawnRefusal, y : SpawnRefusal) -> Bool

    StopReason

    pub(all) enum StopReason {
    Graceful
    Cancelled
    Failed(Error)
    } derive(
    Debug
    )

    Why the loop task terminated. Recorded by the unified stop path (fuwaroid_cleanup) before the loop task terminates. The three constructors distinguish the terminal dispositions the async 0.22.x runtime separates at the language level:

    • Graceful: close was requested and the closed-and-empty drain completed.
    • Cancelled: the host (task group) cancelled the loop. In moonbitlang/async 0.22.x cancellation is a distinct runtime signal, NOT an Error value, so it is reported as its own constructor — the loop never fabricates an error to stand in for it.
    • Failed(Error): an ordinary terminal error (e.g. a foreign mailbox error); the ORIGINAL error identity is kept, never a compressed copy.

    SupervisedTask

    pub struct SupervisedTask[X] {
    // private fields
    }

    SupervisedTask::cancel

    fn[X] SupervisedTask::cancel(self : SupervisedTask[X]) -> Unit

    Request cooperative cancellation. Returning does not imply termination.

    SupervisedTask::snapshot

    SupervisedTask::wait

    async fn[X] SupervisedTask::wait(self : SupervisedTask[X]) -> X

    Wait using the underlying Task semantics.

    A cancelled target raises @async.TaskCancelled. Ordinary worker failures keep their original Error identity. Caller cancellation propagates.

    SupervisedTaskSnapshot

    pub(all) struct SupervisedTaskSnapshot {
    id : Int
    label : String
    status : SupervisedTaskStatus
    error_text : String?
    } derive(Eq,
    Debug
    )

    Copied diagnostic view of one supervised task.

    SupervisedTaskSnapshot::equal

    SupervisedTaskSnapshot::not_equal

    SupervisedTaskStatus

    pub(all) enum SupervisedTaskStatus {
    Running
    Cancelling
    Completed
    Cancelled
    Failed
    } derive(Eq,
    Debug
    )

    Lifecycle of one supervised task.

    SupervisedTaskStatus::equal

    SupervisedTaskStatus::not_equal

    Supervisor

    pub struct Supervisor[X] {
    // private fields
    }

    Task ownership and cancellation on an existing host TaskGroup.

    Supervisor is separate from Fuwaroid message processing. It creates no runtime and does not detach tasks from the host group.

    Supervisor::Supervisor

    fn[G, X] Supervisor::Supervisor(group~ :
    TaskGroup
    [G]) -> Supervisor[X]

    Bind to a host TaskGroup of any result type.

    Supervisor::cancel_and_wait

    async fn[X] Supervisor::cancel_and_wait(_self : Supervisor[X], tasks~ : ArrayView[SupervisedTask[X]], timeout_ms~ : Int) -> SettleOutcome

    Cancel selected handles managed by this supervisor and wait under one overall deadline.

    The deadline bounds this settlement operation only. Supervised tasks remain children of the host TaskGroup, whose structured exit still waits for unresolved children.

    Supervisor::shutdown

    async fn[X] Supervisor::shutdown(self : Supervisor[X], timeout_ms~ : Int) -> SettleOutcome

    Close admission, cancel the live-task snapshot, and bounded-settle it.

    DeadlineExceeded does not detach unresolved tasks from the host TaskGroup. Repeated shutdown calls may wait for them again.

    Supervisor::snapshot

    fn[X] Supervisor::snapshot(self : Supervisor[X]) -> SupervisorSnapshot

    Supervisor::spawn

    fn[X] Supervisor::spawn(self : Supervisor[X], f : async () -> X, label? : String) -> Result[SupervisedTask[X], SpawnRefusal]

    Spawn a supervised child task. Admission is refused once shutdown starts.

    SupervisorLifecycle

    pub(all) enum SupervisorLifecycle {
    Open
    Closing
    Closed
    } derive(Eq,
    Debug
    )

    Supervisor admission lifecycle.

    SupervisorLifecycle::equal

    SupervisorLifecycle::not_equal

    SupervisorSnapshot

    pub(all) struct SupervisorSnapshot {
    lifecycle : SupervisorLifecycle
    tasks : Array[SupervisedTaskSnapshot]
    } derive(Eq,
    Debug
    )

    Copied diagnostic view of a supervisor and its live tasks.

    SupervisorSnapshot::equal

    SupervisorSnapshot::not_equal