fuwaroid

    Lightweight single-writer concurrency

    fuwaroid
    actor
    concurrency
    async
    moonbitlang
    Download zip
    Author
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    52 minutes ago
    Downloads
    3

    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 or Stopped(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.

    #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, Stopped(original error) after an abnormal stop (host cancellation keeps its original error identity). Multiple waiters are fine, and a join after termination returns immediately. If the waiter itself is cancelled, that cancellation propagates unchanged.
    • 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 Stopped(cancellation error). 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.

    #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.

    #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 original stop error: only QueueAlreadyClosed is a graceful mailbox stop, while cancellation and foreign queue errors propagate as the recorded StopReason::Stopped cause. 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.

    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 otherwise).
    • 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.
    • The loop task ending Done (graceful closed-and-empty drain) returns the recorded reason, Graceful. The task failing returns the recorded Stopped(original cause) — notably host cancellation, which the unified stop path records before re-raising the cancel identity, so a joiner that is NOT itself cancelled observes Stopped(cancel error) instead of the raw cancellation.
    • If the WAITER itself is cancelled, that cancellation propagates: the caught wait error is re-raised after checking @async.is_being_cancelled(), never converted into a StopReason.
    • Fallback boundary: a task failure whose recorded reason is still Graceful means an error escaped outside the recorded original cause — the recorded secondary cleanup evidence is mapped to Stopped when present (the task did fail; the evidence must not vanish), and the escaped error itself otherwise (only reachable through a cleanup-path bug).
    • A handle not obtained from spawn (white-box construction) has no loop of its own; join returns Graceful immediately for it.

    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 Stopped; 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 (a close was requested) → Stopped (the unified stop/cleanup path completed). An abnormal stop — host cancellation observed at a yield/get boundary, or another terminal mailbox error — may jump straight from Running or Closing to Stopped, carrying the recorded StopReason. 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.

    SendRefusal

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

    Why a tell was not accepted.

    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.

    StopReason

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

    Why the loop task terminated. Recorded by the unified stop path (fuwaroid_cleanup) before the loop task terminates; abnormal stops keep the ORIGINAL error identity (the host's cancellation error, or whatever terminal mailbox error occurred), never a compressed copy.