Skip to content

Patterns — Slot and PolyNode Idioms

Concepts: Tools — PolyNode.
API: API Reference — PolyNode, ItemHandle, Slot.

The slot rule in full: API Reference — Tag Identity and Slot Programming.

Slot idioms

Empty Slot initialization

When to use.

  • Every acquisition.

Code shape.

var slot: Slot = null;

Why.

  • Every acquisition API requires an empty slot.
  • Passing a non-null slot is a programming error.

Slot overwrite prevention

When to use.

  • Before every receive/get/create operation.

Code shape.

std.debug.assert(slot.* == null);

Why.

  • A slot always owns exactly one item.
  • Overwriting a non-null slot loses the item it holds.
  • Every acquisition API contains this assert. Wrong use panics immediately.

Transfer clears the slot

When to use.

  • Every transfer.

Code shape.

try mbx.send(&slot);
// slot == null

or

pl.put(&slot);
// slot == null if accepted by pool

or, when the caller takes the item itself:

const ev: *Event = EventPolyHelper.moveFromSlot(&slot) orelse return error.WrongTag;
// slot == null, the caller holds ev

or, when a list takes the item:

list.appendFromSlot(&slot);
// slot == null

Why.

  • Sender no longer owns the item.
  • Cleanup code becomes naturally safe.
  • Transfer pre-empts cleanup: a later defer sees null and does nothing.

Insert from a Slot

When to use.

  • Putting an item that sits in a Slot into an ItemList.

Code shape.

var slot: Slot = null;
try EventPolyHelper.create(allocator, &slot);
EventPolyHelper.mustFromSlot(&slot).code = 7;

list.appendFromSlot(&slot);
// slot == null

prependFromSlot is the same at the front.

Why.

  • append takes an ItemHandle, so it cannot clear a Slot. Every call site wrote slot = null on the next line by hand.

  • Forget that line and defer-destroy-early frees an item the list still points at.

  • appendFromSlot empties the Slot itself, so there is no line to forget.

  • The Slot must hold an item. An insert is not a defer target, so it follows Mbox.send rather than Pool.put.

Do not.

  • Do not use these for a stack item. There is no Slot to empty — use append with toPoly.

Example: examples/layer1/023-tag_dispatch.zig.

Null-safe cleanup

When to use.

  • Every deferred cleanup.

Code shape.

defer pl.put(&slot);

or

defer EventPolyHelper.destroy(allocator, &slot);

Why.

  • Cleanup helpers ignore null slots.
  • Cleanup may safely execute after transfer.

Defer-put-early (pool item)

When to use.

  • Acquiring a pool item. The defer goes before the get.

Code shape.

var slot: Slot = null;
defer pl.put(&slot);              // no-op if slot == null
try pl.get(TAG, .available_or_new, &slot);
// ... work ...
// on transfer: slot = null → defer runs as no-op
// on no transfer: defer recycles item

Why.

  • Failure path, success path, and transfer path all become correct automatically.
  • If the get fails, the defer sees null — nothing lost.

Example: examples/layer4/018-master_with_pool.zig.

Defer-destroy-early (heap item via PolyHelper)

When to use.

  • Creating a heap item. The defer goes before the create.

Code shape.

var slot: Slot = null;
defer EventPolyHelper.destroy(allocator, &slot);   // no-op if slot == null
try EventPolyHelper.create(allocator, &slot);
// ... work ...
// on transfer: slot = null → defer runs as no-op
// on no transfer: defer frees item

Example: examples/layer2/097-wake_up_all.zig.

Defer for received mailbox item

When to use.

  • Receiving into a slot. Cleanup must cover both the error path and the normal path.

Code shape.

var slot: Slot = null;
defer if (slot) |poly| helpers.freeItem(poly, allocator);
try mbx.receive(&slot, null);
// dispatch on slot.?.*.tag, process item
// item stays non-null until explicitly transferred or freed

Example: examples/layer4/031-select_graceful_shutdown.zig.

Fallback destroy after Pool.put

When to use.

  • Pool may already be closed when the item comes back.

Code shape.

defer EventPolyHelper.destroy(allocator, &slot);   // fallback: frees if Pool.put left slot non-null
defer pl.put(&slot);                          // primary: recycles to pool (clears slot on success)
// defers run LIFO: Pool.put first, then destroy (no-op if Pool.put cleared slot)

Why.

  • Pool receives the item if open.
  • A closed pool leaves the slot non-null — the caller keeps the item.
  • Destroy executes only if the item stayed with the caller.

Example: stories/video_transcoder/video_transcoder.zig.

No raw allocator calls on PolyNode-based types

When to use.

  • Every PolyNode-based user type (Event, Sensor, Timer, ShutdownCommand).

Code shape.

// WRONG — raw allocator on PolyNode-based type
const ev = try alloc.create(Event);

// CORRECT — PolyHelper.create/destroy
var slot: Slot = null;
defer EventPolyHelper.destroy(alloc, &slot);
try EventPolyHelper.create(alloc, &slot);

Why.

  • PolyHelper.create sets the tag and initializes the node.
  • Raw allocator.create skips both. The item is unusable for dispatch.

Exempt: mailbox.zig / pool.zig internals, PolyHelper implementations, pool hook bodies, non-PolyNode structs.
Full list: API Reference — Cooperative Cleanup.


PolyNode idioms

Intrusive node embedding

When to use.

  • Every PolyNode-based user type, from first definition.

Code shape.

pub const Message = struct {
    poly: polynode.PolyNode = .{},
    text: []const u8 = "",
    priority: u8 = 0,
};
pub const MessagePolyHelper = polynode.PolyHelper(Message);

