Doll 2 — Mailbox — Deep Dive
See Quick Reference for API signatures and contracts.
Prerequisite: Doll 1 (PolyNode, MayItem, Builder).
Safety: Handle Validation
All mailbox operations (mbox_send, mbox_wait_receive, mbox_interrupt, mbox_close, try_receive_batch) validate the Mailbox handle. If the PolyNode.id is not MAILBOX_ID (-1), the operation will panic immediately. This prevents accidentally using a data item or a pool as a mailbox.
Node reset rule
Every node must have prev == nil and next == nil before it is passed to mbox_send or pool_put.
- Single-item returns (
mbox_wait_receive,pool_get,pool_get_wait) — the library resets the node before returning it. No action needed from the caller. - Batch returns (
mbox_close,try_receive_batch,pool_close) — nodes remain linked to each other in the returnedlist.List. The library cannot reset them. The caller must callpolynode_resetafter eachlist.pop_frontbefore passing the node tombox_sendorpool_put.
In debug builds (-debug), mbox_send and pool_put check polynode_is_linked and panic if the node is still linked.
remaining := mbox_close(mb)
for {
raw := list.pop_front(&remaining)
if raw == nil { break }
poly := (^PolyNode)(raw)
polynode_reset(poly) // required: batch pop does not reset
m: MayItem = poly
pool_put(master.pool, &m)
}
Receiver loop with interrupt
for {
m: MayItem
switch mbox_wait_receive(mb, &m) {
case .Ok:
// process item
dtor(&b, &m)
case .Interrupted:
// woken without a message — check external state
if reload_needed.load() {
reload_config()
}
// next mbox_wait_receive blocks normally — flag is self-clearing
case .Closed:
return // shutdown
case .Timeout, .Already_In_Use, .Invalid:
// handle error conditions
}
}
Key points:
.Interruptedhands over no message —mstays nil.- The receiver must loop back to
mbox_wait_receive. - The interrupted flag clears itself — no reset needed.
Close — handling remaining items
- Walk via
list.pop_front. - Cast each
^list.Nodeto^PolyNode. - Dispose:
remaining := mbox_close(mb)
for {
raw := list.pop_front(&remaining)
if raw == nil { break }
poly := (^PolyNode)(raw) // safe: PolyNode at offset 0
m: MayItem = poly
dtor(&b, &m)
}
The cast (^PolyNode)(raw) works because:
- Every item has
PolyNodeat offset 0 (your convention). list.Nodeis the first field ofPolyNode.
Shutdown is part of normal flow.
try_receive_batch — processing example
batch, res := try_receive_batch(mb)
if res != .Ok { return }
for {
raw := list.pop_front(&batch)
if raw == nil { break }
poly := (^PolyNode)(raw)
m: MayItem = poly
// process item
dtor(&b, &m)
}
Master — full example
newMaster :: proc(alloc: mem.Allocator) -> ^Master {
m := new(Master, alloc)
m.alloc = alloc
m.builder = make_builder(alloc)
m.inbox = mbox_new(alloc)
return m
}
freeMaster :: proc(master: ^Master) {
remaining := mbox_close(master.inbox)
// process remaining remaining items...
// teardown mailbox
m_mb: MayItem = (^PolyNode)(master.inbox)
matryoshka_dispose(&m_mb)
alloc := master.alloc
free(master, alloc)
}
freeMaster owns the full teardown.
Nothing outside it should call free on ^Master directly.
Patterns
- Master runs on a thread.
- From here on, you think in Masters, not threads.
No pool yet.
Builder
- creates items.
- destroys items.
Mailbox moves them between Masters.
Request-Response
- Two Masters.
- Two mailboxes each.
- Master A sends a request.
- Master B receives, processes, sends response.
sequenceDiagram
participant A as Master A
participant B as Master B
A->>B: mbox_send(mb_req) — request
B->>B: process request
B->>A: mbox_send(mb_resp) — response
A->>A: process response
All items created by Builder.ctor.
All items destroyed by Builder.dtor.
Two-mailbox interrupt + batch
mb_main — the mailbox you block on.
mb_oob — out-of-band side channel.
Master blocks on mb_main.
mb_oob carries extra data delivered alongside the interrupt.
Master wakes, receives the mb_oob batch.
Topology — who sends to whom:
sequenceDiagram
participant B as Master B
participant A as Master A
B->>A: mb_oob — send data items
B->>A: mb_main — interrupt
A->>A: .Interrupted — process remaining mb_oob
Receiver loop — what happens on each result:
flowchart TD
W([mbox_wait_receive mb_main]) --> Ok[".Ok<br/>handle message"]
W --> Int[.Interrupted]
W --> Cl[".Closed<br/>return"]
Int --> D["try_receive_batch mb_oob<br/>process remaining batch"]
Ok --> W
D --> W
for {
m: MayItem
switch mbox_wait_receive(mb_main, &m) {
case .Ok:
// handle main message
dtor(&b, &m)
case .Interrupted:
// woken — receive from the out-of-band mailbox
batch, res := try_receive_batch(mb_oob)
if res != .Ok { break }
for {
raw := list.pop_front(&batch)
if raw == nil { break }
poly := (^PolyNode)(raw)
m2: MayItem = poly
// process oob item
dtor(&b, &m2)
}
case .Closed:
return
}
}
When interrupted, receive from mb_oob — that is where the data is.
Calling try_receive_batch on mb_main by mistake empties the wrong queue and loses the interrupt signal.
OOB — out-of-band side channel
OOB is an advanced flow. Not for everyday use. Use it only when a single mailbox cannot express what you need.
Two mailboxes:
mb_main— the mailbox the receiver blocks on.mb_oob— carries data alongside the interrupt.
Critical ordering rule:
Fill mb_oob first.
Then interrupt mb_main.
Interrupting first is a race.
The receiver may call try_receive_batch(mb_oob) before items arrive.
Why try_receive_batch returns (list.List, RecvResult):
In OOB flows, mb_oob may itself be in an unexpected state.
The result tells you exactly what happened:
.Ok — items ready.
.Interrupted — mb_oob was also interrupted, no items drained.
.Closed — mb_oob was closed.
Always check the result before processing the list.
Pipeline
Chain of Masters.
Each Master: receive → process → send forward.
flowchart LR
A[Master A] -->|mb1| B[Master B]
B -->|mb2| C[Master C]
- Master A: create → fill → send.
- Master B: receive → process → forward. No destroy — ownership transfers.
- Master C: receive → consume → destroy.
Fan-In
Multiple Masters send to one mailbox.
One Master receives.
flowchart LR
MA[Master A] -->|send| IN[(inbox)]
MB[Master B] -->|send| IN
MC[Master C] -->|send| IN
IN -->|receive| R[Receiver]
Receiver dispatches on id:
for {
m: MayItem
switch mbox_wait_receive(mb, &m) {
case .Ok:
ptr, ok := m^.?
if !ok { continue }
switch ItemId(ptr.id) {
case .Event:
// process event
case .Sensor:
// process sensor
}
dtor(&b, &m)
case .Closed:
return
}
}
Fan-Out
flowchart LR
MA[Master A] -->|send| MB[(shared mailbox)]
WA[Worker A] -->|mbox_wait_receive| MB
WB[Worker B] -->|mbox_wait_receive| MB
WC[Worker C] -->|mbox_wait_receive| MB
- All workers call mbox_wait_receive on the same mailbox.
- One Master sends.
- One worker wakes. The others keep waiting.
No round-robin. No routing logic. The mailbox does the distribution.
Shutdown — Exit message
Don't think in threads. Don't use thread.join. Master sends an Exit message to another Master's mailbox. That Master receives it and returns from its loop.
sequenceDiagram
participant MM as MainMaster
participant W as Worker
MM->>W: mbox_send — Exit message
W->>W: receives Exit → return
// MainMaster sends Exit
ExitId :: enum int { Exit = 99 }
m := ctor(&b, int(ExitId.Exit))
mbox_send(worker.inbox, &m)
// Worker receives
for {
m: MayItem
switch mbox_wait_receive(worker.inbox, &m) {
case .Ok:
ptr, ok := m^.?
if !ok { continue }
if ptr.id == int(ExitId.Exit) {
dtor(&b, &m)
return // Master returns from its loop — done
}
// handle other messages
dtor(&b, &m)
case .Closed:
return
}
}
What you can build with Doll 1 + 2
- Multi-threaded pipelines — read → process → write across Masters.
- Request-response pairs — Master A asks, Master B answers.
- Worker pools — fan-out to multiple worker Masters, fan-in results.
- Background processing — one Master compresses, another writes.
- OOB flows — interrupt on one mailbox, data on a second side-channel mailbox.
- Any system where items travel between threads and every item has one owner.