once-ptr-cell
A lazy, CAS-published pointer cell — UNINIT → INITIALIZING → READY over a
single AtomicPtr<T> — with fallible init, OOM rollback, and loser re-race.
It fills the niche std::sync::OnceLock cannot:
no_std, allocation-free — the cell is oneAtomicPtr; it never touches the heap.- usable inside a
#[global_allocator]— the cell's own non-panicking operations use nostdsync primitive, no parking and no allocation, so it can publish a process-'staticpointer before any heap exists without itself re-entering the allocator it is bootstrapping. That is a property of the cell, not of a whole call: yourinitclosure runs inside it and must not allocate, block, or unwind — see Using this inside a#[global_allocator]below before adopting it there. - fallible without blocking — on winner OOM the sentinel rolls back to
nulland losers re-race the CAS themselves.OnceLock::get_or_initcannot fail at all, itsget_or_try_initis still unstable, and both may block the losing threads for the duration of the winner's init (blocking is the documented contract; the mechanismstduses is an implementation detail).
static CHUNK: OncePtrCell<Chunk> = OncePtrCell::new();
let chunk: Option<NonNull<Chunk>> = CHUNK.get_or_try_init(|| {
// OS reservation etc.; return None on OOM to roll back and let losers re-race.
// Inside a #[global_allocator], this closure must not allocate, block, or
// unwind -- see the section below.
reserve_and_init() // -> Option<NonNull<Chunk>>
});
One rule applies to every user, allocator or not: the init closure runs
while your thread holds the INITIALIZING sentinel, so it must not wait on
that same cell — and the restriction is transitive. Several cells form a
lock-order graph: thread 1 wins A and its init initialises B while
thread 2 wins B and its init initialises A, and both spin forever at
100% CPU with no direct self-recursion anywhere. Acquire multiple cells in a
fixed global order, exactly as you would locks.
Using this inside a #[global_allocator]
The cell is built for this niche, but the niche has hard rules that are yours 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. It runs while your thread holds theINITIALIZINGsentinel.initmust not block — every loser thread spins for exactly as long asinitruns, provided a winner is currently running at all. There is no bounded-latency guarantee.initmust not panic, and no panic may unwind through aGlobalAllocmethod — unwinding out of a global allocator is undefined behaviour. The crate's rollback guard keeps the cell consistent across an unwindinginit, but it cannot make the unwind itself sound.- An
initthat returns the sentinel address (1) is a caller bug, caught by a release-activeassert!— a violated precondition, not a recoverable allocator error.
The cell is neither fork-safe nor async-signal-safe. INITIALIZING is
owned by a specific thread — no misbehaving init is needed to break it:
fork()in a multithreaded process. If a thread holds the sentinel when another thread callsfork(), the child inherits a cell stuck atINITIALIZINGwith no thread able to publish or roll it back — every subsequent caller in the child spins forever, and there is no reset API.- An allocating signal handler. If a signal interrupts the thread holding the sentinel and the handler allocates, the allocator re-enters the same cell from inside the handler and spins on a sentinel owned by the very thread it interrupted — an unrecoverable 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 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.
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. 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 as an argument to the hook call, sounwrap/expect/assert_eq!/panic!("{}", …)allocate before any hook runs — 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 this 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.
Portability limit — requires pointer-width atomic CAS
The whole cell is one AtomicPtr<T> driven by compare_exchange, so this
crate 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. no_std and
allocation-free do not imply 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.
The two rules people get wrong
- Publish with
Release. The winner stores the real pointer withRelease; losers/readersAcquire. ARelaxedpublish breaks the happens-before and lets a reader observe an uninitialised pointee. - Losers spin
while == INITIALIZING, notwhile != READY. Spinning on!= READYdeadlocks against the OOM-rollback path: a winner that hits OOM rolls the sentinel back tonulland never publishesREADY, so a!= READYspinner waits forever. Spinning on== INITIALIZINGlets a loser observe the rollback and re-race.
Both rules are pinned by executable loom proofs that run against the real
OncePtrCell type (the crate aliases its atomics to loom::sync::atomic
under --cfg loom) — real_exactly_once_two_threads/real_exactly_once_three_threads
for rule 1, real_survives_oom_rollback_two_threads for rule 2. The
#[should_panic] counterfactuals in the same test module are a separate,
complementary check: they run against small shadow models of the same two
rules (an AtomicPtr/AtomicU8 standing in for OncePtrCell's own
internals, since loom cannot rebuild this crate with a deliberately-broken
ordering baked in), proving the loom harness itself is sensitive to each
protocol violation rather than passing vacuously:
RUSTFLAGS="--cfg loom"
--cfg loom is a global RUSTFLAGS cfg — it applies to every crate in the
build, not only this one. Under it, this crate's atomics become
loom::sync::atomic, so OncePtrCell::new is not const, and a
static CELL: OncePtrCell<T> = OncePtrCell::new(); — this README's own
usage example — fails to compile anywhere in that build. Always scope the
flag to this crate (-p once-ptr-cell, as above), or supply a #[cfg(loom)]
const-capable stand-in in your own crate if you need to set the flag
workspace-wide — see sefer-alloc's own
loom_shim
for a worked example.
Test-probe API stability
OncePtrCell exposes two dbg_-prefixed methods — dbg_is_ready and
dbg_rollback_reenterable — that are NOT hidden from rustdoc. This is a
deliberate posture decision, not an oversight:
dbg_rollback_reenterable's own doc explicitly invites downstream consumers to call it FROM their own test suites, to drive the OOM-rollback protocol on a real, live cell (e.g. a process-global registry chunk) without a process-terminating OOM. A#[doc(hidden)]posture — the usual default for adbg_*diagnostic hook — would have hidden this function from the very rustdoc a consumer needs to discover it in, while simultaneously advertising it for their use: an unresolvable contradiction, since a publishedpubitem is callable regardless of#[doc(hidden)], which only affects documentation visibility, not the semver surface.- The alternative considered — gating both methods behind a non-default
Cargo feature (e.g.
test-probes) — was rejected. The cost is small ([[test]] required-featuresexists; the real cost is only the corresponding CI matrix addition), but so is the benefit: neither method accepts a raw pointer or touches allocator metadata, which is the hazard a feature gate would be protecting against. dbg_is_readyis functionally identical toget().is_some()— same singleAcquireload, same predicate. It stays public because it has a real, exercised caller downstream, not because it offers any capabilitygetlacks: it reads as a self-documenting boolean assertion (assert!(cell.dbg_is_ready())) at a call site that would otherwise need an.is_some()/.is_none()match.
Both methods carry the crate's ordinary semver guarantee: they are public API, not "test-only, may change or vanish any time" hidden internals. Their own doc comments each carry a "# Stability" section stating this explicitly.
License
MIT OR Apache-2.0.