Skip to content

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 returned list.List. The library cannot reset them. The caller must call polynode_reset after each list.pop_front before passing the node to mbox_send or pool_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:

  • .Interrupted hands over no message — m stays 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.Node to ^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 PolyNode at offset 0 (your convention).
  • list.Node is the first field of PolyNode.

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. .Interruptedmb_oob was also interrupted, no items drained. .Closedmb_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.