Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std.actor

Supervised actors on one thread. Every actor owns a heap: its handlers allocate there, and a trap in a handler discards that heap, closes the files the actor opened, and tells the actor’s parent. The rest of the program keeps running. Messages are copied into the receiver’s heap, mailboxes are bounded and allocated when the actor starts, and a send that cannot be delivered returns the message.

Implemented targets: x86-64 and AArch64 GNU/Linux. Elsewhere supported() is False and starting an actor returns StartError.Unsupported. The ARM heap and AAPCS64 recovery path are part of ARM qualification; native hardware validation remains a release gate.

A boss starts two workers. One answers; the other has a bug and traps. The boss receives the answer and both exits:

from std.actor import Address, Context, Exit, Mail, heap_bytes_used, heaps_live, StageError, stage
from std.ready import WaitError
from std.sync import CancellationToken
from std.time import deadline_after, monotonic_now, seconds

type Fault = AllocError | IoError | OverflowError[i64]

pub enum Job:
    Square(i64)
    Answer(i64)

pub class Boss:
    pub me: Option[Address]
    pub ended: i64

pub class Worker:
    pub boss: Address

pub enum Node:
    Leading(Boss)
    Working(Worker)

    pub def started(self: &mut Node, context: &mut Context[Job, Fault]) -> Result[(), Fault]:
        match self:
            case Node.Leading(boss):
                boss.me = Some(context.address())
                good = context.spawn(1, 65536).unwrap()
                bad = context.spawn(2, 65536).unwrap()
                context.send(good, Job.Square(7)).unwrap()
                context.send(bad, Job.Square(-1)).unwrap()
            case Node.Working(_):
                pass
        Ok(())

    pub def receive(self: &mut Node, mail: Mail[Job, Fault], context: &mut Context[Job, Fault]) -> Result[(), Fault]:
        match self:
            case Node.Leading(boss):
                match mail:
                    case Mail.Message(Job.Answer(value)):
                        print("answer", value)?
                    case Mail.Down(_, _, Exit.Stopped):
                        print("worker stopped")?
                        boss.ended += 1
                    case Mail.Down(_, _, Exit.Trapped(kind)):
                        print("worker trapped", kind)?
                        boss.ended += 1
                    case _:
                        pass
                if boss.ended == 2:
                    context.stop()
            case Node.Working(worker):
                match mail:
                    case Mail.Message(Job.Square(value)):
                        if value < 0:
                            # A bug: unwrapping Nothing traps.
                            missing: Option[i64] = Nothing
                            missing.unwrap()
                        context.send(worker.boss, Job.Answer(value * value)).unwrap()
                        context.stop()
                    case _:
                        pass
        Ok(())

    # Runs with the new child's heap current; see "The spawn hook".
    pub def spawn(self, tag: i64, child: Address, context: &mut Context[Job, Fault]) -> Result[Option[Node], Fault]:
        match self:
            case Node.Leading(boss):
                Ok(Some(Node.Working(Worker(boss.me.unwrap()))))
            case Node.Working(_):
                Ok(Nothing)

def main() -> Result[(), AllocError | IoError | StageError | WaitError]:
    mut actors = stage[Node, Job, Fault](8, 4, 4)?
    root = Node.Leading(Boss(Nothing, 0))
    actors.start(root, 65536).unwrap()
    actors.run(&deadline_after(&monotonic_now(), &seconds(5)).unwrap(), &CancellationToken()?)?
    stats = actors.stats()
    print(stats.started, stats.stopped, stats.trapped, heaps_live(), heap_bytes_used())?
    Ok(())
answer 49
worker stopped
worker trapped std.actor.TrapKind.Unwrap
3 2 1 0 0

The trap also prints its usual message to standard error, followed by note: contained in an actor; its heap is discarded.

For TCP servers and clients, std.actor.net provides a listener with one actor per connection, bounded connection buffers, deadlines, and nonblocking connect and lookup. The repository’s examples/actor_echo.plenty is an echo server built on it; a connection whose first byte is X traps, and only that connection closes.

Actors and stages

Stage.node(address) borrows a live node shared between calls to run. Use it to read counters or copy an owned snapshot outside the actor’s heap. It returns Nothing after the actor stops or traps, including when another actor has reused its slot. End the borrow before running the stage, sending mail, stopping an actor or dropping the stage; the ordinary borrowing rules prevent those operations while the node is borrowed. The accessor does not switch heaps or run actor code.

run checks its deadline between scheduling rounds even when mail remains queued. It does not preempt a running handler: a blocking call or an infinite loop still prevents the stage from making progress.

Stage.shutdown() immediately stops every live actor, releases queued mail, timers and tracked handles, and discards their heaps. It does not deliver Stop or Down callbacks or restart actors, and does not need mailbox space or timer capacity. Node destructors run under containment. It is idempotent; the empty stage can start new actors afterward, with its statistics retained and old addresses invalidated. Dropping a stage calls shutdown automatically, including on early returns after cancellation or errors. Application-level draining belongs before this final cleanup. Like other actor code, a destructor that blocks or loops is not preempted.

A Stage[N, M, E] runs actors of one node type N, message type M and error type E. N is a public class or enum with three public methods:

MethodCalled
started(self: &mut N, context: &mut Context[M, E]) -> Result[(), E]Once, before any mail
receive(self: &mut N, mail: Mail[M, E], context: &mut Context[M, E]) -> Result[(), E]For each delivered mail
spawn(self, tag: i64, child: Address, context: &mut Context[M, E]) -> Result[Option[N], E]On the parent, after a handler that called Context.spawn returns; builds the child with the child’s heap current. Nothing stops the child at once. See the spawn hook

M and E must be copyable with copy; delivery copies them into another heap. Use an enum node type to run several kinds of actor on one stage, as in the example.

The spawn hook

The spawn hook runs the parent’s code while the child’s heap is current, so everything it allocates belongs to the child. Two rules keep the parent’s heap and the child’s apart:

  • The hook takes self as a shared borrow. It cannot store a value in the parent’s node, so nothing it allocates outlives the child’s heap there, and it cannot move a value out of the parent’s node into the child.
  • The node type holds only values that a shared borrow neither aliases nor changes: numbers, bool, bytes, Bytes, files and sockets, functions, std.tls configurations and sessions, and lists, dicts, sets, boxes, classes and enums of these. A str copy shares its storage and Mutex.lock gives &mut through a shared borrow, so the compiler rejects a node holding str, Shared, a lock, an atomic, a channel, a closure or a task, naming the type and the field’s holder:
error: an actor node cannot hold `str` (in `Node`): its `spawn` hook reads the node while the child's heap is current; keep text in `Bytes` and pass shared state in messages

The hook builds the child’s node from copies of what it reads, and creates values the child owns, such as an accepted socket, inside the hook. A hook declared self: &mut N is rejected with cannot borrow shared reference as mutable in the standard library’s run_spawn.

In the hook, Context.spawn and Context.spawn_isolated return StartError.Capacity, and Context.stop has no effect. With ordinary spawn, an error or trap in the hook ends the parent: the hook is the parent’s code. An error is copied to the parent’s heap before the child’s heap is discarded.

Use Context.spawn_isolated(tag, heap_limit) when construction itself belongs to the child’s failure boundary, such as admitting a network connection. A hook error or trap ends only the attempted child and delivers Down(child, tag, Exit.Failed(error)) or Exit.Trapped(kind) to its parent. Returning Nothing delivers Exit.Stopped. The notification does not require room in the parent’s mailbox. A failed setup releases the child’s heap, tracked handles and timers before another attempt can reuse its slot. Stats.started includes these admitted attempts, even if they never reach their started method.

Child exits follow the mail already queued for the parent when the child stopped, including the child’s final messages. Newer mail cannot postpone Down indefinitely: an acceptor can recover retired connection slots even while new readiness keeps arriving.

Isolation also applies when the stage rebuilds an isolated child under a restart policy. A replacement hook that fails or traps ends the child and notifies its parent immediately; it is not retried recursively. A replacement hook returning Nothing reports Stopped. Ordinary child handler failures continue to follow the configured restart policy.

