Status: Accepted. D10’s cost is still to be measured (§6).
Date: October 6, 2026 (#121); D8 rewritten, D9-D11: October 12, 2026 (#490)
Answers: what a panic in one task (ADR-326 D4) leaves to the
others, and whether one task can exhaust the memory of all
Related:
ADR-326 (a panic ends its task);
ADR-321 D10 (a counting allocator bounds the build-time run);
Part I 6.2-6.3 (Shared, SharedMut, the four doors), Part II 12.8 (supervision), Part III 15.2 (crossing into Rust)
When a task dies while it changes a value other tasks share, what do they see; and can one task’s memory end everyone else’s?
A panic between two changes in an update door leaves a value half-changed.
Part III A.2 promises it is poisoned so no other thread reads state a
half-finished task left behind; the lock in std takes the guard without
looking at the poison. And a task that reads a large upload into a String
grows until the process has no memory left.
| A lock held by a dying task | Memory of one unit of work | |
|---|---|---|
| Erlang/Elixir | nothing is shared; the owner’s restart starts its state anew | a heap per process; max_heap_size ends that process, unset by default; a full machine ends the VM |
Rust std |
poisoned; a program usually unwraps, so every later use panics |
the global allocator; failure ends the process |
| tokio, parking_lot | not poisoned; the half-changed value is read | as Rust |
| Haskell STM, Clojure refs | changes go to a copy, committed at the end | - |
| Zig | - | every allocation takes an allocator and can fail; FixedBufferAllocator returns OutOfMemory |
From memory, not checked against a version.
| Option | Why it lost |
|---|---|
| Read through the poison | a wrong value nobody is told about |
| A poisoned value stays poisoned | every task that needs it dies; the server runs and does nothing |
| Every change to a copy, committed at the end | pays on every change, not on the crash; for a large value, a copy per update |
| A budget in Rust’s global allocator | an allocator may not panic and a refusal ends the process, so the limit could only be soft |
| One account per allocation, recorded beside it | a few bytes and a count on every allocation; a block per account says the same by address |
| Collections that are Rust’s own, as today | their memory is the global allocator’s, so no budget is hard |
| Every owned value toward Rust a copy the program writes | a copy, and a .clone() to write, where the compiler can follow the value to where it was built and build it where Rust wants it |
| The compiler copies where it cannot follow the value | a copy made unseen, at the boundary where copies cost most; a copy is written or refused, never inserted (ADR-282 D7) |
Nikaia’s blocks as Rust’s global allocator, so that every Vec is in one |
a crate that keeps a value past its task points into a block that was emptied; a refusal ends the process |
Every update on a copy, committed at its end |
for a list or a text, a copy the program did not write, on every change |
| A block that only moves a pointer forward | a task that runs as long as a connection frees and allocates without end, and would be ended for memory it no longer holds |
A value behind a lock a panicking task held is poisoned; no door opens it as if nothing happened.
By ADR-326 D4 that ends that task only.
Made precise by ADR-328 D9: the policy, a door during a delay, and giving up.
let cache = SharedMut::supervised(fn { Cache::load() })
The holders keep the same handle; the runtime calls the function again, puts its result in place of the poisoned content and lifts the poison. How often is bounded as a supervisor’s restarts are (Part II 12.8). The content starts anew: what must survive a crash belongs in a store with transactions.
std’s collections take it from an allocator Nikaia controls, and every such allocation can fail.List, String and Map stand on collections with an allocator of their own
(allocator-api2, hashbrown), not on Rust’s Vec and String.
What a task allocates is the task’s; what is allocated inside a door of a
SharedMut or in the construction of a Shared is that value’s. Shared is
written in the source (Part I 6.2), so which account pays is known where the
code stands.
Changed by ADR-async-5 D19: a full block unwinds the task as a panic does, cleanups run, but it arrives at every boundary as task::OverBudget, not a panic: the panic hook does not run, and in a group it fails only its child. A shared value is still poisoned.
A task whose block is full panics where it allocated - outside the allocator, so it unwinds - and ends (ADR-326 D4); its memory goes with it. A shared value whose block is full is poisoned, and rebuilt where it says how (D3).
As admission is (ADR-326 D2): std provides one that derives
budgets from the memory there is and the tasks running; a package may provide
another. No number is configured.
.clone() the program writes.&[T] and &str cross without a copy (Part III 15.2). A list or text a
program hands to a Rust API that owns its Vec<T> or String crosses as it is
wherever the compiler follows it back to where it was built - through locals,
results, the parameters of functions whose every call it sees, and the fields
of values built in the package. The compiler builds it there outside the block,
from the allocator Rust’s own collections use, counted against the task’s
budget. A value outside every block already - taken from a channel, handed back
by Rust - crosses as it is too. Where the compiler cannot follow the value - a
pub function’s parameter, a value read out of a shared value - the hand-over
is refused (NK2506) and the program writes .clone(): a copy is written or
refused, never inserted (ADR-282 D7). How far the compiler follows
decides only how often a .clone() is asked for, never whether one is made
unseen. The value leaves the budget at the call. A Vec or String a Rust API
hands back is taken over as it is, outside the block, and counts against the
budget from then on. Memory a Rust crate or a C library allocates and keeps for
itself is the global allocator’s and no task’s budget.
A value the compiler sees can outlive the task - sent on a channel, stored into a shared value outside its door, handed to Rust to own (D8), the task’s result - is built outside the block, from the global allocator, and counted against the task while the task holds it. Where the compiler cannot tell, the value is built outside. Nothing in the block outlives the task, so the block is given back whole when the task ends, without a walk over what it held.
From the hand-over the value counts against its receiver: the task that takes it from a channel or joins the task that produced it, or the shared value it is stored into. A receiver it does not fit is a full block (D6): the task panics where it takes the value, and the shared value is poisoned.
Memory a task frees, it can allocate again. A task that runs as long as a connection, or as the program, frees and allocates without end; a block that only moved its pointer forward would end it for memory it no longer holds.
update of a value that copies works on a copy and puts it in place at its end, so a panic in it poisons nothing; a door that cannot change a value poisons nothing either.Where every value an update or update_all changes is one that copies - a
number, a bool, a char - the block changes a copy, and the copy replaces the
value when the block ends. A panic inside it leaves the value as it was, and the
value is not poisoned (D1 asks only of a value a panic can have left
half-changed). A list or a text is changed in place and poisoned as before: a
copy of it would be a copy the program did not write (D8,
ADR-282 D7).
An update_all puts its copies back together, at the end of its block, while
it still holds both locks, so a panic leaves both values as they were. Where
one of its values does not copy, neither is copied, and a panic poisons both:
the one that copies would keep its old value while its partner is poisoned or
rebuilt from nothing, and two values changed in one door are ones that must
agree. A door that cannot change a value - access, access_all - leaves
nothing half-changed, so a panic in it poisons nothing. A change made in an
earlier door is not undone by a panic in a later one: every task could see it
between the two doors already, and values that must agree are changed in one
update_all.
update.std’s collections change their ground; a value that can
outlive its task, or that goes to Rust, is allocated as today and not from the
block; a hand-over to Rust the compiler cannot follow asks for a written
.clone(); a hand-over between tasks moves the value’s count from one budget
to the other; the content of a rebuilt value is lost; a full machine still ends the process, as it ends the
BEAM.malloc for short tasks. It can be slower, so it is
measured before it is built.