Expand description
once-ptr-cell — a lazy, CAS-published pointer cell with fallible init,
OOM rollback, and loser re-race.
OncePtrCell<T> is a three-state machine over a single AtomicPtr<T>:
UNINIT(null) --CAS--> INITIALIZING(sentinel=1) --Release store--> READY(real *mut T)
|
+-- init returns None (OOM) --> UNINIT(null) [rollback]- The thread that CASes
null -> sentinelbecomes the winner and runs the caller’s init closure exactly once. - On success the winner publishes the real pointer with
Releaseand leaks it for the process lifetime (the cell never drops or freesT). - Losers spin-
Acquireonly while the state isINITIALIZING— NOTwhile != READY. Spinning on!= READYdeadlocks against the OOM-rollback path: if the winner hits OOM and rolls the sentinel back tonullwithout ever publishingREADY, a!= READYspinner waits forever for aREADYthat will never come. Spinning on== INITIALIZINGinstead means a loser that observes the rollback (null) falls out of the spin and re-races the CAS itself. - On winner OOM the sentinel is rolled back to
nulland losers re-race the CAS themselves, rather than being blocked and woken.
§Why not OnceLock?
This cell fills the niche OnceLock cannot: it is
-
no_stdand allocation-free — the cell itself is oneAtomicPtr; it never touches the heap. -
usable inside a
#[global_allocator]— the cell’s own non-panicking operations use NOstdsync primitive (noMutex, no parking, noOnceLock) and allocate nothing, so the cell itself cannot re-enter the allocator being bootstrapped. That is a property of the CELL, not of a wholeget_or_try_initcall: the caller’sinitclosure runs inside that call and carries hard obligations of its own — see “Using this inside a#[global_allocator]” below. Used by hand-rolled allocators, runtimes, and bare-metal bootstraps that must publish a process-'staticpointer before any heap exists. -
fallible without blocking —
OnceLock::get_or_initcannot fail at all, and itsget_or_try_initis still unstable (once_cell_try). Both may also BLOCK the losing threads for the duration of the winner’s init (the documented contract is that losers block; the mechanismstduses to do so is an implementation detail, not a stable promise). This cell reports failure as a plainNoneper caller and lets losers re-race the CAS with no OS involvement, so a later attempt (after the OS frees memory, say) can succeed without a blocking primitive anywhere.To be precise about what
OnceLockdoes and does not do, since this crate’s earlier docs got it wrong:OnceLock::get_or_try_initdoes NOT poison the cell onErr, and a failed or panicking initialiser leaves it uninitialised and retryable (stddrives it throughOnce::call_once_force, which deliberately ignores poisoning). The real distinctions are the ones above —no_std, no parking, no internal allocation, and a raw-pointer/lifetime posture — not recoverability.
§The spin-wait (no parking, no std)
Losers busy-spin with core::hint::spin_loop — there is no OS park/unpark
(that would need std sync and could re-enter the allocator). There is
no bounded-latency guarantee: a loser waits for exactly as long as the
winner’s init closure takes — provided a winner is currently running
at all. init is arbitrary caller code — a closure that blocks on a
syscall, gets preempted, or simply runs long makes every loser wait that
long too. The intended usage (typically one OS reservation + one publish
store) keeps the spin short in practice, but that is a caller obligation,
not something this cell enforces: init must be fast and
non-blocking, on top of the re-entry restriction below. A cell whose
INITIALIZING owner has stopped running (see “Fork and signal safety”
below) is waited on forever, not merely for a long time. This is a
deliberate design constraint of the “usable inside the global allocator”
niche, not an oversight — see the module docs above.
§Using this inside a #[global_allocator]
The cell is built for this niche, but the niche has hard rules that are the CALLER’s to keep — the cell can enforce none of them:
initmust not allocate, directly or transitively, and must not otherwise re-enter the allocator being bootstrapped.initruns while this thread holds theINITIALIZINGsentinel; an allocation from inside it re-enters an allocator whose own bootstrap is mid-flight.initmust not block — every loser thread spins for exactly as long asinitruns (see “The spin-wait” above).initmust not wait on another cell that can wait back — the re-entry restriction is transitive, and several cells form a lock-order graph. An allocator bootstrap is exactly the shape that produces this (many per-chunk cells plus a sidecar path); seeOncePtrCell::get_or_try_init’s own docs for the two-cell deadlock.initmust not panic, and no panic may unwind through aGlobalAllocmethod — unwinding out of a global allocator is undefined behaviour. This crate’s rollback guard keeps the CELL consistent across an unwindinginit(the sentinel is rolled back, not left wedged), but it cannot make the unwind itself sound once the frame below isGlobalAlloc::alloc.- An
initthat returns the sentinel address is a caller bug, not a recoverable error. The release-activeassert!documented underOncePtrCell::get_or_try_init’s# Panicsexists to make that bug loud — it is a violated precondition, not a condition an allocator is expected to survive.
§Panic sites and the two link environments
The panic sites, independently:
| # | Panic site | Whose code | Reaches the panic runtime? | Message shape | Allocations before a non-allocating hook (measured — see below) |
|---|---|---|---|---|---|
| 1 | sentinel-collision assert! in get_or_try_init | this crate | yes | bare &'static str | 0 |
| 2 | an unwinding init closure | yours | yes | whatever you wrote | 0 if a bare literal, ≥ 2 if formatted |
| 3 | align_of::<T>() >= 2 in new, static form | this crate | no — const-eval failure, compile time | n/a | n/a |
| 3 | align_of::<T>() >= 2 in new/default, non-const form | this crate | yes | bare &'static str | 0 |
Normative contract, separate from the measurement below: init must
not panic, full stop. The std panic path may allocate before any
hook runs — especially for a formatted message — so the absence of an
allocation is never something to rely on. The numbers in this table and
the paragraph below are a measurement (rustc 1.97,
x86_64-pc-windows-msvc, --release, RUST_BACKTRACE=0, one specific
non-allocating hook), not an API guarantee about the panic runtime, this
crate’s MSRV, other std implementations/targets, or future toolchains.
The two link environments need genuinely different mitigations, not a shared recipe:
- A
no_stdbinary supplies its own#[panic_handler]. Written not to allocate, it closes the hazard completely: the whole panic path is yours, so nothing on it can re-enter the allocator. - A
stdbinary’spanic = "abort"profile setting removes the unwind (the UB when the frame below isGlobalAlloc::alloc), but it does not stop the panic runtime from allocating: with the DEFAULT hook, every panic sampled here allocated before it could print anything (measured: 2 allocations underpanic = "abort",RUST_BACKTRACE=0,--release, rustc 1.97, x86_64-pc-windows-msvc). Inside a#[global_allocator]that allocation re-enters the very cell that is mid-init, and the thread deadlocks on its own sentinel instead of aborting — the diagnostic the release-activeassert!above exists to print never reaches stderr, because the allocation that would have printed it is the one that deadlocked. Astdconsumer therefore needspanic = "abort"and astd::panic::set_hookthat goes straight tostd::process::abortwithout formatting — or, better, aninitthat cannot panic at all. Residual limit: a hook cannot help if the panic message is formatted.stdmaterialises the message (payload.get()) as an argument to the hook call, sounwrap/expect/assert_eq!/panic!("{}", …)allocate before any hook runs, whether or not the hook itself allocates — measured: 2 allocations forResult::unwrap, 4 forassert_eq!, with the same non-allocating hook that reaches 0 for a bare-&'static strpanic. Only a panic whose message is a bare&'static strwas measured allocation-free under that hook. The crate’s own twoassert!s (the sentinel-collision check and thealign_of::<T>() >= 2check) are of that shape and measure 0 allocations before the hook; an unwindinginitis your code, and its message is whatever you wrote, so it is covered only if you keep it a bare literal — and even then only as a measured observation on one toolchain, not a promise. The only mitigation the contract actually rests on is aninitthat cannot panic at all. Note also thatpanic = "abort"compiles the crate’s internal rollback guard out entirely (it is unwind-only) — under this profile the cell-consistency guarantee above comes from the process dying, not from the guard.
§Fork and signal safety
Everything above is about init and the panic path it can reach; two
further hazards break the cell from outside any of that, with no
misbehaving closure and no panic anywhere. The cell is neither
fork-safe nor async-signal-safe.
INITIALIZING is owned by a specific thread:
fork()in a multithreaded process. If one thread holds the sentinel (runninginit) when another thread callsfork(), the child process inherits a cell that readsINITIALIZINGbut has no thread that can ever publish or roll it back — every subsequent caller in the child spins forever. There is no reset API:dbg_rollback_reenterable’s entry CAS requires the cell to already benulland is a no-op on a sentinel-holding cell, by design.- An allocating signal handler. If a signal is delivered to the thread
that holds the sentinel and the handler allocates (directly, or
transitively — a
format!, aVec, a panic-hook path), the allocator reaches the same cell from inside the handler, the claim CAS fails, and the handler spins on a sentinel owned by the very thread it interrupted: an unrecoverable single-thread self-deadlock.
The rule for a multithreaded POSIX process is the POSIX rule, and this
crate adds nothing to it: after fork(), the child may call only
async-signal-safe functions until a successful exec(); if exec()
fails, terminate through an async-signal-safe path such as _exit.
That means no Rust allocator, no get_or_try_init, no init closure, no
panic path, and no other ordinary Rust code in the child before exec()
— the child inherits the whole address space, including every lock and
resource state left behind by threads that do not exist in it, and POSIX
specifies that a function is not async-signal-safe unless it is
explicitly documented to be (POSIX fork(), async-signal-safety).
There is a narrower, cell-local invariant worth stating separately,
because it is the part this crate can speak to at all: fork() must
not race any thread’s init, anywhere in the process — not just once,
before some notional “first” fork; every subsequent fork(), and every
cell created or reset afterward, is bound by it. A process-wide barrier
establishes it: every initializer holds the barrier’s shared side for the
whole duration of its init, and the forking thread takes it
exclusively — which by construction both waits for quiescence and blocks
new inits — calls fork() while still holding it, and releases it
only after fork() returns. (Acquiring, observing quiescence, releasing,
and only then forking leaves a window in which a fresh init starts
before the fork; holding across the call is the load-bearing part.)
That barrier prevents exactly one thing: a child snapshotting a cell
wedged at INITIALIZING with no thread alive to finish it. It does NOT
make the allocator, this cell, or Rust runtime code callable in the child
before exec() — inherited allocator and runtime locks are untouched
by it, and a get_or_try_init call in the child is a non-async-signal-safe
call regardless of what any cell’s state word says. Anything broader than
the POSIX rule above is an environment-specific contract you own, and owes
a fully proven atfork protocol covering every affected resource, not
just these cells.
Do not allocate in a signal handler.
§Sentinel encoding
The INITIALIZING state is the address 1 (SENTINEL_INITIALIZING), a bare
marker that is never dereferenced, only compared for pointer equality.
Constructed via core::ptr::without_provenance_mut so it carries no
provenance — strict-provenance-clean, since it is never turned back into a
dereferenceable pointer. An aligned pointer to T can never have address
1 (align_of::<T>() >= 2 is asserted at construction) — but a
misaligned or synthesised pointer at address 1 IS reachable from safe
code (an init closure can construct and return one). That case is
rejected by a release-active assert! in
OncePtrCell::get_or_try_init — see its # Panics.
§What the caller owns
The cell stores and hands back a *mut T / NonNull<T>; it does not
own the pointee. The init closure is responsible for producing a pointer
valid for the lifetime the caller treats the cell’s output as living (for the
bootstrap use case: a leaked, process-'static allocation). Reading the
payload behind the pointer is unsafe and left to the caller, who knows the
pointee’s real lifetime — see OncePtrCell::get and
OncePtrCell::get_or_try_init.
§Portability limit — requires pointer-width atomic CAS
The whole cell is one AtomicPtr<T> driven by compare_exchange; that is
not an incidental implementation choice, it is the entire mechanism. This
crate therefore needs target_has_atomic = "ptr" and will not compile
on a target without it. thumbv6m-none-eabi (Cortex-M0/M0+) and
riscv32imc-unknown-none-elf (no A extension) have load/store atomics
but no CAS; msp430-none-elf has no atomics at all. This crate is
no_std and allocation-free, but neither property implies pointer-width
CAS. A build on an unsupported target fails with an explicit
compile_error! naming the requirement, and with nothing else:
the implementation carries the positive #[cfg(target_has_atomic = "ptr")], so its body is not compiled there at all. That replaces the
“no method named compare_exchange” cascade an unguarded build would
produce on thumbv6m-none-eabi/riscv32imc-unknown-none-elf, and the
unresolved AtomicPtr import on msp430-none-elf (which has no atomics
for core to define it from), with one sentence naming the real
requirement.
§Layout — #[repr(transparent)]
OncePtrCell<T> carries #[repr(transparent)]: its layout is guaranteed
identical to AtomicPtr<T> — same size, same alignment. This is a real
contract, not merely an observation about the current compiler: the
“one AtomicPtr”/“one word” language throughout this crate’s docs would
otherwise describe an unstated detail of plain repr(Rust) layout
(field order, padding, and single-field size equivalence are not
guaranteed there), which is not something to leave implicit for a type
meant to sit in allocator metadata or an array of cells.
Structs§
- Once
PtrCell - A lazy, CAS-published pointer cell:
UNINIT -> INITIALIZING -> READYover a singleAtomicPtr<T>, with fallible init (OOM rolls back and losers re-race). See the crate-level docs for the full state machine, the anti-livelock loser-spin rule, and the “usable inside a#[global_allocator]” niche.
Enums§
- Rollback
Probe - The outcome of
OncePtrCell::dbg_rollback_reenterable— exactly the two answers that probe can give, and no third one it could never produce.