ExportContract
stage[N, M, E](capacity, mailbox, events)Result[Stage[N, M, E], StageError]: at most capacity live actors, mailbox messages per actor, events ready handles per wait. Allocates every slot table now
Stage.start(node, limit: Positive[i64])Result[Address, StartError]: a root actor with a heap of limit bytes. Allocates its mailbox now
Stage.send(to, message)Result[(), Undelivered[M]]
Stage.stop_actor(to)bool: queues Mail.Stop; False when the actor has stopped or its mailbox is full
Stage.run(&deadline, &cancel)Result[(), WaitError]: delivers mail and readiness until no actor is live; fails when the deadline passes or cancel is cancelled first
Stage.live()Live actors
Stage.stats()Stats
Stage.heap_used(actor)Option[i64]: bytes in the actor’s heap, Nothing once it has stopped
Stage.add_timers(count)Result[(), AllocError]: allocates count more records for timers and pending requests; a stage starts with none
Context.address()The running actor’s Address
Context.send(to, message)Result[(), Undelivered[M]]
Context.stop()Stops the running actor after this handler returns
Context.stop_actor(to)As Stage.stop_actor
Context.spawn(tag, limit: Positive[i64])Result[Address, StartError]: reserves a child slot, heap and mailbox now; the parent’s spawn hook builds the child after the handler returns
Context.restart(child, limit, &window)bool: restarts the child when it fails or traps, at most limit times per window; see Restart
Context.after(&delay, tag), every(&period, tag)Result[Timer, TimerError]: delivers Mail.Timer once after delay, or every period
Context.cancel(timer)bool: no Mail.Timer for it follows; False when it has already ended
Context.request(to, message, &timeout)Result[Request, RequestError[M]]: queues Mail.Request; exactly one Mail.Reply follows
Context.reply(request, message)Result[(), Undelivered[M]]: answers a request this actor received
Context.heap_used()Bytes in the running actor’s heap
Context.watch(&handle, interest)Result[Watch, WaitError]: delivers the handle’s readiness as Mail.Ready(watch, readiness); see Watching handles
Context.unwatch(&handle)Result[(), WaitError]: stops delivering it
supported()Whether this target has actor heaps
heaps_live(), heap_bytes_used(), heap_bytes_committed()Process totals over all actor heaps
max_heap_limit64 MiB, the largest heap limit; Stage.start, Context.spawn and Context.spawn_isolated trap above it
max_spawns16 spawn requests per handler
max_capacity1,048,576, the largest stage capacity; stage returns StageError.Allocation(CapacityOverflow) for a negative capacity or one above it
TypeValues
AddressA slot and a generation. A stopped actor’s address is never reused; key() returns it as u64
Mail[M, E]Message(M), Ready(Watch, Readiness), Down(Address, i64, Exit[E]) (the child, its spawn tag, its exit), Stop, Timer(Timer), Request(Request, M), Reply(Request, Reply[M])
Timertag(), and key() as u64
Watchkey() as u64, to tell watched handles apart; copyable
Requestkey() as u64, to match a reply to its request
Reply[M]Answer(M), TimedOut, Gone (the receiver stopped or restarted first)
TimerErrorCapacity (every timer record is in use), Range (a zero period, or a time out of range)
RequestError[M]Full(M), Gone(M) as for Undelivered; Capacity(M): no free timer record
Exit[E]Stopped, Failed(E), Trapped(TrapKind)
TrapKindMemory, Index, Arithmetic, Unwrap, Other; see Traps
Undelivered[M]Full(M): the mailbox is full or the receiver’s heap cannot hold the copy. Gone(M): the receiver has stopped
StageErrorAllocation(AllocError) (slot tables, or a capacity that is negative or over max_capacity: CapacityOverflow), Wait(WaitError) (the readiness selector)
StartErrorUnsupported, Capacity (no free slot, or max_spawns reached), Heaps (1024 heaps live or the reservation failed), Mailbox (its allocation failed)
Statsstarted, stopped, failed, trapped, restarted, delivered, full (refused sends, and readiness deferred by a full mailbox), gone (sends and readiness for stopped actors), lost (Down for a parent that had stopped)

Delivery

Each round delivers one mail to every actor that was runnable when the round began, then waits on the stage’s std.ready selector without blocking if any actor is still runnable. Readiness is level-triggered: an actor has at most one Mail.Ready queued per handle, and readiness that finds a full mailbox is reported again on a later wait. Due timers and ended requests wait outside the mailbox and alternate with mailbox deliveries, so they never need mailbox room and neither kind delays the other indefinitely.

Stop stops the actor after its handler returns. An Err(error) from any handler stops the actor and gives its parent Exit.Failed(error), copied into the parent’s heap; if that copy does not fit, the parent sees Exit.Trapped(TrapKind.Memory). Destructors run for a stopped or failed actor’s node.

