From 52c5f747ba1fd074e03814f027fc218542c8bdb0 Mon Sep 17 00:00:00 2001 From: lxl66566 Date: Tue, 1 Sep 2026 13:02:11 +0800 Subject: [PATCH 1/6] Move stolen items with bulk segmented copies Split both ring ranges at the union of their physical wrap points and move the (at most 3) chunks with ptr::copy_nonoverlapping; small steals keep an inline scalar path, and loom falls back to the tracked per-element loop. Wraparound is exhaustively covered by new bulk steal/self-steal matrix tests. Signed-off-by: lxl66566 --- src/fifo.rs | 32 +++++++---- src/lib.rs | 1 + src/lifo.rs | 32 +++++++---- src/transfer.rs | 137 +++++++++++++++++++++++++++++++++++++++++++++++ tests/general.rs | 109 +++++++++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 20 deletions(-) create mode 100644 src/transfer.rs diff --git a/src/fifo.rs b/src/fifo.rs index aaf6700..d5289f7 100644 --- a/src/fifo.rs +++ b/src/fifo.rs @@ -585,11 +585,16 @@ impl Stealer { debug_or_loom_assert!(transfer_count <= dest_free_capacity); // Move all items but the last to the destination queue. - for offset in 0..transfer_count { - unsafe { - let item = self.queue.read_at(stealer_head.wrapping_add(offset)); - dest.queue.write_at(dest_tail.wrapping_add(offset), item); - } + unsafe { + crate::transfer::transfer_items( + &self.queue.buffer, + self.queue.mask, + stealer_head, + &dest.queue.buffer, + dest.queue.mask, + dest_tail, + transfer_count, + ); } // Make the moved items visible by updating the destination tail position. @@ -664,11 +669,18 @@ impl Stealer { debug_or_loom_assert!(transfer_count <= dest_free_capacity); // Move all items but the last to the destination queue. - for offset in 0..transfer_count { - unsafe { - let item = self.queue.read_at(stealer_head.wrapping_add(offset)); - dest.queue.write_at(dest_tail.wrapping_add(offset), item); - } + // + // Safety: see the `steal()` method. + unsafe { + crate::transfer::transfer_items( + &self.queue.buffer, + self.queue.mask, + stealer_head, + &dest.queue.buffer, + dest.queue.mask, + dest_tail, + transfer_count, + ); } // Read the last item. diff --git a/src/lib.rs b/src/lib.rs index 342042a..ba9f554 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,6 +66,7 @@ mod config; pub mod fifo; pub mod lifo; mod loom_exports; +mod transfer; /// Error returned when stealing is unsuccessful. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/lifo.rs b/src/lifo.rs index f429267..bd4fd8e 100644 --- a/src/lifo.rs +++ b/src/lifo.rs @@ -661,11 +661,16 @@ impl Stealer { debug_or_loom_assert!(transfer_count <= dest_free_capacity); // Move all items but the last to the destination queue. - for offset in 0..transfer_count { - unsafe { - let item = self.queue.read_at(old_head.wrapping_add(offset)); - dest.queue.write_at(dest_tail.wrapping_add(offset), item); - } + unsafe { + crate::transfer::transfer_items( + &self.queue.buffer, + self.queue.mask, + old_head, + &dest.queue.buffer, + dest.queue.mask, + dest_tail, + transfer_count, + ); } // Make the moved items visible by updating the destination tail position. @@ -732,11 +737,18 @@ impl Stealer { debug_or_loom_assert!(transfer_count <= dest_free_capacity); // Move all items but the last to the destination queue. - for offset in 0..transfer_count { - unsafe { - let item = self.queue.read_at(old_head.wrapping_add(offset)); - dest.queue.write_at(dest_tail.wrapping_add(offset), item); - } + // + // Safety: see the `steal()` method. + unsafe { + crate::transfer::transfer_items( + &self.queue.buffer, + self.queue.mask, + old_head, + &dest.queue.buffer, + dest.queue.mask, + dest_tail, + transfer_count, + ); } // Read the last item. diff --git a/src/transfer.rs b/src/transfer.rs new file mode 100644 index 0000000..30f3009 --- /dev/null +++ b/src/transfer.rs @@ -0,0 +1,137 @@ +//! Bulk transfer of stolen items between ring buffers. + +use core::mem::MaybeUninit; + +use crate::config::UnsignedShort; +use crate::loom_exports::cell::UnsafeCell; + +#[cfg(not(all(test, st3_loom)))] +use core::ptr; + +#[cfg(not(all(test, st3_loom)))] +use crate::loom_exports::debug_or_loom_assert; + +/// Maximum number of items moved by the inline scalar path of +/// [`transfer_items`] before falling back to a bulk copy. +#[cfg(not(all(test, st3_loom)))] +const SMALL_CHUNK: usize = 8; + +/// Bitwise-move `count` items from position `src_pos` of a ring buffer to +/// position `dst_pos` of another ring buffer. +/// +/// Splitting both ring ranges at the union of their physical wrap points +/// yields at most 3 chunks that are contiguous on both sides at once, so the +/// whole transfer lowers to at most 3 `ptr::copy_nonoverlapping` calls (SIMD +/// bulk copies). Chunks of at most [`SMALL_CHUNK`] items keep an inline +/// scalar loop since the fixed cost of a call would otherwise dominate. +/// +/// The items are *relocated*, not copied: the source slots are logically +/// uninitialized afterwards, exactly as with the per-element +/// `read_at`/`write_at` pair this helper replaces. +/// +/// # Safety +/// +/// - `count` must not exceed the number of live items at `src_pos` in the +/// source queue nor the spare capacity at `dst_pos` in the destination +/// queue. When both buffers are the same (self-stealing), this in +/// particular guarantees that the source and destination physical ranges +/// are disjoint. +/// - The moved items must not be read or dropped again from the source +/// queue. +#[cfg(not(all(test, st3_loom)))] +pub(crate) unsafe fn transfer_items( + src_buffer: &[UnsafeCell>], + src_mask: UnsignedShort, + src_pos: UnsignedShort, + dst_buffer: &[UnsafeCell>], + dst_mask: UnsignedShort, + dst_pos: UnsignedShort, + count: UnsignedShort, +) { + let cap = src_mask as usize + 1; + let dcap = dst_mask as usize + 1; + let src_idx = (src_pos & src_mask) as usize; + let dst_idx = (dst_pos & dst_mask) as usize; + let n = count as usize; + + debug_or_loom_assert!(cap == src_buffer.len() && dcap == dst_buffer.len()); + debug_or_loom_assert!(cap.is_power_of_two() && dcap.is_power_of_two()); + + let src_base = src_buffer.as_ptr().cast::(); + let dst_base = dst_buffer.as_ptr().cast::() as *mut T; + + // Fast path for transfers of at most one item (the most common steal + // size): no chunk computation needed. + if n <= 1 { + if n == 1 { + dst_base.add(dst_idx).write(src_base.add(src_idx).read()); + } + return; + } + + // Run length before the wrap point of each range; the sorted pair + // (lo, hi) is the union of both wrap points and defines up to 3 chunks + // that are contiguous on both sides at once. + let s = (cap - src_idx).min(n); + let d = (dcap - dst_idx).min(n); + let (lo, hi) = if s <= d { (s, d) } else { (d, s) }; + + // When self-stealing, check that the source and destination chunks are + // physically disjoint, as required by `copy_nonoverlapping`. + debug_or_loom_assert!({ + if !ptr::eq(src_buffer.as_ptr(), dst_buffer.as_ptr()) { + true + } else { + let chunks = [(0, lo), (lo, hi), (hi, n)]; + let mut disjoint = true; + for &(a_start, a_end) in &chunks { + let a0 = (src_idx + a_start) & (cap - 1); + let al = a_end - a_start; + for &(b_start, b_end) in &chunks { + let b0 = (dst_idx + b_start) & (dcap - 1); + let bl = b_end - b_start; + disjoint &= a0 + al <= b0 || b0 + bl <= a0; + } + } + disjoint + } + }); + + // Capacities are powers of two, so a single masked add per chunk yields + // the wrapped base index (each range wraps at most once). + for (start, end) in [(0, lo), (lo, hi), (hi, n)] { + let len = end - start; + if len == 0 { + continue; + } + let src_chunk = src_base.add((src_idx + start) & (cap - 1)); + let dst_chunk = dst_base.add((dst_idx + start) & (dcap - 1)); + if len <= SMALL_CHUNK { + for i in 0..len { + dst_chunk.add(i).write(src_chunk.add(i).read()); + } + } else { + ptr::copy_nonoverlapping(src_chunk, dst_chunk, len); + } + } +} + +/// Loom fallback: loom's `UnsafeCell` exposes no raw pointers, so the +/// transfer degenerates to the tracked per-element loop. +#[cfg(all(test, st3_loom))] +pub(crate) unsafe fn transfer_items( + src_buffer: &[UnsafeCell>], + src_mask: UnsignedShort, + src_pos: UnsignedShort, + dst_buffer: &[UnsafeCell>], + dst_mask: UnsignedShort, + dst_pos: UnsignedShort, + count: UnsignedShort, +) { + for offset in 0..count { + let item = src_buffer[(src_pos.wrapping_add(offset) & src_mask) as usize] + .with(|slot| slot.read().assume_init()); + dst_buffer[(dst_pos.wrapping_add(offset) & dst_mask) as usize] + .with_mut(|slot| slot.write(MaybeUninit::new(item))); + } +} diff --git a/tests/general.rs b/tests/general.rs index 7ddb06b..300e888 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -398,6 +398,115 @@ fn lifo_extend_overflow() { } } +macro_rules! bulk_steal { + ($flavor:ident, $rotate:ident, $is_fifo:literal, $test_name:ident) => { + // Exhaustively exercises bulk `steal` with all ring position + // alignments: independent rotations of the source and destination, + // various destination occupancies and stolen counts, including + // transfers crossing the physical wrap point of the source, of the + // destination, or both, with equal or different capacities. + #[test] + fn $test_name() { + // Reduced coverage under Miri to keep the test fast enough. + // + // (16, 16) exercises transfers larger than the inline scalar + // chunk limit, i.e. the actual bulk copy path. + let cap_pairs: &[(usize, usize)] = if cfg!(miri) { + &[(8, 8)] + } else { + &[(8, 8), (16, 8), (8, 16), (16, 16)] + }; + for &(src_cap, dst_cap) in cap_pairs { + let rotation_stride = if cfg!(miri) { 4 } else { 1 }; + for src_rotation in (0..src_cap).step_by(rotation_stride) { + for dst_rotation in (0..dst_cap).step_by(rotation_stride) { + for preload in 0..dst_cap { + for count in 1..=(dst_cap - preload).min(src_cap) { + let src = $flavor::Worker::>::new(src_cap); + let dst = $flavor::Worker::>::new(dst_cap); + + $rotate(&src, src_rotation); + $rotate(&dst, dst_rotation); + for i in 0..preload { + dst.push(TestValue::new(100 + i)).unwrap(); + } + for i in 0..count { + src.push(TestValue::new(i)).unwrap(); + } + + let stolen = src.stealer().steal(&dst, |_| usize::MAX).unwrap(); + assert_eq!(stolen, count); + assert!(src.pop().is_none()); + + let mut popped = Vec::new(); + while let Some(v) = dst.pop() { + popped.push(*v); + } + let expected: Vec = if $is_fifo { + (0..preload).map(|i| 100 + i).chain(0..count).collect() + } else { + (0..count) + .rev() + .chain((0..preload).rev().map(|i| 100 + i)) + .collect() + }; + assert_eq!(popped, expected); + } + } + } + } + } + } + }; +} +bulk_steal!(fifo, fifo_rotate, true, fifo_bulk_steal); +bulk_steal!(lifo, lifo_rotate, false, lifo_bulk_steal); + +macro_rules! bulk_self_steal { + ($flavor:ident, $rotate:ident, $is_fifo:literal, $test_name:ident) => { + // Bulk self-stealing moves items from the head to the tail of the + // same buffer, exercising the disjointness of the source and + // destination chunks with every alignment and split. The capacity + // is large enough for transfers to reach the bulk copy path. + #[test] + fn $test_name() { + const CAP: usize = 32; + let rotation_stride = if cfg!(miri) { 8 } else { 1 }; + + for rotation in (0..CAP).step_by(rotation_stride) { + for fill in 1..CAP { + let max_count = fill.min(CAP - fill); + for count in 1..=max_count { + let worker = $flavor::Worker::>::new(CAP); + + $rotate(&worker, rotation); + for i in 0..fill { + worker.push(TestValue::new(i)).unwrap(); + } + + let stolen = worker.stealer().steal(&worker, |_| count).unwrap(); + assert_eq!(stolen, count.min(max_count)); + + let mut popped = Vec::new(); + while let Some(v) = worker.pop() { + popped.push(*v); + } + // The `count` oldest items were moved to the tail. + let expected: Vec = if $is_fifo { + (count..fill).chain(0..count).collect() + } else { + (0..count).rev().chain((count..fill).rev()).collect() + }; + assert_eq!(popped, expected); + } + } + } + } + }; +} +bulk_self_steal!(fifo, fifo_rotate, true, fifo_bulk_self_steal); +bulk_self_steal!(lifo, lifo_rotate, false, lifo_bulk_self_steal); + macro_rules! multi_threaded_steal { ($flavor:ident, $test_name:ident) => { #[test] From d8a6858955d6420b0ee642f0956ad9ee85cc3c71 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 5 Sep 2026 21:30:36 +0700 Subject: [PATCH 2/6] test: hold the bulk transfer against the per-element reference The bulk path cannot run under loom, and the reason is legitimate: loom's `UnsafeCell` exposes no raw pointers, so `copy_nonoverlapping` is inexpressible there. The consequence is not legitimate. Two implementations of one contract now exist, loom exercises the one that does not ship, and nothing checks they agree, so a divergence in the bulk version would keep a green loom run beside it. The scalar version is now compiled under `test` as well as under loom, named `transfer_items_scalar`, and `bulk_matches_scalar` runs both over every combination of ring capacity, start position and count the booking step can produce, wrap points included, and asserts the destinations are identical. Planted and reverted, because a check that has never failed has not been shown to work. Dropping the ordering of the two wrap points, which is wrong exactly when the destination wraps before the source: - let (lo, hi) = if s <= d { (s, d) } else { (d, s) }; + let (lo, hi) = (s, d); cargo test --release --lib SIGBUS: access to undefined memory cargo +nightly miri test --lib error: Undefined Behavior: memory access failed: attempting to access 4 bytes, but got alloc45684+0x8 which is at or beyond the end of the allocation of size 8 bytes --> src/transfer.rs:111:17 --> src/transfer.rs:155:9 reverted: test result: ok. 1 passed Worth recording that the native failure is a memory fault rather than an assertion: a mis-split chunk reads out of bounds before it can produce a wrong value. So the differential test detects, and Miri localises. Neither alone gives both. --- src/transfer.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/src/transfer.rs b/src/transfer.rs index 30f3009..e386b94 100644 --- a/src/transfer.rs +++ b/src/transfer.rs @@ -116,10 +116,16 @@ pub(crate) unsafe fn transfer_items( } } -/// Loom fallback: loom's `UnsafeCell` exposes no raw pointers, so the -/// transfer degenerates to the tracked per-element loop. -#[cfg(all(test, st3_loom))] -pub(crate) unsafe fn transfer_items( +/// The per-element reference implementation. +/// +/// Under loom this *is* `transfer_items`: loom's `UnsafeCell` exposes no raw pointers, so +/// the bulk path cannot be expressed there. That leaves two implementations of one contract +/// with nothing checking they agree, which is why this is also compiled under `test` and +/// held against the bulk version by `bulk_matches_scalar` below. A divergence between them +/// would otherwise be invisible: loom would keep passing on the version that does not ship. +#[cfg(any(test, st3_loom))] +#[allow(dead_code)] +pub(crate) unsafe fn transfer_items_scalar( src_buffer: &[UnsafeCell>], src_mask: UnsignedShort, src_pos: UnsignedShort, @@ -135,3 +141,64 @@ pub(crate) unsafe fn transfer_items( .with_mut(|slot| slot.write(MaybeUninit::new(item))); } } + +#[cfg(all(test, st3_loom))] +pub(crate) use self::transfer_items_scalar as transfer_items; + +#[cfg(all(test, not(st3_loom)))] +mod tests { + use super::*; + use alloc::vec::Vec; + + fn buffer(n: usize) -> Vec>> { + (0..n) + .map(|_| UnsafeCell::new(MaybeUninit::uninit())) + .collect() + } + + /// The bulk transfer must move exactly what the per-element loop moves, for every + /// combination of ring sizes, start positions and counts that the booking step can + /// produce - including every wrap point, which is where a segmented copy can go wrong + /// and a scalar loop cannot. + #[test] + fn bulk_matches_scalar() { + for &src_cap in &[2usize, 4, 8, 16, 32] { + for &dst_cap in &[2usize, 4, 8, 16, 32] { + let (src_mask, dst_mask) = + (src_cap as UnsignedShort - 1, dst_cap as UnsignedShort - 1); + for src_pos in 0..(src_cap as UnsignedShort * 2) { + for dst_pos in 0..(dst_cap as UnsignedShort * 2) { + for count in 0..=(src_cap.min(dst_cap) as UnsignedShort) { + let src = buffer(src_cap); + for i in 0..src_cap { + src[i].with_mut(|s| unsafe { + s.write(MaybeUninit::new(0xC0DE_0000u32 + i as u32)) + }); + } + let bulk = buffer(dst_cap); + let scalar = buffer(dst_cap); + unsafe { + transfer_items( + &src, src_mask, src_pos, &bulk, dst_mask, dst_pos, count, + ); + transfer_items_scalar( + &src, src_mask, src_pos, &scalar, dst_mask, dst_pos, count, + ); + } + for offset in 0..count { + let idx = (dst_pos.wrapping_add(offset) & dst_mask) as usize; + let a = bulk[idx].with(|s| unsafe { s.read().assume_init() }); + let b = scalar[idx].with(|s| unsafe { s.read().assume_init() }); + assert_eq!( + a, b, + "bulk and scalar disagree: src_cap={src_cap} dst_cap={dst_cap} \ + src_pos={src_pos} dst_pos={dst_pos} count={count} offset={offset}" + ); + } + } + } + } + } + } + } +} From 962b4f3ffabf1079102a5c8edaa9122257909564 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 5 Sep 2026 21:42:59 +0700 Subject: [PATCH 3/6] chore: publish this fork as ps-st3 House convention is a ps- prefix on a forked crate, so the package is ps-st3. The library keeps the name st3, so `use st3::lifo::Worker` and the `st3_loom` cfg both work against the fork with no caller churn. --- Cargo.toml | 9 +++++++-- README.md | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e798655..d47fb52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "st3" +name = "ps-st3" # When incrementing version and releasing to crates.io: # - Update crate version in README.md # - Update CHANGELOG.md @@ -10,7 +10,7 @@ authors = ["Serge Barral "] edition = "2021" rust-version = "1.60" license = "MIT OR Apache-2.0" -repository = "https://github.com/asynchronics/st3" +repository = "https://github.com/pathscale/st3" readme = "README.md" description = """ A very fast lock-free, bounded, work-stealing LIFO queue. @@ -20,6 +20,11 @@ keywords = ["queue", "stack", "bounded", "lock-free", "work-stealing"] autotests = false autobenches = false +[lib] +# The crate is renamed but the library is not, so `use st3::...` and the +# `st3_loom` cfg both keep working unchanged against the fork. +name = "st3" + [dependencies] crossbeam-utils = { version = "0.8.14", default-features = false } diff --git a/README.md b/README.md index aa9cf1a..23138e3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # St³ — the Stealing Static Stack +> A PathScale fork of [asynchronics/st3](https://github.com/asynchronics/st3), published as +> `ps-st3`. The library is still named `st3`, so `use st3::lifo::Worker` is unchanged. +> It carries upstream PR #12 (bulk segmented copies on steal) ahead of a release, plus a +> differential test holding that bulk path against the per-element reference. + Lock-free, bounded, work-stealing queues with FIFO stealing and LIFO or FIFO semantic for the worker thread. @@ -34,7 +39,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -st3 = "0.4.1" +ps-st3 = "0.4.1" ``` From 2aff71f20a275314489737cc4c44c61630c81173 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 5 Sep 2026 21:51:14 +0700 Subject: [PATCH 4/6] ci: run on ubicloud, and add the release workflow Upstream's workflows were on ubuntu-latest; house runners are ubicloud. The six jobs it already had (check, test, loom, miri, lints, docs) are unchanged otherwise. Release guards on crates.io rather than on a version diff, so a re-run is a no-op and the first publish of ps-st3 does not need a bump to fire. --- .github/workflows/ci.yml | 12 ++++----- .github/workflows/publish.yml | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed96e88..8e0ccd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ env: jobs: check: name: Check - runs-on: ubuntu-latest + runs-on: ubicloud-standard-2 strategy: fail-fast: false matrix: @@ -32,7 +32,7 @@ jobs: test: name: Test suite - runs-on: ubuntu-latest + runs-on: ubicloud-standard-2 steps: - name: Checkout sources uses: actions/checkout@v4 @@ -45,7 +45,7 @@ jobs: loom: name: Test suite (Loom) - runs-on: ubuntu-latest + runs-on: ubicloud-standard-2 steps: - name: Checkout sources uses: actions/checkout@v4 @@ -61,7 +61,7 @@ jobs: miri: name: Miri - runs-on: ubuntu-latest + runs-on: ubicloud-standard-2 steps: - name: Checkout sources uses: actions/checkout@v4 @@ -78,7 +78,7 @@ jobs: lints: name: Lints - runs-on: ubuntu-latest + runs-on: ubicloud-standard-2 steps: - name: Checkout sources uses: actions/checkout@v4 @@ -96,7 +96,7 @@ jobs: docs: name: Docs - runs-on: ubuntu-latest + runs-on: ubicloud-standard-2 steps: - name: Checkout sources uses: actions/checkout@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7c4d9fe --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: Publish + +# Publishing is irreversible: a version can never be reused and yanking does not +# delete. The guard is crates.io itself rather than a diff against the previous +# commit, so it survives a squash merge, does not need a bump to make the first +# release, and cannot double-publish if it runs twice. +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + publish: + runs-on: ubicloud-standard-2 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + + - id: version + run: | + name=$(sed -nE '0,/^name = "(.*)"/s//\1/p' Cargo.toml) + version=$(sed -nE '0,/^version = "(.*)"/s//\1/p' Cargo.toml) + echo "name=$name" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + code=$(curl -s -o /dev/null -w '%{http_code}' \ + -A "$name-release-workflow" \ + "https://crates.io/api/v1/crates/$name/$version") + if [ "$code" = "200" ]; then + echo "$name $version is already on crates.io, nothing to do" + echo "publish=false" >> "$GITHUB_OUTPUT" + else + echo "$name $version is not on crates.io (HTTP $code), publishing" + echo "publish=true" >> "$GITHUB_OUTPUT" + fi + + # The gate runs on this commit too, but a release must not depend on job + # ordering, so it is re-run here rather than assumed. + - if: steps.version.outputs.publish == 'true' + run: cargo test --release + + - if: steps.version.outputs.publish == 'true' + run: cargo publish --token "$CARGO_REGISTRY_TOKEN" + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + - if: steps.version.outputs.publish == 'true' + run: | + tag="v${{ steps.version.outputs.version }}" + git tag "$tag" + git push origin "$tag" From 2a31b4bf0d26ff31f05931d53f364d69f3b3b9f5 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 5 Sep 2026 21:53:09 +0700 Subject: [PATCH 5/6] ci: a version bump is the release, same as ps-spsc --- .github/workflows/publish.yml | 41 +++++++++++++---------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7c4d9fe..6e1ea61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,51 +1,40 @@ name: Publish -# Publishing is irreversible: a version can never be reused and yanking does not -# delete. The guard is crates.io itself rather than a diff against the previous -# commit, so it survives a squash merge, does not need a bump to make the first -# release, and cannot double-publish if it runs twice. +# House scheme: a version bump on the default branch is the release. No tags, +# no manual step. Publishing is irreversible, so the trigger has to be +# something you cannot do by accident, and editing the version field is that. on: push: branches: [main] - workflow_dispatch: + paths: ['Cargo.toml'] jobs: publish: runs-on: ubicloud-standard-2 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 2 - uses: dtolnay/rust-toolchain@stable - id: version run: | - name=$(sed -nE '0,/^name = "(.*)"/s//\1/p' Cargo.toml) - version=$(sed -nE '0,/^version = "(.*)"/s//\1/p' Cargo.toml) - echo "name=$name" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - - code=$(curl -s -o /dev/null -w '%{http_code}' \ - -A "$name-release-workflow" \ - "https://crates.io/api/v1/crates/$name/$version") - if [ "$code" = "200" ]; then - echo "$name $version is already on crates.io, nothing to do" - echo "publish=false" >> "$GITHUB_OUTPUT" + new=$(sed -nE '0,/^version = "(.*)"/s//\1/p' Cargo.toml) + old=$(git show HEAD^:Cargo.toml | sed -nE '0,/^version = "(.*)"/s//\1/p') + if [ "$new" = "$old" ]; then + echo "version unchanged at $new, nothing to publish" + echo "changed=false" >> "$GITHUB_OUTPUT" else - echo "$name $version is not on crates.io (HTTP $code), publishing" - echo "publish=true" >> "$GITHUB_OUTPUT" + echo "version $old -> $new" + echo "changed=true" >> "$GITHUB_OUTPUT" fi # The gate runs on this commit too, but a release must not depend on job # ordering, so it is re-run here rather than assumed. - - if: steps.version.outputs.publish == 'true' + - if: steps.version.outputs.changed == 'true' run: cargo test --release - - if: steps.version.outputs.publish == 'true' + - if: steps.version.outputs.changed == 'true' run: cargo publish --token "$CARGO_REGISTRY_TOKEN" env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - - if: steps.version.outputs.publish == 'true' - run: | - tag="v${{ steps.version.outputs.version }}" - git tag "$tag" - git push origin "$tag" From 0c8ec3a1785a31a9d4d05c237e79624146a825cb Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 5 Sep 2026 22:40:21 +0700 Subject: [PATCH 6/6] chore: 0.4.2, the first release of this fork Upstream's 0.4.1 plus the bulk segmented steal it has not released, which measured +4.2% at 3 us and +9.6% at 16 us on our steal-heavy shape. Patch rather than minor: no API changed, the transfer path got faster. This commit is last on the branch so the release workflow sees the version move against its parent when the branch lands. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d47fb52..95ce04b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ name = "ps-st3" # - Update CHANGELOG.md # - Update if necessary copyright notice in LICENSE-MIT # - Create a "vX.Y.Z" git tag -version = "0.4.1" +version = "0.4.2" authors = ["Serge Barral "] edition = "2021" rust-version = "1.60"