Why.

  • PolyNode sits at offset 0. One allocation, no wrapper struct.
  • Safe cast both ways: *Message to *PolyNode and back, via PolyHelper.
  • No separate link object to keep in sync with the payload.

Example: examples/layer1/021-define_type.zig.

ItemList for many items

When to use.

  • Any API that carries more than one item: receive_batch, close, put_all, on_put, on_close.

Code shape.

var batch: polynode.ItemList = try mbx.receive_batch();
while (batch.popFirst()) |ih| {
    const msg = MessagePolyHelper.mustFromPoly(ih);
    // ... use msg
}

Why.

  • ItemHandle is one item, Slot is a place for one item, ItemList is many. The trio covers every shape the toolkit passes around.

  • popFirst yields an ItemHandle, so @fieldParentPtr stays out of your code.

  • popFirst calls polynode.reset before it returns. The old prev/next links are cleared for you — with a raw std.DoublyLinkedList they are not,
    and that was a documented trap.

  • Build one with .{} and appendFromSlot — see "Insert from a Slot". Take a raw std list over with ItemList.moveFromList, which leaves the raw list
    empty.

  • Under a safety build every insert asserts twice on the item going in: this list does not hold it, and polynode.is_linked is false. The list walk
    catches a list of one, which is_linked misses; is_linked catches a
    different list, which the walk cannot reach. Neither alone is complete.

  • Take one item out with remove(ih) — head, middle or tail. It calls polynode.reset, so the item comes back unlinked. popLast is popFirst at
    the other end, and first/last look without taking.

Example: examples/layer2/060-batch_processing.zig.
Details: api/polynode/stdlib-compatibility.md.

PolyHelper everywhere

When to use.

  • Every PolyNode type.

Code shape.

pub const EventPolyHelper =
    polynode.PolyHelper(Event);

Why.

  • Eliminates manual tag management.
  • Eliminates unsafe casts.
  • Eliminates initialization boilerplate.

Node identification

When to use.

  • Recovering a concrete type from a *PolyNode handle (e.g. an ItemHandle received from a mailbox or returned by a pool event source).

Code shape.

if (EventPolyHelper.fromPoly(handle)) |ev| {
    ...
}

Why.

  • Tag check and recovery are combined.
  • Wrong types return null.

Slot identification — accessing items

When to use.

  • After create or get, to access fields of the item in a Slot before sending or returning it.

Code shape (assert non-null, known type).

var slot: Slot = null;
defer EventPolyHelper.destroy(allocator, &slot);
try EventPolyHelper.create(allocator, &slot);
EventPolyHelper.mustFromSlot(&slot).code = 42;
try mbx.send(&slot);

Code shape (optional — type may vary).

if (EventPolyHelper.fromSlot(&slot)) |ev| {
    ev.code = 42;
}

Why.

  • Unwraps the optional internally — no .? in application code.
  • mustFromSlot panics if the Slot is empty or the tag does not match.
  • Use fromSlot (nullable) when the type is not guaranteed.
  • Inspection leaves the Slot full. The item is still there for send or put.
  • To take the item out instead, use moveFromSlot — see "Transfer clears the slot".

Polymorphic dispatch

Moved. One mailbox or one list carries more than one item type, and the
receiver recovers the concrete type — three ways, and the advice on choosing
between them: Patterns — Dispatch.

Tag identifies the class

When to use.

  • Runtime dispatch.

Pattern.

tag
type

Not

tag
instance

Use.

  • Pointer comparison for infrastructure handles.
  • User fields (kind, role) for application roles.

Details: API Reference — Tag Identity.

Wrapper type for infrastructure handles

When to use.

  • Mailbox or Pool must participate in polymorphic dispatch by tag.

Code shape.

const WorkerInbox = struct {
    poly: PolyNode,
    mbx: *Mbox,
};
pub const WorkerInboxPolyHelper = polynode.PolyHelper(WorkerInbox);

Why.

  • Wrapper has its own PolyHelper tag, distinct from Mbox.TAG.
  • Enables normal type dispatch. The receiver finds the embedded *Mbox.

A mailbox can travel on its own — mbx.toPoly() in, Mbox.mustFromPoly out.
Wrap it only when the receiver needs more than the endpoint: a job id, a
deadline, a reply address alongside it.

Mailbox-as-message

When to use.

  • Handing a communication endpoint to another Master.

Pattern.

Worker
returns *Mbox
Master receives mailbox

Typical use.

  • Worker completion notification.
  • Dynamic topology construction.
  • Channel migration.

Worker-finish-signal

When to use.

  • A worker signals completion by sending its own mailbox back to the Master.

Pattern.

  • Master creates worker_mbx: *Mbox, spawns a worker via io.concurrent, passes worker_mbx as parameter.
  • Worker processes items until a shutdown signal.
  • Worker sends worker_mbx.toPoly() back to the Master's inbox (unclosed) as the finish signal, then exits.
  • Master confirms class and instance in one step: Mbox.mustFromPoly(slot.?) == worker_mbx.
  • Master closes and destroys worker_mbx, then awaits the worker's future.

Why.

  • Replaces relying on the future await as a completion signal, or a separate shutdown message, with handing the mailbox back.
  • The instance check is a real pointer comparison. Both sides are *Mbox, so the compiler agrees the two values are the same kind of thing. Under the
    handle API this compared two look-alike ItemHandles and only the tag
    stood between a match and a silent mistake.

  • Mbox.fromPoly is the checking form when a foreign node is possible; it returns null instead of panicking.

Details: API Reference — Transporting infra handles.

Pool-as-message

When to use.

  • Sharing lifecycle managers.

Pattern.

pl: *Pool
mbx.send(&slot)

Why.

  • A pool is itself a PolyNode. pl.toPoly() in, Pool.mustFromPoly out.