Supervision

Every actor started by Context.spawn has a parent; the parent receives Mail.Down(child, tag, exit) after the child’s last message, so a reply always arrives before the Down that follows it. tag is the one the child was spawned with, so a parent can tell kinds of children apart without recording their addresses. Down never needs mailbox room and is never refused: the child’s slot holds the exit until the parent receives it, so a stopped child occupies one unit of capacity until then. When the parent has already stopped, the exit is dropped and counted in Stats.lost. A stopping actor’s undelivered mail is dropped, and so are its children’s pending exits.

A parent can also call Context.spawn again on Down, or ask the stage to restart the child.

Watching handles

Context.watch(&handle, interest) delivers a handle’s readiness to the running actor as Mail.Ready(watch, readiness). It takes any std.ready.Watchable handle: std.net’s TcpStream, TcpListener, UdpSocket, Connecting and Lookup. The returned Watch is the one the mail carries, so an actor watching several handles compares watch.key() to find which is ready. Watching a watched handle again changes its interest and returns the same Watch. Context.unwatch(&handle) stops delivery, and so does closing the handle.

The key is built from the handle’s descriptor number and the actor’s slot, so watching allocates nothing. A closed handle’s number can be reused by a later handle, which then has the same key; readiness already queued for the closed one can reach the new one, and its nonblocking calls return WouldBlock.

Arithmetic limits

Observation-only Stats totals saturate at i64.MAX; saturation never changes mail delivery, admission, errors or cleanup. Slot and timer/request generations never wrap: after an identity with generation u32.MAX ends, its slot or record is permanently retired. A pending Down still retains its payload and is delivered (or dropped during parent cleanup) before the exhausted slot is forgotten. Later admission uses the existing capacity errors when no reusable slot or record remains. Timer-record table sizes beyond their 32-bit key index range return AllocError.CapacityOverflow before appending.

Mailbox ordering counters are not lifetime totals. Before their representation would overflow, the stage subtracts the delivered prefix from both counters and all of that mailbox’s pending Down fences, clamping only already-passed fences to zero. Ready exits stay ready and future exits retain their exact remaining prefix distance. No callback or allocation runs during rebasing. Clearing an actor drains its queued messages and child exits before counters are reset on slot reuse; restarting also clears those queues.

Timers, requests and restart

A lead actor asks a counting service for its count, crashes it, and asks again. The request queued behind the crash is answered Gone, since the restart drops the old actor’s mail; the retry reaches the restarted service, whose count starts again:

from std.actor import Address, Context, Mail, Reply, StageError, stage
from std.ready import WaitError
from std.sync import CancellationToken
from std.time import deadline_after, milliseconds, monotonic_now, seconds

type Fault = AllocError | IoError | OverflowError[i64]

pub enum Job:
    Get
    Count(i64)
    Crash

pub class Lead:
    pub service: Option[Address]
    pub answers: i64

pub enum Node:
    Leading(Lead)
    Counting(i64)

    pub def started(self: &mut Node, context: &mut Context[Job, Fault]) -> Result[(), Fault]:
        match self:
            case Node.Leading(lead):
                service = context.spawn(0, 65536).unwrap()
                # Up to 3 restarts a minute.
                context.restart(service, 3, &seconds(60))
                context.request(service, Job.Get, &seconds(1)).unwrap()
                lead.service = Some(service)
            case Node.Counting(_):
                pass
        Ok(())

    pub def receive(self: &mut Node, mail: Mail[Job, Fault], context: &mut Context[Job, Fault]) -> Result[(), Fault]:
        match self:
            case Node.Leading(lead):
                service = lead.service.unwrap()
                match mail:
                    case Mail.Reply(_, Reply.Answer(Job.Count(count))):
                        print("count", count)?
                        lead.answers += 1
                        if lead.answers == 1:
                            context.send(service, Job.Crash).unwrap()
                            context.request(service, Job.Get, &seconds(1)).unwrap()
                        else:
                            context.after(&milliseconds(10), 0).unwrap()
                    case Mail.Reply(_, Reply.Gone):
                        print("gone")?
                        context.request(service, Job.Get, &seconds(1)).unwrap()
                    case Mail.Timer(_):
                        context.stop_actor(service)
                    case Mail.Down(_, _, exit):
                        print("service", exit)?
                        context.stop()
                    case _:
                        pass
            case Node.Counting(count):
                match mail:
                    case Mail.Request(request, Job.Get):
                        *count += 1
                        context.reply(request, Job.Count(*count)).unwrap()
                    case Mail.Message(Job.Crash):
                        missing: Option[i64] = Nothing
                        missing.unwrap()
                    case _:
                        pass
        Ok(())

    pub def spawn(self, tag: i64, child: Address, context: &mut Context[Job, Fault]) -> Result[Option[Node], Fault]:
        Ok(Some(Node.Counting(0)))

