Lightweight single-writer concurrency
Dependencies
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)| Symbol | Meaning |
|---|---|
| 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, AskFailure | Configuration 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. |
// 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)")
})
}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]")
})
}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")
})
}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")
}
})
}| Field | Meaning |
|---|---|
| lifecycle | Running → Closing (a close was requested) → Stopped(StopReason); forward-only, never moves back. |
| accepted | Admissions ACCEPTED by the mailbox — commands and queries alike. |
| processed | Messages whose handler RETURNED and whose state was committed. Not background-work completion. |
| rejected_full / rejected_closed | Admissions refused because the bounded mailbox was full / the mailbox was closed. |
| abandoned | Messages accepted but never served when the loop stopped abnormally; 0 after a graceful drain. |
| outstanding | accepted − processed − abandoned at snapshot time: requests in flight. NOT the underlying buffer length. |
| Pre-R1 | Now |
|---|---|
| mailbox? : @aqueue.Kind | mailbox? : 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 wait | join() returns StopReason; close(); join() confirms the drain. |
| no diagnostics | snapshot() returns Snapshot counters. |
pub(all) suberror StoppedError {
StoppedError
}pub struct Fuwaroid[Cmd, Query, Reply] {
// private fields
}async fn[Cmd, Query, Reply] Fuwaroid::ask(self : Fuwaroid[Cmd, Query, Reply], query : Query, timeout_ms~ : Int) -> Result[Reply, AskFailure]fn[Cmd, Query, Reply] Fuwaroid::tell(self : Fuwaroid[Cmd, Query, Reply], cmd : Cmd) -> Result[Unit, SendRefusal]Install
Download zipLightweight single-writer concurrency
Dependencies