Skip to main content

import StdlibStatus from '@site/src/components/StdlibStatus';

core.sync — Synchronisation primitives

Atomic operations, locking types, condition variables, barriers, and Send/Sync marker protocols.

<StdlibStatus status="partial" detail="3261 LOC of tests / 254 @test entries across 9 submodules — 8/9 stable under --interp, waitgroup partial (interpreter Tier-0 handle-table landed, defect class § A closed; §B duration-add intercept removed)." defects={[ {area: 'sync/waitgroup', summary: 'Closed § A: Tier-0 interpreter stubbed all WaitGroup intrinsics as inert no-ops with fixed return values; tests silently passed/failed against the wrong counter state. Real handle-table now lives at crates/verum_vbc/src/interpreter/waitgroup.rs.'}, {area: 'sync/waitgroup', summary: 'Closed § B: method_dispatch.rs:4059 had a Duration-bias "add" intercept on Int-receivers that fired for any single-field-record-unboxed record (every WaitGroup{handle: Int}.add(delta) silently dropped to a bare Int+Int sum). Same defect class as [[duration_single_field_record_unboxing_2026-05-27]].'}, ]} sweepDate="2026-05-27" />

FileWhat's in it
atomic.vrAtomicInt, AtomicU8..AtomicU64, AtomicBool, AtomicPtr<T>, MemoryOrdering (Relaxed/Acquire/Release/AcqRel/SeqCst), AtomicOrdering alias, fence(order), SpinLock, FutexLock
mutex.vrMutex<T>, MutexGuard<T>, LockResult<T>, TryLockResult<T>, PoisonError<T>, TryLockError<T>
rwlock.vrRwLock<T>, RwLockReadGuard<T>, RwLockWriteGuard<T>
once.vrOnce, OnceState (New/InProgress/Done/Poisoned), OnceLock<T>
semaphore.vrSemaphore, SemaphoreGuard
condvar.vrCondvar, WaitTimeoutResult, CondvarNotifyGuard, producer_consumer_pair<T>
barrier.vrBarrier, BarrierWaitResult, Phaser, CountDownLatch
waitgroup.vrWaitGroup
mod.vrTop-level re-exports + Send/Sync marker-protocol implementations + prelude

All locks in this module are futex-backed: contention parks the thread (or task on async runtimes) rather than busy-spinning.

Module status

Each core.sync.* module carries an explicit conformance status — same contract as core.base, core.collections, and core.time. The status row is the truth-table over the module's public API exercised by core-tests/sync/<module>/ under both Tier 0 (interpreter) and Tier 2 (AOT). Disagreement between tiers is itself a test failure.