def main() -> Result[(), AllocError | IoError | StageError | WaitError]:
    mut actors = stage[Node, Job, Fault](2, 4, 1)?
    # Records for timers and pending requests.
    actors.add_timers(4)?
    actors.start(Node.Leading(Lead(Nothing, 0)), 65536).unwrap()
    actors.run(&deadline_after(&monotonic_now(), &seconds(5)).unwrap(), &CancellationToken()?)?
    stats = actors.stats()
    print(stats.trapped, stats.restarted)?
    Ok(())
count 1
gone
count 1
service std.actor.Exit[AllocError | IoError | OverflowError[i64]].Stopped
1 1

Timers

Context.after(&delay, tag) delivers Mail.Timer(timer) once, and Context.every(&period, tag) every period starting one period from now; timer.tag() is the caller’s tag. A tick still undelivered when the next falls due absorbs it, and ticks missed while the stage was busy are skipped rather than delivered late in a burst. Context.cancel(timer) ends a timer, and no Mail.Timer for it is delivered afterwards. An actor’s timers end when it stops.

Timers need records, which Stage.add_timers(count) allocates outside every actor heap; a stage starts with none, and after, every and request return Capacity when every record is in use. Starting, firing and cancelling a timer allocate nothing. Stage.run waits on the selector until the earlier of its deadline and the next due timer; the timer’s wake-up is not a timeout.

An unrepresentable now + delay returns TimerError.Range before reserving or scheduling a record. Repeating timers skip missed ticks and clamp their next deadline to the last representable instant. Mailbox/runnable ring indices and timer-heap indices use bounded arithmetic, including at representation limits, rather than relying on a potentially overflowing index sum.

Request and reply

Context.request(to, message, &timeout) copies message into the receiver’s heap like send, queues it as Mail.Request(request, message), and returns the Request. The receiver answers with Context.reply(request, answer), which copies the answer into the requester’s heap and queues it as Mail.Reply(request, Reply.Answer(answer)) in the requester’s mailbox, after the messages the receiver sent before it. Exactly one Mail.Reply reaches the requester for each request:

  • Answer(M) when the receiver replies in time;
  • TimedOut when timeout passes first;
  • Gone when the receiver stops or restarts first.

A late or second reply, a reply to a request sent to another actor, and a reply after the requester stopped return Undelivered.Gone. A pending request holds a timer record until its reply. Handlers do not block, so the requester handles other mail while it waits. Compare request.key() to match replies when several requests are pending.

A request timeout beyond the representable clock range is clamped to the last instant, rather than returning an arithmetic error or consuming its message on an overflow path.

Restart

Context.restart(child, limit, &window) makes the stage restart a child of the running actor when it fails or traps. Restart is opt-in per child; limit 0 turns it off. On a restart:

  1. The child’s queued mail, timers and child exits are dropped, requests sent to it are answered Gone, and its heap is discarded.
  2. A heap of the child’s original limit is reserved, and the parent’s spawn hook builds the child again with its original tag. The address stays, so other actors can keep sending to it.
  3. The new child runs started before any mail. Stats.restarted counts it.

The parent receives no mail for a restart. At most limit restarts happen per window, counted from the first restart in the window. After that, or when the hook returns Nothing or no heap can be reserved, the child stops and the parent receives Down with its exit. When the hook fails or traps, the parent ends as for any hook error, and the child’s exit is counted in Stats.lost.

Restart suits service actors that the hook can rebuild from the tag. An actor built around a resource the hook took once, such as an accepted connection, cannot be rebuilt: its socket was closed with its heap. Children that a restarted actor had started keep running without a parent.

Memory accounting

