cargo / async-lock / audit
cargo : async-lock @ 3.4.2
PE Patrick Elsen signed 2026-05-27 published 2026-05-27

Claims

concurrency-documentedconcurrency-impl-correctconcurrency-impl-documentedconcurrency-impl-safeconcurrency-impl-testedconcurrency-safedatastructure-impl-boundsdatastructure-impl-correctdatastructure-impl-safedatastructure-impl-testedhas-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignunsafe-documentedunsafe-minimalunsafe-safeunsafe-testeduses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

async-lock 3.4.2 implements async-aware Mutex, RwLock, Semaphore, Barrier, and OnceCell on top of event-listener. The unsafe is sound; three low-severity findings cover doc/test gaps (OnceCell reentrant-init deadlock, Mutex fairness gated to std+non-wasm, the only loom test is #[ignore]d).

Report

Subject

async-lock 3.4.2 is the synchronization-primitive crate of the smol-rs ecosystem. It exports Mutex, RwLock, Semaphore, Barrier, and OnceCell in async-aware form, plus matching _blocking methods so the same type can be used from both async and sync code paths. The waiting and wakeup mechanism is delegated to event-listener and event-listener-strategy; the locking logic, fairness policy, state machines, and unsafe arithmetic are implemented here. The crate is no_std-capable behind a default std feature and supports wasm32. MSRV is 1.85, edition 2021.

Methodology

Tools: openvet 0.6.0, ripgrep, diff, wc, manual reading. No tests were executed.

The audit covered every .rs file under contents/src/ end-to-end (lib.rs, mutex.rs, once_cell.rs, rwlock.rs, rwlock/raw.rs, rwlock/futures.rs, semaphore.rs, barrier.rs; about 4900 LOC). All five integration test files and tests/common/mod.rs were read in full to assess coverage, as was .github/workflows/ci.yml to see what the upstream pipeline actually runs. Metadata (Cargo.toml, Cargo.toml.orig, README.md, CHANGELOG.md) was read in full.

diff -rq contents vcs found only the expected cargo normalisation differences (Cargo.toml, Cargo.lock, .cargo_vcs_info.json, the Cargo.toml.orig file, and the publish-excluded .git, .github, .gitignore). Two source-text differences exist: a single bullet listing OnceCell is present in the VCS README.md and src/lib.rs doc preamble but removed in the published crate. The OnceCell type is still in the public API. These are documentation drift, not a supply-chain signal.

Results

The crate has no FFI, no network, filesystem, process, or environment surface, and no cryptography (uses-network=false, uses-filesystem=false, uses-exec=false, uses-environment=false, uses-crypto=false, uses-jit=false, uses-interpreter=false). It has no build script or proc macro and ships no binaries (has-build-exec=false, has-install-exec=false, has-binaries=false). It exists to implement concurrency primitives (impl-concurrency=true, uses-concurrency=true) and one data structure (impl-datastructure=true for OnceCell); it does not implement a parser, protocol, interpreter, JIT, algorithm, or crypto primitive (impl-parser=false, impl-protocol=false, impl-interpreter=false, impl-jit=false, impl-algorithm=false, impl-crypto=false). No malicious code was observed; is-benign=true.

The unsafe surface (uses-unsafe=true) lives in src/mutex.rs, src/once_cell.rs, src/rwlock.rs, src/rwlock/raw.rs, and src/rwlock/futures.rs. Each block carries a SAFETY: comment or, for unsafe fn, a # Safety doc clause. The blocks fall into a small set of patterns: unsafe impl Send/Sync on the public types with bounds that match the std mutex pattern (justified type-by-type in the annotations); UnsafeCell dereferences inside guard Deref/DerefMut where the guard's existence is the proof of access rights; MaybeUninit reads in OnceCell gated on a state-machine transition to Initialized via Release/Acquire; arithmetic on a packed (reader_count, WRITER_BIT) word in RawRwLock with explicit overflow guards (crate::abort() on reader counts above isize::MAX, line 73 of raw.rs); and the 'static lifetime fiction in UpgradeArc where the reference points into the heap allocation of an Arc co-stored in the same struct so movement of the outer struct does not invalidate it. The Send/Sync bounds were checked against what each guard exposes; MutexGuardArc::source is correctly restricted to T: Send to prevent the inner Arc from being cloned out and dropped on a thread that does not own T. All PinnedDrop impls correctly distinguish the cancelled-mid-acquire case (where they must release lock state they set) from the completed case (where the caller now owns a guard that will release on drop). I reviewed every unsafe block and consider them sound and minimal (unsafe-safe=true, unsafe-documented=true, unsafe-minimal=true).

Concurrency correctness was assessed by reading the state machines for each primitive and tracing the relevant memory orderings. Mutex packs a lock bit and a 30/62-bit starvation counter into one AtomicUsize; the "eventually fair" fallback path under starvation uses fetch_or against the packed state to claim the lock without resetting the starvation counter until take_mutex runs (and take_mutex decrements only when starved is set, balancing the fetch_add(2) at line 588). RwLock is built on a RawRwLock that uses the writer mutex from this crate to serialize writers and an (WRITER_BIT, reader_count) packed state for reader/writer arbitration; write-preference is enforced by readers refusing to acquire when WRITER_BIT is set (line 67 of raw.rs and the equivalent check in RawRead::poll). The orderings on the lock state are Acquire on loads that gate use of the protected data, Release on stores that publish release of the lock, and SeqCst for the writer-bit toggles in upgrade/downgrade_*/write_unlock/read_unlock paths and for the checks in RawWrite::poll that confirm there are no remaining readers. Semaphore is a straightforward CAS loop on a counter. Barrier uses a generation counter under the local Mutex to detect spurious wakeups across barrier generations. The thread-safety contract for every public type is documented (concurrency-safe=true, concurrency-documented=true, concurrency-impl-safe=true, concurrency-impl-documented=true).

OnceCell is the data structure (impl-datastructure=true, datastructure-impl-safe=true, datastructure-impl-bounds=true; get and set are O(1) with one waker queue traversal under contention). Its state-machine Guard correctly resets the cell to Uninitialized and notifies the next waiter if the initializer panics or returns Err, so a failed initialization does not poison the cell. Storage of T is UnsafeCell<MaybeUninit<T>> and every read is gated on observing Initialized via Acquire. The state-machine invariants hold (datastructure-impl-correct=true); however, the no-reentrant-initializer precondition that the state machine relies on is not documented, see finding 1.

Three findings, all low severity. Finding 1 (quality): OnceCell::get_or_init and friends deadlock if called reentrantly from the initializer closure, and that hazard is not mentioned in the public docs. Finding 2 (correctness): the Mutex "eventual fairness" guarantee is gated behind cfg(all(feature = "std", not(target_family = "wasm"))) and silently does not hold on no_std or wasm targets, despite the doc comment on Mutex not noting this limitation. This justifies concurrency-impl-correct=false. Finding 3 (quality): the only loom test in the repository is tests/loom.rs::barrier_smoke, which carries #[ignore]. The CI invocation does not pass --ignored, so loom coverage is effectively zero, and Mutex, RwLock, Semaphore, and OnceCell have no loom tests at all; this justifies concurrency-impl-tested=false and datastructure-impl-tested=false. Miri runs in CI against the integration tests, which gave me enough coverage of the unsafe blocks under realistic single-thread paths to assert unsafe-tested=true.

has-integration-tests=true (43 #[test] functions in tests/ plus doctests); has-unit-tests=false (no #[cfg(test)] mod tests in src/); has-fuzz-tests=false and has-property-tests=false (neither contents/fuzz/ nor any proptest!/quickcheck! invocation exists in the source tree).

Conclusion

async-lock 3.4.2 is sound. The unsafe is concentrated, well-commented, and correct on the code paths I traced. The Send/Sync bounds match what each guard actually exposes, the atomic orderings are appropriate for the synchronization role they play, and the future state machines correctly balance the locking state they set against the cancellation paths that must release it. The findings are documentation and test-coverage gaps rather than soundness bugs: a reentrant-initializer hazard on OnceCell that deserves a doc note, a fairness guarantee that quietly does not hold on no_std or wasm targets, and an effectively dormant loom test suite.

Findings(3)

FINDING-1 quality low

OnceCell reentrant initializer deadlock is not documented

OnceCell::get_or_init, get_or_try_init, and their _blocking variants do not document that calling the same OnceCell reentrantly from inside the initializer closure deadlocks.

The internal state machine in initialize_or_wait (src/once_cell.rs:602-680) transitions to State::Initializing and then awaits an active_initializers listener if it observes that state. A reentrant call from inside the initializer will hit the State::Initializing branch and call strategy.wait(listener) (or its blocking equivalent), but nothing will ever notify that listener because the notifying party is the same suspended initializer. In the async case this leaves the task hanging forever; in get_or_init_blocking it parks the OS thread.

Comparable APIs (tokio::sync::OnceCell, async_once_cell::OnceCell, std::sync::OnceLock) document this hazard explicitly. The doc comments in async-lock only warn about deadlocks "in an asynchronous context" for the _blocking variants; they do not mention reentrant initializer calls for either variant.

This is a documentation gap, not a soundness bug. The state machine itself is sound: the Guard struct on src/once_cell.rs:686-697 resets state to Uninitialized if the initializer panics or returns Err, so the cell remains usable across initializer failures. The hazard only arises when the user code violates the implicit no-reentrancy precondition.

Cited by datastructure-impl-correct as a documentation caveat (the invariant holds, but the contract is under-documented).

FINDING-2 correctness low

Mutex eventual-fairness guarantee does not hold on no_std or wasm targets

Mutex documents "eventual fairness" via a 0.5 ms starvation threshold (src/mutex.rs:22-24). This guarantee only holds when the std feature is enabled and the target is not wasm, because the timing fallback is gated:

// src/mutex.rs:526-527
#[cfg(all(feature = "std", not(target_family = "wasm")))]
let start = *this.start.start.get_or_insert_with(Instant::now);

// src/mutex.rs:580-583
#[cfg(all(feature = "std", not(target_family = "wasm")))]
if start.elapsed() > Duration::from_micros(500) {
    break;
}

On no_std or wasm builds the AcquireSlow::poll_with_strategy hot loop has no break path that leads to the "starved" branch. A waiter that loses every CAS race will spin (technically: re-register the listener and wait again) indefinitely while newer contenders take the lock. The fairness contract documented on Mutex therefore does not hold on those targets.

This is a documentation/correctness gap. The behaviour without the time-based break is still correct mutual exclusion; it just lacks the documented fairness property under adversarial scheduling.

Justifies concurrency-impl-correct.

FINDING-3 quality low

Only loom test is #[ignore]d; no loom coverage for Mutex/RwLock/Semaphore/OnceCell

The only loom test in the repository (tests/loom.rs) covers Barrier and carries #[ignore] on line 8:

#![cfg(loom)]
...
#[ignore]
#[test]
fn barrier_smoke() { loom::model(|| { ... }); }

The CI job loom (.github/workflows/ci.yml:91-101) invokes cargo test --release --test loom --features loom with RUSTFLAGS=--cfg=loom. It does not pass --ignored, so the test is compiled but not executed. There is no other loom::model block in the source tree; Mutex, RwLock, Semaphore, and OnceCell have no loom coverage at all.

The crate plumbs loom support through src/lib.rs:112-116 and uses crate::sync::atomic indirection for all atomic types, so adding loom tests would not require structural changes. The infrastructure is present but unused.

Miri is exercised in CI (.github/workflows/ci.yml:103-113) against the integration test suite, which covers a useful subset of single-thread correctness questions but does not exhaustively explore concurrent interleavings.

Justifies concurrency-impl-tested=false and datastructure-impl-tested=false.

Annotations(4)

src/mutex.rs

The unsafe surface of mutex.rs falls into three groups, all sound:

  1. unsafe impl Send/Sync for Mutex, Lock, LockArc, MutexGuard, MutexGuardArc (lines 56-57, 341-342, 401-402, 638-639, 700-701). The bounds (T: Send for Send/Sync on Mutex; the guards' bounds match the std::sync::Mutex pattern) are the standard mutex pattern and are correct. MutexGuardArc::source is restricted to T: Send (line 722) to prevent Arc<Mutex<T>> from being cloned out and dropped on a thread other than the one that owns T, which would be UB for a non-Send T.

  2. pub(crate) unsafe fn unlock_unchecked (line 203). Documented invariant: caller must hold the lock and not own a guard that will also unlock. Only callers are MutexGuard::Drop (line 664) and MutexGuardArc::Drop (line 733), both immediately after the guard's lifetime ends. Also called by RawRwLock after forgeting a writer mutex guard.

  3. Guard Deref/DerefMut (lines 686, 692, 754, 760) dereference the UnsafeCell<T>. Sound because the guard's existence proves exclusive access via the mutex protocol.

Justifies unsafe-safe, unsafe-documented, unsafe-minimal.

src/once_cell.rs

OnceCell uses UnsafeCell<MaybeUninit<T>> plus an atomic State (Uninitialized=0, Initializing=1, Initialized=2). All unsafe { get_unchecked() } call sites first observe State::Initialized via an Acquire load (or via debug_assert!(self.is_initialized()) after waiting on a listener that is only notified once state has been set to Initialized with Release), which pairs with the Release store at line 661-662 and gives a happens-before relationship to the ptr::write at line 658.

get_mut and take (lines 200-239) use WithMut, which under non-loom builds delegates to AtomicUsize::get_mut. Because they take &mut self, no concurrent access can race the read.

Drop (lines 769-779) reads the state through with_mut and drops the inner T in place only if state is Initialized. Sound because &mut self disallows concurrent access.

The initialize_or_wait state machine uses a Guard (lines 686-697) to reset state to Uninitialized and notify the next waiter if the initializer panics or returns Err. On success the guard is forget-ed (line 660) and state is moved directly to Initialized via a Release store. The reentrancy hazard this state machine has on user-provided closures is described in finding 1.

Justifies unsafe-safe, unsafe-documented, datastructure-impl-safe, datastructure-impl-correct.

src/rwlock/futures.rs

This module wraps the type-erased RawRwLock futures with futures that carry the value pointer for each guard variant (read, upgradable_read, write, upgrade, and their Arc counterparts). The unsafe impl Send/Sync lines justify each combination of T bounds against what the resulting guard exposes:

  • Read only exposes &T; sending requires T: Sync.
  • Write exposes &mut T; sending requires T: Send. Sync still requires T: Sync because the lock could be polled from a shared reference.
  • UpgradableRead* requires T: Send + Sync for Send because the future may be upgraded into a writer.
  • The *Arc variants additionally need T: Send + Sync because Arc<RwLock<T>> itself requires both to be Send/Sync.

The UpgradeArc type stores RawUpgrade<'static> alongside the Arc whose heap data the reference points into; see the ann-rwlock-raw.md annotation for why the lifetime fiction is sound. The PinnedDrop (lines 426-445) correctly distinguishes the is_ready case (the Arc has already been moved out into the resulting guard, do not drop again) from the cancelled case (drop the Arc to balance the ManuallyDrop::new from the call site).

Justifies unsafe-safe, unsafe-documented, concurrency-impl-safe.

src/rwlock/raw.rs

RawRwLock is the unsafe core of the reader-writer lock; it stores no T and exposes unsafe methods that assume the caller holds the corresponding lock state. The state word packs WRITER_BIT (LSB) and a reader count in the upper bits (ONE_READER = 2). Reader counts are bounded by isize::MAX (raw.rs:72-73, 112-114, 311-312, 380-382), with crate::abort() on overflow.

The unsafe fn methods (try_upgrade, upgrade, downgrade_*, read_unlock, upgradable_read_unlock, write_unlock, lines 179-276) each carry a # Safety clause naming the required pre-condition. All callers inside this crate hold the appropriate guard at the point of call and consume or forget the guard so the state transition is balanced.

The RawWrite future's PinnedDrop (lines 417-428) calls write_unlock only when the state is WaitingReaders, i.e. WRITER_BIT was set but the caller has not yet been given ownership of the lock. Acquiring holds the inner mutex acquisition future, whose own drop handles release; Acquired means the caller has the guard, which will release on drop.

RawUpgrade's PinnedDrop (lines 520-531) calls write_unlock only while lock.is_some(). The poll path sets lock = None via take() after returning Ready, so the unlock is not called once ownership has been handed to the caller.

The 'static lifetime lie inside UpgradeArcInner (rwlock/futures.rs:406-446) is documented at the point of use: the RawUpgrade<'static> references the heap data of the Arc<RwLock<T>> co-stored in the same struct. Moving the outer struct does not move the heap allocation, so the reference remains valid for the struct's lifetime.

Justifies unsafe-safe, unsafe-documented, unsafe-minimal, concurrency-impl-safe.