ModuleStatusConformance suite
atomic.vrstablecore-tests/sync/atomic — 8 unit + 14 property + 10 regression. MemoryOrdering 5-variant pairwise disjointness + name() canonical-token injectivity + Eq laws (added this round). AtomicInt/AtomicBool single-threaded load/store/fetch_add round-trip in regression suite. Live atomic contention deferred to vcs/specs/L2-standard/sync/atomic/.
mutex.vrstablecore-tests/sync/mutex — 22 unit + 12 property + 5 regression. Mutex.new + poison/clear_poison/is_poisoned state-machine + PoisonError construction + TryLockError 2-variant ADT (WouldBlock / Poisoned). Default-via-new(0) workaround pinned in regression (task #17 close-gate). Live lock/contention at L2.
rwlock.vrstablecore-tests/sync/rwlock — 14 unit + 10 property + 4 regression. RwLock.new + poison protocol + re-exported error types (LockResult / TryLockResult / PoisonError / TryLockError) destructure round-trip + multi-instance independence matrix. Writer-preference fairness pinned by data-shape; live verification at L2.
semaphore.vrstablecore-tests/sync/semaphore — 15 unit + 8 property + 4 regression. try_acquire / release / try_acquire_many / release_many full sequential cycle. add_permits dual-bump (capacity AND availability) + forget_permit asymmetric shrink + binary() ≡ new(1) pinned in regression. Live contention at L2.
condvar.vrpartialcore-tests/sync/condvar — 12 unit + 7 property + 5 regression, of which 9 @ignore'd (audit § 3.6 — Tier-0 futex FFI symbol gap; notify_one/notify_all calls trip "FFI symbol not found: FfiSymbolId(61)"). Live path (Condvar.new + Default + waiter_count + WaitTimeoutResult shape + CondvarNotifyGuard + producer_consumer_pair) all GREEN. notify_one/notify_all and live wait/wait_timeout/wait_while at vcs/specs/L2-standard/sync/condvar/.
barrier.vrpartialcore-tests/sync/barrier — 21 unit + 8 property + 8 regression, of which 15 @ignore'd (audit § 3.1 secondary — same Tier-0 futex FFI symbol gap; Phaser.register/terminate and CountDownLatch.count_down-to-zero touch futex_wait/wake). Live path (Barrier.new + accessors + BarrierWaitResult shape + Default + Phaser.new + atomic accessors + CountDownLatch.new + zero-counter drained-immediate fast-path) all GREEN. Live multi-thread rendezvous + futex paths at L2.
waitgroup.vrpartialcore-tests/sync/waitgroup — 9 unit + 7 property + 4 regression. §A closed this round: Tier-0 interpreter handle-table at crates/verum_vbc/src/interpreter/waitgroup.rs replaces inert no-op stubs. §B closed this round: Duration-bias "add" intercept on Int-receivers at method_dispatch.rs:4059 removed — fired for any single-field-record-unboxed receiver. Live concurrent wait()+done() at L2.
once.vrstablecore-tests/sync/once — 6 unit + 11 property + 6 regression. OnceState 4-variant pairwise disjointness matrix + name() canonical tokens ("New" / "InProgress" / "Done" / "Poisoned") + Eq reflexivity / inequality. Live Once.call_once / call_once_force multi-threaded at L2.
mod.vrstablecore-tests/sync/mod — 16 unit + 8 regression. Top-level re-export resolution + submodule-direct equivalence (Mutex / RwLock / MemoryOrdering / OnceState all resolve identically via core.sync.X and core.sync.<sub>.X paths) + curated prelude (Mutex / MutexGuard / RwLock / AtomicInt / AtomicBool / Send / Sync) resolution.

The status table is the runtime truth, not the file's lifecycle annotation: lifecycle: Lifecycle.Theorem("v0.1") is the spec lifecycle (what the contract promises); the table above is the implementation lifecycle (what the runtime currently delivers). When the two diverge, the table is the source of truth for callers.


Atomics

type MemoryOrdering is Relaxed | Acquire | Release | AcqRel | SeqCst;
type AtomicOrdering is MemoryOrdering; // alias for atomic contexts

MemoryOrdering implements name() -> Text + Display + Debug + Eq (landed this round) — useful for state-machine assertions:

assert_eq(o, MemoryOrdering.Acquire);
assert_eq(o.name(), "Acquire");

Atomic integer types

AtomicInt, AtomicU8, AtomicU16, AtomicU32, AtomicU64, AtomicBool, AtomicPtr<T> — all expose the same shape:

A.new(value) -> A
a.load(order) -> V
a.store(value, order)
a.swap(value, order) -> V
a.compare_exchange(current, new, success_order, failure_order) -> Result<V, V>
// One CAS, not two: there is no `_weak` variant that may fail
// spuriously, so a retry loop around `compare_exchange` is retrying a
// real conflict and nothing else.
a.fetch_add(delta, order) -> V
a.fetch_sub(delta, order) -> V
a.fetch_and(mask, order) / fetch_or / fetch_xor // bitwise
a.fetch_max(value, order) / fetch_min // monotone
a.get_mut() -> &mut V // unique access
a.into_inner() -> V // consumes

Memory fence

fence(order) // standalone barrier (mfence/lfence/sfence on x86_64;
// dmb variants on aarch64)
compiler_fence(order) // prevents compiler reordering only

Ordering guide

OrderingUse when
Relaxedcounters, statistics — no inter-thread ordering needed
Acquireload that must see previous Release writes (read side of lock)
Releasestore that publishes previous writes (write side of lock)
AcqRelread-modify-write that both acquires and releases
SeqCsttotal order across all SeqCst ops (strongest; use when in doubt)

Lazy-init idiom

let ptr: AtomicPtr<T> = AtomicPtr.new(null_ptr<T>());
if ptr.load(MemoryOrdering.Acquire).is_null() {
let new_ptr = Heap(T.default());
match ptr.compare_exchange(
null_ptr<T>(), new_ptr,
MemoryOrdering.Release, MemoryOrdering.Relaxed
) {
Result.Ok(_) => (),
Result.Err(_) => { /* someone beat us; drop new_ptr */ }
}
}

Mutex<T>

Mutex.new(value: T) -> Mutex<T>

let r = m.lock(); // -> LockResult<MutexGuard<T>>
let r = m.try_lock(); // -> TryLockResult<MutexGuard<T>>

m.is_poisoned() -> Bool
m.poison() // advisory — explicit (not panic-driven; see audit §3.2)
m.clear_poison()
m.is_locked() -> Bool // best-effort

m.get_mut() -> LockResult<&mut T> // unique-borrow, no lock
m.into_inner() -> LockResult<T> // consumes

MutexGuard<T> implements Deref<Target=T> + DerefMut. The lock is released when the guard drops.

{
let mut g = config.lock().unwrap_or_else(|p| p.into_inner());
g.apply(update);
} // released here

Poisoning is advisory in Verum

Unlike Rust, Verum does NOT auto-poison on panic-during-guard-drop — the language does not yet expose a "currently unwinding" predicate. Callers that detect inconsistent state while holding the guard MUST invoke mutex.poison() explicitly before raising. Documented at core/sync/mutex.vr:91-104; pinned in the regression suite.


RwLock<T>

RwLock.new(value)

rw.read() -> LockResult<RwLockReadGuard<T>>
rw.write() -> LockResult<RwLockWriteGuard<T>>
rw.try_read() -> TryLockResult<RwLockReadGuard<T>>
rw.try_write() -> TryLockResult<RwLockWriteGuard<T>>

rw.is_poisoned() / rw.poison() / rw.clear_poison()
rw.get_mut() / rw.into_inner()
  • Multiple concurrent readers, OR one writer.
  • Writer-preferred fairness: when at least one writer is queued (writers_waiting > 0), incoming readers step aside and sleep so the queued writer can drain the reader pile. Bounded writer wait-time at the cost of mild reader latency under sustained writer pressure. See core/sync/rwlock.vr:57-67.

Once — one-time initialisation

static INIT: Once = Once.new();
INIT.call_once(|| setup_global_state());

OnceState (New / InProgress / Done / Poisoned) inspects why a call short-circuited. Implements name() + Display + Debug + Eq.

assert_eq(once.state(), OnceState.Done);

OnceLock<T> is the typed variant — get-or-init container with single-shot publication:

let cfg = CONFIG.get_or_init(|| load_config());

OnceGuard.drop soundness fix

OnceGuard.drop uses CAS-based poisoning to avoid overwriting a successful COMPLETE with POISONED in a panic-during-publication race window. Pinned in the source-side doc-comment at core/sync/once.vr:50-72; LOCKed at the L2-spec level once the multi-threaded harness lands.


Semaphore — counting permits

Semaphore.new(permits: Int)
Semaphore.binary() // alias for new(1)

sem.acquire() // blocks via futex until available
sem.try_acquire() -> Bool // non-blocking
sem.acquire_many(n) / try_acquire_many(n) -> Bool

sem.release() / sem.release_many(n)
sem.acquire_guard() -> SemaphoreGuard // RAII

sem.available_permits() -> Int // clamped non-negative
sem.max_permits() -> Int
sem.add_permits(n) // grows capacity AND availability
sem.forget_permit() // shrinks capacity permanently

SemaphoreGuard releases the held permits on drop.

Missed-wakeup ordering rationale

acquire() / acquire_many() use Release ordering on the waiters.fetch_add so the waiter-registration synchronises-with the Acquire load in release() / release_many() / add_permits(). With Relaxed, a concurrent releaser could read waiters == 0 before the waiter's increment becomes visible and skip the futex_wake. See core/sync/semaphore.vr:78-87.


Condvar — condition variable

Condvar.new()
cv.wait(mutex_guard) -> LockResult<MutexGuard<T>>
cv.wait_timeout(guard, timeout_ns) -> LockResult<(MutexGuard<T>, Bool)>
cv.wait_while(guard, predicate) -> LockResult<MutexGuard<T>>

cv.notify_one() // unconditional — no `if waiters > 0` gate
cv.notify_all()
cv.waiter_count() -> Int

Unconditional notify

notify_one / notify_all always bump the sequence counter AND issue futex_wake. The previous gate if waiters > 0 was unsafe in the notify-without-lock pattern (producer modifies state under mutex, releases the mutex, then calls notify_one()): a concurrent consumer's waiters.fetch_add may not be observed before the notifier's waiters.load, so the notifier skips the wake; the consumer sleeps forever. The cost of the unconditional wake is one syscall per notify on truly-uncontended condvars (kernel returns immediately when nothing is waiting). See core/sync/condvar.vr:217-230.

// `producer_consumer_pair<T>(initial: T) -> (Mutex<T>, Condvar)` takes
// whatever you want guarded. There is no bare `Queue` in the library —
// `Deque` is the double-ended one.
let (mu, cv) = producer_consumer_pair(Deque.new());
// Producer
{
let mut q = mu.lock().unwrap_or_else(|p| p.into_inner());
q.push_back(msg);
cv.notify_one();
}
// Consumer
{
let mut q = mu.lock().unwrap_or_else(|p| p.into_inner());
q = cv.wait_while(q, |q| q.is_empty()).unwrap();
let msg = q.pop_front().unwrap(); // FIFO: front, not back
}

Barrier — N-thread rendezvous

Barrier.new(n: Int)
barrier.wait() -> BarrierWaitResult // is_leader: exactly one per sync
barrier.num_threads() -> Int
barrier.waiting_count() -> Int // approximate
barrier.generation() -> Int // increments each pass

Barrier is reusable — generation increments each time all N threads have arrived, and the barrier resets to accept the next batch.

Phaser — reusable, growable, terminable barrier

Phaser.new(initial_parties: Int)
phaser.register() -> Int // returns current phase
phaser.arrive_and_await() -> Int // returns phase that just completed
phaser.arrive_and_deregister() -> Int

phaser.get_phase() / get_registered_parties() / get_arrived_parties()
phaser.is_terminated() -> Bool
phaser.terminate()

Packed-state encoding (see core/sync/barrier.vr:262-298):

Bits 0-15: parties (≤ 0xFFFF)
Bits 16-31: arrived (≤ 0xFFFF)
Bits 32-61: phase (30 bits = ~1.07 billion phases)
Bit 62: terminated
Bit 63: UNUSED (sign bit — historically held terminated, which
silently corrupted phase decoding via arithmetic-right-
shift; task #32 moved the flag to bit 62)

Phase-advance under concurrent register / arrive_and_await uses a CAS loop that MUST preserve the TERMINATED_BIT — losing it silently un- terminates the phaser on the next arrival. Same data-loss class as the arrive_and_deregister fix; both pinned in core-tests/sync/barrier/regression_test.vr.

CountDownLatch — single-use count-down barrier

let latch = CountDownLatch.new(N);
for _ in 0..N {
spawn { do_work(); latch.count_down(); };
}
latch.wait_for_zero();
latch.wait_for_zero_timeout(timeout_ns) -> Bool

wait_for_zero_timeout loops on the predicate under the mutex, recomputing remaining_ns against an absolute monotonic deadline each iteration. The earlier single-shot implementation returned false on the first spurious condvar wake even when the count was still > 0. See core/sync/barrier.vr:548-587.


WaitGroup — Go-style task-completion barrier

let wg = WaitGroup.new();
for item in items {
wg.add(1);
spawn { work(item); wg.done(); };
}
wg.wait(); // blocks until counter == 0
wg.try_wait() -> Bool // non-blocking

Tier-0 interpreter handle-table

The interpreter at crates/verum_vbc/src/interpreter/waitgroup.rs provides a real, thread-safe handle-table for WaitGroup. Pre-fix the intrinsics (__waitgroup_new_raw, __waitgroup_add_raw, __waitgroup_done_raw, __waitgroup_wait_raw, __waitgroup_try_wait_raw, __waitgroup_destroy_raw) were inert no-op stubs that always returned 0/1 — every conformance test silently passed against the wrong counter state (try_wait returning true even on a non-drained group). The handle-table allocates fresh, monotonically-increasing handles per new(), tracks a per-WG counter under a Mutex, and clamps done() on a drained counter (instead of underflowing).

Single-field-record "add" intercept removed

crates/verum_vbc/src/interpreter/dispatch_table/handlers/method_dispatch.rs:4059 had a Duration-bias "add" intercept that fired for any is_int() receiver and computed v + other. Combined with Verum's single-field-record unboxing optimisation, this caused WaitGroup { handle: Int }.add(delta) to silently drop the call to the underlying __waitgroup_add_raw intrinsic and return a bare handle + delta instead — corrupting every conformance test that expected a counter increment. The intercept has been removed; Duration's Add::add continues to dispatch through its Verum-side body + the time_duration_add intrinsic. Same defect surface as [[duration_single_field_record_unboxing_2026-05-27]].


Send and Sync

Marker protocols imported from core.base.protocols. Auto-derived for primitives, Heap<T>, Shared<T>, List<T>, Map<K,V>, Set<T>, Channel<T>, Mutex<T>, Maybe<T>, Result<T,E>, AtomicInt, AtomicBool under the appropriate T: Send / T: Send + Sync constraints.

type Send is protocol {}; // values can be transferred across threads
type Sync is protocol {}; // &T can be shared across threads

Auto-derivation table

TypeSend whenSync when
primitives (Int / Float / Bool / Byte / Char / Text)alwaysalways
Heap<T>T: SendT: Send + Sync
Shared<T>T: Send + SyncT: Send + Sync
List<T> / Set<T>T: SendT: Send + Sync
Map<K, V>K: Send, V: SendK: Send + Sync, V: Send + Sync
Channel<T>T: SendT: Send
Mutex<T>T: SendT: Send (the lock guarantees exclusion)
Maybe<T> / Result<T, E>members all Sendmembers all Send + Sync
AtomicInt / AtomicBoolalwaysalways

Opting out

type MyType<T>: !Sync = ...; // in a generic bound
fn requires_not_sync<T: !Sync>(x: T) { ... }

Prelude

public module prelude {
public mount super.{Mutex, MutexGuard, RwLock, AtomicInt, AtomicBool, Send, Sync};
}

Add to your file with:

mount core.sync.*; // no `prelude` submodule — `mod.vr`
// re-exports 35 names directly

Cross-references

  • base → Cell/RefCell — non-thread-safe equivalents (mutation without locking).
  • async — the executor that backs async-aware locks.
  • intrinsicsatomic_load_*, atomic_store_*, atomic_cas_*, atomic_fetch_*, fence, futex_wait, futex_wake, spinlock_*.
  • runtime — global runtime context bootstrap (uses Once).