Each heap reserves a 64 MiB address range and commits pages as its size classes grow. A heap’s limit counts the bytes of its live blocks, each rounded up to its size class; an allocation over the limit fails like any other allocation, as AllocError where the program handles it. A handler that returns that error fails its actor:

from std.actor import Address, Context, Mail, heap_bytes_used, heaps_live, StageError, stage
from std.ready import WaitError
from std.sync import CancellationToken
from std.time import deadline_after, monotonic_now, seconds

type Fault = AllocError | IoError | OverflowError[i64]

pub class Greedy:
    pub size: i64

    pub def started(self: &mut Greedy, context: &mut Context[i64, Fault]) -> Result[(), Fault]:
        # 8 bytes for each of a million values do not fit in 64 KiB.
        values: list[i64] = [0 for _ in range(self.size)]?
        print("unreachable", len(values))?
        Ok(())

    pub def receive(self: &mut Greedy, mail: Mail[i64, Fault], context: &mut Context[i64, Fault]) -> Result[(), Fault]:
        Ok(())

    pub def spawn(self, tag: i64, child: Address, context: &mut Context[i64, Fault]) -> Result[Option[Greedy], Fault]:
        Ok(Nothing)

def main() -> Result[(), AllocError | IoError | StageError | WaitError]:
    mut actors = stage[Greedy, i64, Fault](1, 1, 1)?
    actors.start(Greedy(1000000), 65536).unwrap()
    actors.run(&deadline_after(&monotonic_now(), &seconds(5)).unwrap(), &CancellationToken()?)?
    print(actors.stats().failed, heaps_live(), heap_bytes_used())?
    Ok(())
1 0 0

An allocation failure the handler does not handle, such as one inside a runtime operation that cannot report it, is contained like a trap. std.crypto’s RSA operations reserve their memory in the heap first and return Allocation when it does not fit; see RSA allocation.

heap_bytes_used() is the sum of every heap’s used bytes and heap_bytes_committed() the pages made writable; discarding a heap returns both. At most 1024 heaps are live per process.

The stage’s own tables, every mailbox, and Context live outside all actor heaps and hold no pointer into one, so discarding a heap cannot leave a dangling pointer in them. The root node passed to Stage.start was built outside its heap: whatever it already owns stays outside and leaks if the actor traps. Build large state in started instead.

Traps

A trap in a handler, including an allocation failure that cannot be returned:

  • discards the actor’s heap, returning its memory;
  • closes up to 4096 files and sockets the actor opened, which also removes them from the stage’s selector;
  • gives the parent Exit.Trapped(kind);
  • does not run destructors for the trapped actor’s node or the handler’s locals, since they lived in the discarded heap.

kind is a TrapKind:

KindCause
MemoryAn allocation failure the program cannot handle, or an exit that does not fit the parent’s heap
IndexA list, string, byte or slice index out of bounds, or a missing dictionary key
ArithmeticInteger overflow, division by zero, an invalid shift or a lossy cast
Unwrapunwrap on Nothing or Err
OtherAny other runtime error, such as a failed assertion

The message appears on standard error only; reporting the kind allocates nothing. These are not contained and still end the process:

  • stack overflow;
  • internal runtime panics;
  • an allocation failure while no handler is running.

A lock or other process-wide state held at the moment of a trap stays as it was. Allocations made by C libraries through FFI, such as SQLite’s, are outside actor heaps and are neither limited nor freed by a trap.

Limits

  • One thread. Heaps belong to the thread that created the first one, and heap operations from another thread fail. Starting a thread in a handler traps the actor, since the thread could outlive the heap holding its task.
  • An actor node cannot hold str; use Bytes, which also accepts text.
  • Copying a message copies owned values. Where copy would share storage instead, a str is duplicated and any other shared value stops the program with a runtime error.
  • One message type per stage; a request and its reply are both M.
  • Restart is one-for-one: a child’s restart does not stop or restart its siblings, and the parent is not stopped when the restart limit is reached.
  • Files beyond 4096 per process are not tracked and stay open after a trap.
  • Handlers are not preempted: a handler that loops forever stalls the stage.
  • A stage holds at most max_capacity (1,048,576) actors, since a watch key holds a 20-bit slot.

Acknowledgements

  • Parents receiving their children’s exits follow Erlang/OTP supervisors; no Erlang code is used.
  • Uses the Rust libc crate for the Linux page-size query used by actor heaps (MIT OR Apache-2.0).