From fd4b059b1e5e99ccd2a54cf99d56e4e043f5b922 Mon Sep 17 00:00:00 2001 From: David Roundy Date: Sun, 18 Oct 2026 12:10:21 -0700 Subject: [PATCH 1/4] Make the crate no_std, with std as a default feature AppendOnlyVec only needs core and alloc. The one use of std is yielding to the operating system when a push has spun for a long time; that is now behind a default `std` feature, and without it the crate is no_std and always spins. An optional `portable-atomic` feature uses that crate's atomics, for targets such as thumbv6m-none-eabi that have no compare-and-swap. CI checks both no_std targets. This lets internment's `intern` feature, which uses AppendOnlyVec, build without std. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/rust.yml | 10 ++++++ Cargo.toml | 9 ++++++ src/lib.rs | 62 ++++++++++++++++++++++++++------------ 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 48fcdc9..4f85edb 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -50,6 +50,16 @@ jobs: with: command: test args: --all-features + no_std: + name: Check without std + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: "x86_64-unknown-none, thumbv6m-none-eabi" + - run: cargo check --no-default-features --target x86_64-unknown-none + - run: cargo check --no-default-features --target thumbv6m-none-eabi --features portable-atomic,portable-atomic/critical-section # docsrs: # name: Generate docs.rs # runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 6473ac9..421ff2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,16 @@ categories = ["data-structures", "concurrency"] keywords = ["frozen", "data-structure"] +[features] +default = ["std"] +# Yield to the operating system when a push has waited a long time for +# another. Without it the crate is `no_std` (it still needs `alloc`). +std = [] + [dependencies] +# The `portable-atomic` feature: use that crate's atomics, for targets that +# lack compare-and-swap. +portable-atomic = { version = "1", default-features = false, optional = true } [dev-dependencies] diff --git a/src/lib.rs b/src/lib.rs index f9834a2..a2cfee3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,11 +33,31 @@ //! } //! assert_eq!(V.len(), 1000); //! ``` - -use std::cell::UnsafeCell; -use std::ops::{Index, IndexMut}; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; +//! +//! ### `no_std` +//! +//! This crate needs only `core` and `alloc`. The `std` feature, which is on by +//! default, lets a thread that has waited a long time for another to finish a +//! push yield to the operating system instead of continuing to spin; without +//! it (`default-features = false`) the crate is `no_std` and always spins. +//! +//! On targets without atomic compare-and-swap (such as `thumbv6m-none-eabi`), +//! the `portable-atomic` feature uses the `portable-atomic` crate's atomics +//! instead of `core`'s. As that crate explains, such targets also need one of +//! its features that says how to provide them (for instance `critical-section`). + +#![cfg_attr(not(any(feature = "std", test)), no_std)] + +extern crate alloc; + +use alloc::vec::Vec; +use core::cell::UnsafeCell; +use core::ops::{Index, IndexMut}; +#[cfg(not(feature = "portable-atomic"))] +use core::sync::atomic::AtomicUsize; +use core::sync::atomic::Ordering; +#[cfg(feature = "portable-atomic")] +use portable_atomic::AtomicUsize; pub struct AppendOnlyVec { count: AtomicUsize, reserved: AtomicUsize, @@ -47,7 +67,7 @@ pub struct AppendOnlyVec { unsafe impl Send for AppendOnlyVec {} unsafe impl Sync for AppendOnlyVec {} -const BITS: usize = std::mem::size_of::() * 8; +const BITS: usize = core::mem::size_of::() * 8; #[cfg(target_arch = "x86_64")] const BITS_USED: usize = 48; @@ -74,17 +94,19 @@ const fn bin_size(array: u32) -> usize { fn spin_wait(failures: &mut usize) { *failures += 1; - if *failures <= 3 { + if *failures <= 3 || cfg!(not(feature = "std")) { // If there haven't been many failures yet, then we optimistically - // spinloop. - for _ in 0..(1 << *failures) { - std::hint::spin_loop(); + // spinloop. Without `std` there is no scheduler to yield to, so we + // always spin. + for _ in 0..(1 << (*failures).min(3)) { + core::hint::spin_loop(); } } else { // If there have been many failures, then continuing to spinloop will // probably just waste CPU, and whoever we are waiting for has been // preempted then spinning could actively delay completion of the task. // So instead, we cooperatively yield to the OS scheduler. + #[cfg(feature = "std")] std::thread::yield_now(); } } @@ -128,8 +150,8 @@ impl AppendOnlyVec { self.count.load(Ordering::Acquire) } - fn layout(&self, array: u32) -> std::alloc::Layout { - std::alloc::Layout::array::(bin_size(array)).unwrap() + fn layout(&self, array: u32) -> alloc::alloc::Layout { + alloc::alloc::Layout::array::(bin_size(array)).unwrap() } /// Internal-only function requests a slot and puts data into it. /// @@ -146,7 +168,7 @@ impl AppendOnlyVec { // is determined in the self.layout method, which needs to be // consistent with the indices function. let layout = self.layout(array); - let ptr = unsafe { std::alloc::alloc(layout) } as *mut T; + let ptr = unsafe { alloc::alloc::alloc(layout) } as *mut T; unsafe { *self.data[array as usize].get() = ptr; } @@ -226,7 +248,7 @@ impl AppendOnlyVec { self.count.store(idx + 1, Ordering::Relaxed); idx } - const EMPTY: UnsafeCell<*mut T> = UnsafeCell::new(std::ptr::null_mut()); + const EMPTY: UnsafeCell<*mut T> = UnsafeCell::new(core::ptr::null_mut()); /// Allocate a new empty array pub const fn new() -> Self { AppendOnlyVec { @@ -281,11 +303,11 @@ impl AppendOnlyVec { vec } } -impl std::fmt::Debug for AppendOnlyVec +impl core::fmt::Debug for AppendOnlyVec where - T: std::fmt::Debug, + T: core::fmt::Debug, { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_list().entries(self.iter()).finish() } } @@ -333,7 +355,7 @@ impl Drop for AppendOnlyVec { // with the write in `self.push`. let ptr = unsafe { *self.data[array as usize].get() }; unsafe { - std::ptr::drop_in_place(ptr.add(offset)); + core::ptr::drop_in_place(ptr.add(offset)); } } // Now we will free all the arrays. @@ -343,7 +365,7 @@ impl Drop for AppendOnlyVec { let ptr = unsafe { *self.data[array as usize].get() }; if !ptr.is_null() { let layout = self.layout(array); - unsafe { std::alloc::dealloc(ptr as *mut u8, layout) }; + unsafe { alloc::alloc::dealloc(ptr as *mut u8, layout) }; } else { break; } @@ -363,7 +385,7 @@ where /// An `Iterator` for the values contained in the `AppendOnlyVec` #[derive(Debug)] -pub struct IntoIter(std::vec::IntoIter); +pub struct IntoIter(alloc::vec::IntoIter); impl Iterator for IntoIter { type Item = T; From bdf385d828ef6e8d1c6298728431368841712897 Mon Sep 17 00:00:00 2001 From: David Roundy Date: Sun, 18 Oct 2026 13:42:18 -0700 Subject: [PATCH 2/4] Address review of no_std support - Define BITS_USED for 16-bit targets, which no_std makes reachable. - Saturate the spin_wait failure counter, since without std a waiter can spin long enough to overflow it on 32-bit targets. - Document that a no_std push must not preempt another push to the same vec (e.g. from an interrupt handler), and that std users who disable default features should re-enable std. - Run the tests in CI with std disabled, with and without portable-atomic. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/rust.yml | 2 ++ src/lib.rs | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4f85edb..db7f569 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -60,6 +60,8 @@ jobs: targets: "x86_64-unknown-none, thumbv6m-none-eabi" - run: cargo check --no-default-features --target x86_64-unknown-none - run: cargo check --no-default-features --target thumbv6m-none-eabi --features portable-atomic,portable-atomic/critical-section + - run: cargo test --no-default-features + - run: cargo test --no-default-features --features portable-atomic # docsrs: # name: Generate docs.rs # runs-on: ubuntu-latest diff --git a/src/lib.rs b/src/lib.rs index a2cfee3..6e19cdb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,6 +40,13 @@ //! default, lets a thread that has waited a long time for another to finish a //! push yield to the operating system instead of continuing to spin; without //! it (`default-features = false`) the crate is `no_std` and always spins. +//! If you turn off default features on a platform that does have `std`, turn +//! `std` back on to keep that behavior. +//! +//! Without `std`, a push that finds another push unfinished spins until it +//! completes. So never push from an interrupt handler, or from a task that +//! can preempt another task's push without letting it run, to a vec that the +//! interrupted code also pushes to: the handler would spin forever. //! //! On targets without atomic compare-and-swap (such as `thumbv6m-none-eabi`), //! the `portable-atomic` feature uses the `portable-atomic` crate's atomics @@ -75,6 +82,8 @@ const BITS_USED: usize = 48; const BITS_USED: usize = 64; #[cfg(target_pointer_width = "32")] const BITS_USED: usize = 32; +#[cfg(target_pointer_width = "16")] +const BITS_USED: usize = 16; // This takes an index into a vec, and determines which data array will hold it // (the first return value), and what the index will be into that data array @@ -93,7 +102,8 @@ const fn bin_size(array: u32) -> usize { } fn spin_wait(failures: &mut usize) { - *failures += 1; + // Saturate, since a long enough wait would overflow a 32-bit counter. + *failures = failures.saturating_add(1); if *failures <= 3 || cfg!(not(feature = "std")) { // If there haven't been many failures yet, then we optimistically // spinloop. Without `std` there is no scheduler to yield to, so we From 397569503e080c71628f8dcb053132e322e4aa04 Mon Sep 17 00:00:00 2001 From: David Roundy Date: Sun, 18 Oct 2026 13:42:40 -0700 Subject: [PATCH 3/4] Split spin_wait into std and no_std versions Instead of one function that mixes cfg! in its condition, a cfg attribute on the yield, and a min(3) that only the no_std path needs, define spin_wait once for each configuration. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/lib.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6e19cdb..123f78a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,14 +101,14 @@ const fn bin_size(array: u32) -> usize { (1 << 3) << array } +#[cfg(feature = "std")] fn spin_wait(failures: &mut usize) { // Saturate, since a long enough wait would overflow a 32-bit counter. *failures = failures.saturating_add(1); - if *failures <= 3 || cfg!(not(feature = "std")) { + if *failures <= 3 { // If there haven't been many failures yet, then we optimistically - // spinloop. Without `std` there is no scheduler to yield to, so we - // always spin. - for _ in 0..(1 << (*failures).min(3)) { + // spinloop. + for _ in 0..(1 << *failures) { core::hint::spin_loop(); } } else { @@ -116,11 +116,20 @@ fn spin_wait(failures: &mut usize) { // probably just waste CPU, and whoever we are waiting for has been // preempted then spinning could actively delay completion of the task. // So instead, we cooperatively yield to the OS scheduler. - #[cfg(feature = "std")] std::thread::yield_now(); } } +/// Without `std` there is no scheduler to yield to, so we always spin, backing +/// off up to 8 spins per call. +#[cfg(not(feature = "std"))] +fn spin_wait(failures: &mut usize) { + *failures = (*failures + 1).min(3); + for _ in 0..(1 << *failures) { + core::hint::spin_loop(); + } +} + #[test] fn test_indices() { for i in 0..32 { From 0a83d685f5b383a19ebcb2f941115efc119770cd Mon Sep 17 00:00:00 2001 From: David Roundy Date: Sun, 18 Oct 2026 13:53:08 -0700 Subject: [PATCH 4/4] CI: check a 16-bit target, and fix the MSRV job Add a job that checks the crate on msp430-none-elf, so the 16-bit BITS_USED setting is compiled. It is a tier 3 target, so it needs nightly and -Zbuild-std. The MSRV job ran cargo test on Rust 1.56, which builds the dev-dependencies, and parking_lot_core and libc now need 1.65+, so that job failed on main too. Check only the library there instead, with default, all, and no default features. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/rust.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index db7f569..072d506 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -62,6 +62,16 @@ jobs: - run: cargo check --no-default-features --target thumbv6m-none-eabi --features portable-atomic,portable-atomic/critical-section - run: cargo test --no-default-features - run: cargo test --no-default-features --features portable-atomic + sixteen_bit: + name: Check on a 16-bit target + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # msp430-none-elf is a tier 3 target, so core must be built from source. + - uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + - run: cargo check -Zbuild-std=core,alloc --no-default-features --target msp430-none-elf --features portable-atomic # docsrs: # name: Generate docs.rs # runs-on: ubuntu-latest @@ -97,9 +107,11 @@ jobs: profile: minimal toolchain: ${{ matrix.rust }} override: true - - uses: actions-rs/cargo@v1 - with: - command: test + # Only the library is checked: the dev-dependencies used by the tests + # and benchmarks need a newer compiler. + - run: cargo check + - run: cargo check --all-features + - run: cargo check --no-default-features miri-test: runs-on: ubuntu-latest strategy: