From 5c9f1d8d5db7d22a944a37de2efc24a6a7aadd84 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 03:06:06 -0400 Subject: [PATCH 01/87] Explore bounded CountedSignal saturation --- AGENTS.md | 27 ++- CHANGELOG.md | 8 + README.md | 40 +++- docs/proposals/counted-signal.md | 69 +++++++ scripts/codesize.sh | 19 +- scripts/codesize/baseline.tsv | 16 ++ scripts/codesize/src/lib.rs | 13 ++ scripts/cycles.sh | 1 + scripts/cycles/src/main.rs | 22 ++- src/counted_signal.rs | 310 +++++++++++++++++++++++++++++++ src/lib.rs | 6 +- src/loom_tests.rs | 56 +++++- 12 files changed, 566 insertions(+), 21 deletions(-) create mode 100644 src/counted_signal.rs diff --git a/AGENTS.md b/AGENTS.md index 4aca151..09d7ad1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,10 +26,13 @@ prose when it disagrees. **ph-eventing** provides stack-allocated ring buffers for no-std embedded targets. -It ships three primitives: +It ships four primitives: - **`RingBuf`** — a single-owner ring buffer (no atomics, `&mut` access). Ideal for local event logs, sample windows, and single-context collection. - **`SeqRing`** — a lock-free SPSC ring that **overwrites** old entries. Designed for high-rate telemetry where a fast producer and a potentially slower consumer run on different contexts. - **`EventBuf`** — a lock-free SPSC ring with **backpressure**. `push` returns `Err(val)` when full, so the producer always knows when delivery fails. +- **`CountedSignal`** — an exploratory saturating SPSC count for identical, + payload-free events. The sole producer handle makes exact bounded saturation + possible without a CAS retry loop. **Key characteristics:** - Zero runtime dependencies (`portable-atomic` is optional; `loom` is a dev-dependency gated on `--cfg loom`). Verify with `cargo tree` — it must print the crate alone. @@ -138,6 +141,7 @@ ph-eventing/ ├── lib.rs # Crate root, public exports, doctests ├── macros.rs # static_spsc! -- declarative static bring-up ├── event_buf.rs # Bounded SPSC event buffer with backpressure + ├── counted_signal.rs # Saturating payload-free SPSC counter ├── ring.rs # Single-owner stack-allocated ring buffer ├── seq_ring.rs # Lock-free SPSC overwrite ring with sequence tracking ├── sync.rs # Atomic/cell shim — swaps in Loom's primitives under --cfg loom @@ -159,6 +163,9 @@ ph-eventing/ | `EventBuf` | Bounded SPSC ring with backpressure (push returns `Result`) | | `event_buf::Producer<'a, T, N>` | EventBuf write handle; `push(T) -> Result<(), T>` | | `event_buf::Consumer<'a, T, N>` | EventBuf read handle; `pop() -> Option`, `peek()`, `drain()` | +| `CountedSignal` | Saturating count for payload-free SPSC events | +| `counted_signal::Producer<'a>` | Sole incrementing handle; bounded load plus conditional RMW | +| `counted_signal::Consumer<'a>` | Sole taking handle; `swap(0)` partitions count epochs | | `Sink` | Trait — accept events via `try_push(&mut self, T) -> Result<(), Error>` | | `Source` | Trait — yield events via `try_pop(&mut self) -> Option` | | `Link` | Trait — blanket impl for any `Sink + Source` | @@ -175,6 +182,16 @@ The `SeqRing` implementation uses careful atomic ordering for thread safety: `RingBuf` uses no atomics — it is a plain struct with `&mut self` mutation. +### Memory Ordering Strategy (CountedSignal) + +- The sole producer performs a Relaxed load and, below `u32::MAX`, one Relaxed + `fetch_add`. The consumer performs a Relaxed `swap(0)`. +- Relaxed is sufficient because there is no payload publication; the atomic's + modification order alone partitions increments between take epochs. +- Producer exclusivity is load-bearing. Between its load and RMW, only the + consumer can write and it can only lower the count, so the RMW cannot wrap. + Never make the producer handle `Sync` without replacing this algorithm. + ### Memory Ordering Strategy (EventBuf) `EventBuf` uses a classic Lamport SPSC queue pattern: @@ -657,7 +674,7 @@ measures the other half of the determinism claim — that the hot paths cost a Measured in the reference environment — `scripts/verify/Dockerfile` (rustc 1.92.0, Debian trixie qemu-system-arm 10.0.x, thumbv7m / Cortex-M3), reproduce with `./scripts/verify.sh cycles`. The counts are deterministic *per -environment*, not universal: a 10.2 QEMU build shifted two of the eighteen +environment*, not universal: a 10.2 QEMU build shifted two measured regions regions by exactly one instruction (trace boundary attribution, not codegen). Cross-environment diffs of ±1 are noise; compare inside the image. @@ -698,7 +715,7 @@ verified deterministic by diffing two runs. **Two traps, both hit during development:** - **Markers must embed a unique immediate.** With identical `nop`-only bodies - the linker folded all nineteen onto one address, the runner saw a single + the linker folded all markers onto one address, the runner saw a single label, and the output was silently empty — it looked like the probe measured nothing rather than like a bug. `cycles.sh` now fails loudly if the count of distinct marker addresses does not match the count of markers. @@ -865,7 +882,7 @@ cargo test **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -and `src/traits.rs`. Total: 69 unit tests + 11 doctests, plus 3 `compile_fail` +and `src/traits.rs`. Total: 75 unit tests + 11 doctests, plus 3 `compile_fail` doctests pinning the `N == 0` rejection (`E0080`) on all three types. ## Code Conventions @@ -883,6 +900,8 @@ doctests pinning the `N == 0` rejection (`E0080`) on all three types. - `T: Copy` required by `RingBuf`, `SeqRing`, and `EventBuf` for value-copy returns - `T: Send` required for `SeqRing` and `EventBuf` to be `Sync` - Unsafe code is confined to `MaybeUninit` / `UnsafeCell` slot access in all three buffers +- `CountedSignal` handles are `Send + !Sync`; the producer's `!Sync` property + is part of the saturation proof, not merely API uniformity - No panics in hot paths. All three `new()` reject `N == 0` with a **const** assertion, so a zero-capacity buffer fails the build rather than panicking — which also means no test can cover the rejection, and the const assertion is diff --git a/CHANGELOG.md b/CHANGELOG.md index 83507f5..6310403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added +- Exploratory `CountedSignal`: a payload-free SPSC counter with a bounded + `increment`, atomic `take_count`, exact `u32` saturation, and observable + saturation. Loom models pin both ordinary take partitioning and the + saturation-boundary interleaving; cycle and code-size probes expose the + remaining cost decision. The proposal records why exact bounded saturation + depends on retaining a sole `Send + !Sync` producer handle. + ### Documentation - `RingBuf::new`'s docs no longer mention a `pop` method the type does not have — `pop` was deliberately rejected (its data loss would be unreportable under overwrite; see the worked diff --git a/README.md b/README.md index 348ed85..6cbdd50 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ Stack-allocated ring buffers for no-std embedded targets. | [`RingBuf`](#ringbuf) | Single-owner ring buffer — simple, no atomics, `&mut` access. | | [`SeqRing`](#seqring) | Lock-free SPSC ring that **overwrites** old entries (lossy, high-throughput). | | [`EventBuf`](#eventbuf) | Lock-free SPSC ring with **backpressure** — rejects pushes when full. | +| [`CountedSignal`](#countedsignal) | Saturating SPSC count for identical, payload-free events. | -All three are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Copy`. +All four are fixed-size, `#![no_std]`, and zero-allocation. The buffers are +generic over `T: Copy`; `CountedSignal` carries no payload. ## What this optimises for @@ -131,6 +133,26 @@ assert_eq!(consumer.pop(), Some(1)); assert!(producer.push(3).is_ok()); // space freed ``` +### CountedSignal + +An exploratory saturating count for repeated events whose payload and ordering +do not matter. The sole producer is load-bearing: it permits exact saturation +with a bounded load plus conditional `fetch_add`, without a CAS retry loop. + +```rust +use ph_eventing::CountedSignal; + +let signal = CountedSignal::new(); +let producer = signal.try_producer().expect("no producer taken yet"); +let consumer = signal.try_consumer().expect("no consumer taken yet"); + +producer.increment(); +producer.increment(); +let snapshot = consumer.take_count(); +assert_eq!(snapshot.count(), 2); +assert!(!snapshot.is_saturated()); +``` + ### Common Traits All producers implement `Sink` and all consumers implement `Source`, @@ -211,15 +233,21 @@ them is a runtime step and always will be. - `drain(max, hook)` consumes up to `max` items through a callback and returns the count. - No data is silently lost — the producer always knows when the buffer cannot accept more. +### CountedSignal +- Counts below `u32::MAX` are exact; the counter saturates rather than wrapping. +- `take_count` atomically clears the counter and reports whether it saturated. +- A concurrent increment belongs wholly to the current take or the next one. +- The sole `Send + !Sync` producer handle is part of the correctness contract. + ## Safety and Concurrency - `RingBuf` has no atomics and no interior mutability — standard Rust borrow rules apply. It stores slots as `MaybeUninit` and reads only live entries, so it does contain `unsafe`. -- `SeqRing` and `EventBuf` are SPSC by design: exactly one producer and one consumer may be +- `SeqRing`, `EventBuf`, and `CountedSignal` are SPSC by design: exactly one producer and one consumer may be active. Use `try_producer()`/`try_consumer()`, which return `None` rather than panicking — on a microcontroller a panic is a reset, and the panic machinery costs flash you may not have. The panicking `producer()`/`consumer()` are **deprecated since 0.2.0** and will be removed in 0.3.0. Using unsafe to bypass the SPSC constraint (or sharing handles concurrently) is undefined behavior. -- `T: Copy` is required by all types to avoid allocation and return values by copy. +- `T: Copy` is required by all payload-carrying types to avoid allocation and return values by copy. - `EventBuf` is race-free by construction: its producer and consumer never touch the same slot, and it passes Miri with the data-race detector enabled. - `SeqRing` is a seqlock and carries a **known formal data race** — the consumer may copy a slot @@ -242,8 +270,8 @@ them is a runtime step and always will be. The typical embedded shape is a producer in an interrupt handler and a consumer in a task loop. That works, with three things to know: -- **The buffer is shared; the handles are owned.** `SeqRing` and - `EventBuf` are `Sync` when `T: Send`, so `&buf` can be handed to both +- **The primitive is shared; the handles are owned.** `SeqRing` and + `EventBuf` are `Sync` when `T: Send`, and `CountedSignal` is `Sync`, so the primitive can be handed to both contexts. `Producer` and `Consumer` are `Send + !Sync` — move each one into the context that owns it, and never share a single handle between contexts. There is no way to get a second `Producer` while one is live: `producer()` @@ -293,7 +321,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 69 unit + 11 doctests + 3 compile-fail | Behaviour, including threaded stress tests for both SPSC types; `N == 0` rejected at compile time | +| 75 unit + 11 doctests + 3 compile-fail | Behaviour, including threaded stress tests for all concurrent types; `N == 0` rejected at compile time | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index 210e0b3..c19f60a 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -79,6 +79,75 @@ should remain statically sized and avoid a general dynamic registry. - Counter width: `u32` everywhere (matching the crate's sequence width and the 32-bit `usize` of every shipped target), or parameterised? +## 3.1 Exploratory result: exact bounded saturation requires one producer + +The candidate branch now carries an executable SPSC design. It found a fourth +answer to the central question that is stronger than the three initially +listed, but only under a deliberately narrow handle model: + +```rust +if count.load(Relaxed) != u32::MAX { + count.fetch_add(1, Relaxed); +} +``` + +This is **not** correct for multiple producers: two producers could both load +`u32::MAX - 1` and the second `fetch_add` would wrap. It is correct with the +crate's existing sole `Send + !Sync` producer handle. Between that handle's +load and `fetch_add`, the consumer's `swap(0)` is the only possible competing +write and it can only lower the value. The RMW therefore increments either the +observed epoch or the newly reset epoch and cannot wrap. If the load observes +`u32::MAX`, that no-op linearizes before a concurrent take and is already +represented by the saturated snapshot. + +Consequences for the deferred decisions: + +| Choice | Evaluation result | +|---|---| +| Sole `Send + !Sync` producer, methods take `&self` | Exact saturation; bounded to one load plus at most one RMW; matches existing handle ownership. | +| Shareable/multiple raisers | The two-operation proof fails; needs an unbounded CAS loop, a weaker overflow contract, or a substantially more complex bounded algorithm. | +| `u32::MAX` reserved sentinel wrapped by `CountSnapshot` | Full exact range below the sentinel; `is_saturated()` observes saturation in the take that clears it; the snapshot remains one word and needs no second sticky atomic. | +| `swap(0)` take, relaxed ordering | Counts only, with no payload publication to order. Atomic modification order partitions each increment into exactly one take epoch. | + +The prototype deliberately retains handles even though their operations use +`&self`: `&self` is an API receiver choice, not permission to share a handle. +`PhantomData>` keeps each handle `!Sync`, while the handle itself +remains `Send`. This is directly reusable by EventFlags if that lane chooses +the same ownership model, though EventFlags does not need exclusivity for its +`fetch_or` correctness. + +Evidence added with the prototype: + +- a unit boundary test seeds `u32::MAX - 1`, increments twice, and observes a + saturated (never wrapped) snapshot; +- a threaded stress test proves repeated concurrent takes preserve the sum of + 100,000 increments; +- Loom exhaustively models ordinary take partitioning and the load/take/RMW + interleaving at `u32::MAX - 1`; +- the cycle and code-size probes include the ISR-side `increment` and consumer + `take_count` paths so the remaining admission decision can be made from + per-target measurements rather than from source shape. + +The isolated release-mode code-size rows on the pinned Rust 1.92.0 toolchain +are small and, importantly, expose the architecture split for review: + +| Target family | `increment` | `take_count` | +|---|---:|---:| +| Cortex-M0 (`thumbv6m`) | 30 B | 24 B | +| Cortex-M23 (`thumbv8m.base`) | 28 B | 22 B | +| Cortex-M3/M4/M33 | 28 B | 22 B | +| Armv7-R / Armv7-A | 40 B | 28 B | +| RV32IMAC | 18 B | 8 B | + +The M0/M23 rows use the existing portable-atomic single-core probe backend. +Instruction counts still require the pinned QEMU reference environment; the +probe regions are committed, but no cycle number is claimed from a machine +without QEMU. + +This result refines rather than silently closes the handle decision: choose +SPSC handles and the central bounded-saturation problem has a small exact +solution; choose multiple raisers and this implementation must be rejected. + ## 4. Promotion bar to PROPOSED 1. Solve the bounded-saturating-increment question — it decides whether the diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 4665d38..fdc9223 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -31,9 +31,10 @@ # XTENSA=1 ./scripts/codesize.sh # add ESP32 rows (needs esp-rs fork) # # Reading the output: each number is the byte size of one function, so it -# attributes to a single API shape rather than to a whole binary. `bss` is the -# `static EventBuf`; it should be identical on every target and `data` -# should be 0 -- that pair is the const-`new` claim. +# attributes to a single API shape rather than to a whole binary. `cs_incr` and +# `cs_take` isolate the two CountedSignal hot paths. `bss` is the `static +# EventBuf`; it should be identical on every target and `data` should +# be 0 -- that pair is the const-`new` claim. set -u @@ -114,8 +115,8 @@ fn_size() { RESULTS="$(mktemp)" trap 'rm -f "$RESULTS"' EXIT -printf '%-30s %10s %8s %8s %6s\n' TARGET two_calls split bss data -printf '%-30s %10s %8s %8s %6s\n' '------------------------------' '---------' '-----' '---' '----' +printf '%-30s %10s %8s %8s %8s %8s %6s\n' TARGET two_calls cs_incr cs_take split bss data +printf '%-30s %10s %8s %8s %8s %8s %6s\n' '------------------------------' '---------' '-------' '-------' '-----' '---' '----' skipped=0 failed=0 @@ -161,17 +162,21 @@ for entry in $TARGETS; do ar="scripts/codesize/target/$target/release/libph_eventing_codesize.a" two="$(fn_size "$ar" bringup_two_calls)" + cs_inc="$(fn_size "$ar" counted_increment)" + cs_take="$(fn_size "$ar" counted_take)" spl="$(fn_size "$ar" bringup_split)" bss="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.bss\..*3BUF/ { print $2; exit }')" dat="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.data\./ { s += $2 } END { print s + 0 }')" - printf '%-30s %10s %8s %8s %6s\n' \ - "$target" "${two:--}" "${spl:--}" "${bss:--}" "${dat:-0}" + printf '%-30s %10s %8s %8s %8s %8s %6s\n' \ + "$target" "${two:--}" "${cs_inc:--}" "${cs_take:--}" "${spl:--}" "${bss:--}" "${dat:-0}" # Xtensa is never gated: it needs a toolchain fork, so making it a hard gate # would make that fork mandatory for every contributor. case "$target" in xtensa-*) continue ;; esac [ -n "$two" ] && printf '%s\ttwo_calls\t%s\n' "$target" "$two" >> "$RESULTS" + [ -n "$cs_inc" ] && printf '%s\tcounted_increment\t%s\n' "$target" "$cs_inc" >> "$RESULTS" + [ -n "$cs_take" ] && printf '%s\tcounted_take\t%s\n' "$target" "$cs_take" >> "$RESULTS" [ -n "$bss" ] && printf '%s\tbss\t%s\n' "$target" "$bss" >> "$RESULTS" printf '%s\tdata\t%s\n' "$target" "${dat:-0}" >> "$RESULTS" done diff --git a/scripts/codesize/baseline.tsv b/scripts/codesize/baseline.tsv index 5c1c565..9d475c1 100644 --- a/scripts/codesize/baseline.tsv +++ b/scripts/codesize/baseline.tsv @@ -8,26 +8,42 @@ # Xtensa is deliberately absent -- it needs the esp-rs fork, and gating it # would make that fork mandatory for every contributor. armv7a-none-eabi bss 268 +armv7a-none-eabi counted_increment 40 +armv7a-none-eabi counted_take 28 armv7a-none-eabi data 0 armv7a-none-eabi two_calls 220 armv7r-none-eabi bss 268 +armv7r-none-eabi counted_increment 40 +armv7r-none-eabi counted_take 28 armv7r-none-eabi data 0 armv7r-none-eabi two_calls 220 riscv32imac-unknown-none-elf bss 268 +riscv32imac-unknown-none-elf counted_increment 18 +riscv32imac-unknown-none-elf counted_take 8 riscv32imac-unknown-none-elf data 0 riscv32imac-unknown-none-elf two_calls 152 thumbv6m-none-eabi bss 268 +thumbv6m-none-eabi counted_increment 30 +thumbv6m-none-eabi counted_take 24 thumbv6m-none-eabi data 0 thumbv6m-none-eabi two_calls 156 thumbv7em-none-eabi bss 268 +thumbv7em-none-eabi counted_increment 28 +thumbv7em-none-eabi counted_take 22 thumbv7em-none-eabi data 0 thumbv7em-none-eabi two_calls 180 thumbv7m-none-eabi bss 268 +thumbv7m-none-eabi counted_increment 28 +thumbv7m-none-eabi counted_take 22 thumbv7m-none-eabi data 0 thumbv7m-none-eabi two_calls 180 thumbv8m.base-none-eabi bss 268 +thumbv8m.base-none-eabi counted_increment 28 +thumbv8m.base-none-eabi counted_take 22 thumbv8m.base-none-eabi data 0 thumbv8m.base-none-eabi two_calls 156 thumbv8m.main-none-eabi bss 268 +thumbv8m.main-none-eabi counted_increment 28 +thumbv8m.main-none-eabi counted_take 22 thumbv8m.main-none-eabi data 0 thumbv8m.main-none-eabi two_calls 152 diff --git a/scripts/codesize/src/lib.rs b/scripts/codesize/src/lib.rs index 36f38d3..7e7f09b 100644 --- a/scripts/codesize/src/lib.rs +++ b/scripts/codesize/src/lib.rs @@ -6,6 +6,7 @@ #![no_std] use core::panic::PanicInfo; +use ph_eventing::counted_signal::{Consumer as CountConsumer, Producer as CountProducer}; use ph_eventing::EventBuf; #[panic_handler] @@ -40,6 +41,18 @@ pub extern "C" fn bringup_two_calls() -> i32 { } } +/// ISR-side CountedSignal operation, isolated from bring-up and take costs. +#[unsafe(no_mangle)] +pub fn counted_increment(producer: &CountProducer<'_>) { + producer.increment(); +} + +/// Consumer-side CountedSignal take, isolated from bring-up costs. +#[unsafe(no_mangle)] +pub fn counted_take(consumer: &CountConsumer<'_>) -> u32 { + consumer.take_count().count() +} + /// Bring-up via a single `try_split`. Only present on branches that have it. #[cfg(feature = "split")] #[unsafe(no_mangle)] diff --git a/scripts/cycles.sh b/scripts/cycles.sh index d46c08c..341f317 100755 --- a/scripts/cycles.sh +++ b/scripts/cycles.sh @@ -196,6 +196,7 @@ report="$(awk ' if (group == "eb") printf "\nEventBuf (backpressure SPSC)\n" else if (group == "sr") printf "\nSeqRing (overwrite SPSC)\n" else if (group == "rb") printf "\nRingBuf (single owner)\n" + else if (group == "cs") printf "\nCountedSignal (payload-free SPSC)\n" } } next diff --git a/scripts/cycles/src/main.rs b/scripts/cycles/src/main.rs index 72c6b31..59f0bdd 100644 --- a/scripts/cycles/src/main.rs +++ b/scripts/cycles/src/main.rs @@ -26,7 +26,7 @@ use core::hint::black_box; use cortex_m_rt::entry; use cortex_m_semihosting::debug; -use ph_eventing::{EventBuf, RingBuf, SeqRing}; +use ph_eventing::{CountedSignal, EventBuf, RingBuf, SeqRing}; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { @@ -37,7 +37,7 @@ fn panic(_: &core::panic::PanicInfo) -> ! { /// Declares region markers. /// /// Each body embeds a **unique immediate**, and that is load-bearing: with -/// identical `nop`-only bodies the linker folded all nineteen markers onto a +/// identical `nop`-only bodies the linker folded every marker onto a /// single address, the runner saw one label, and the output was silently empty. /// `r12` is call-clobbered under AAPCS, so writing it in a `-> ()` function is /// free and harmless. @@ -83,6 +83,23 @@ markers! { 17 => m_rb_get, 18 => m_rb_latest, 19 => m_sr_poll_lagged_far, + // CountedSignal -- payload-free SPSC + 20 => m_cs_increment, + 21 => m_cs_take_count, +} + +fn counted_signal_costs() { + let signal = CountedSignal::new(); + let tx = signal.try_producer().expect("producer"); + let rx = signal.try_consumer().expect("consumer"); + + m_cs_increment(); + tx.increment(); + m_end(); + + m_cs_take_count(); + let _ = black_box(rx.take_count()); + m_end(); } fn event_buf_costs() { @@ -207,6 +224,7 @@ fn main() -> ! { event_buf_costs(); seq_ring_costs(); ring_buf_costs(); + counted_signal_costs(); debug::exit(debug::EXIT_SUCCESS); loop {} diff --git a/src/counted_signal.rs b/src/counted_signal.rs new file mode 100644 index 0000000..d0f819b --- /dev/null +++ b/src/counted_signal.rs @@ -0,0 +1,310 @@ +//! A saturating count for payload-free events. +//! +//! [`CountedSignal`] is an exploratory SPSC primitive for events whose +//! multiplicity matters but whose payload and ordering do not. Its producer +//! performs at most one load and one read-modify-write per increment; its +//! consumer atomically takes the accumulated count. +//! +//! The single-producer handle is load-bearing. A producer first observes that +//! the counter is below [`u32::MAX`] and then increments it with `fetch_add`. +//! Between those operations the sole consumer may reset the counter to zero, +//! but no other operation can increase it. Consequently `fetch_add` cannot +//! wrap: it either advances the value observed by the producer or advances a +//! newly reset epoch. Multiple producers would invalidate that proof. + +use core::cell::Cell; +use core::marker::PhantomData; + +use crate::sync::{AtomicBool, AtomicU32, Ordering}; + +/// A saturating SPSC counter for payload-free events. +/// +/// Exactly one [`Producer`] and one [`Consumer`] may be active at a time. +/// Handles are `Send + !Sync`: each can move to another execution context, +/// but cannot be shared between contexts. Dropping a handle releases its slot. +/// +/// `u32::MAX` is the saturation sentinel. Counts below it are exact; a +/// saturated snapshot means that at least `u32::MAX` increments occurred +/// since the preceding take. +pub struct CountedSignal { + count: AtomicU32, + producer_taken: AtomicBool, + consumer_taken: AtomicBool, +} + +impl CountedSignal { + /// Create an empty counted signal. + #[cfg(not(loom))] + #[must_use] + pub const fn new() -> Self { + Self { + count: AtomicU32::new(0), + producer_taken: AtomicBool::new(false), + consumer_taken: AtomicBool::new(false), + } + } + + /// Create an empty counted signal under Loom. + #[cfg(loom)] + #[must_use] + pub fn new() -> Self { + Self { + count: AtomicU32::new(0), + producer_taken: AtomicBool::new(false), + consumer_taken: AtomicBool::new(false), + } + } + + #[cfg(all(loom, test))] + pub(crate) fn with_count_for_model(count: u32) -> Self { + Self { + count: AtomicU32::new(count), + producer_taken: AtomicBool::new(false), + consumer_taken: AtomicBool::new(false), + } + } + + /// Try to acquire the sole producer handle. + /// + /// Returns `None` while another producer handle is active. + #[inline] + pub fn try_producer(&self) -> Option> { + if self.producer_taken.swap(true, Ordering::AcqRel) { + None + } else { + Some(Producer { + signal: self, + _not_sync: PhantomData, + }) + } + } + + /// Try to acquire the sole consumer handle. + /// + /// Returns `None` while another consumer handle is active. + #[inline] + pub fn try_consumer(&self) -> Option> { + if self.consumer_taken.swap(true, Ordering::AcqRel) { + None + } else { + Some(Consumer { + signal: self, + _not_sync: PhantomData, + }) + } + } +} + +impl Default for CountedSignal { + fn default() -> Self { + Self::new() + } +} + +impl core::fmt::Debug for CountedSignal { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CountedSignal") + .field("count", &self.count.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +/// The sole incrementing handle for a [`CountedSignal`]. +/// +/// This handle is `Send + !Sync`. Its exclusivity is what makes exact, +/// bounded saturation possible without a compare-exchange loop. +pub struct Producer<'a> { + signal: &'a CountedSignal, + _not_sync: PhantomData>, +} + +impl Producer<'_> { + /// Record one occurrence. + /// + /// This operation is bounded to one atomic load and, unless the counter + /// was already saturated, one atomic `fetch_add`. It never loops and the + /// counter never wraps. + #[inline] + pub fn increment(&self) { + // Only this handle may increase `count`; the consumer can only reset it + // to zero. If this load is below MAX, the later fetch_add therefore + // observes either a value no greater than this one or a post-take + // value. In both cases adding one cannot wrap. + if self.signal.count.load(Ordering::Relaxed) != u32::MAX { + self.signal.count.fetch_add(1, Ordering::Relaxed); + } + } +} + +impl Drop for Producer<'_> { + fn drop(&mut self) { + self.signal.producer_taken.store(false, Ordering::Release); + } +} + +impl core::fmt::Debug for Producer<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("counted_signal::Producer").finish() + } +} + +/// The sole taking handle for a [`CountedSignal`]. +/// +/// This handle is `Send + !Sync` and may atomically take counts while its +/// paired producer increments from another context. +pub struct Consumer<'a> { + signal: &'a CountedSignal, + _not_sync: PhantomData>, +} + +impl Consumer<'_> { + /// Atomically take the count accumulated since the preceding take. + /// + /// A concurrent increment belongs wholly to this snapshot or wholly to + /// the next one. [`CountSnapshot::is_saturated`] distinguishes the + /// saturation sentinel from an exact count. + #[inline] + pub fn take_count(&self) -> CountSnapshot { + CountSnapshot::from_raw(self.signal.count.swap(0, Ordering::Relaxed)) + } +} + +impl Drop for Consumer<'_> { + fn drop(&mut self) { + self.signal.consumer_taken.store(false, Ordering::Release); + } +} + +impl core::fmt::Debug for Consumer<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("counted_signal::Consumer").finish() + } +} + +/// The result of atomically taking a [`CountedSignal`] count. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use] +pub struct CountSnapshot { + count: u32, +} + +impl CountSnapshot { + #[inline(always)] + const fn from_raw(raw: u32) -> Self { + Self { count: raw } + } + + /// Return the exact count, or `u32::MAX` when saturated. + #[inline(always)] + #[must_use] + pub const fn count(self) -> u32 { + self.count + } + + /// Whether at least `u32::MAX` increments accumulated before the take. + #[inline(always)] + #[must_use] + pub const fn is_saturated(self) -> bool { + self.count == u32::MAX + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn increments_accumulate_and_take_clears() { + let signal = CountedSignal::new(); + let producer = signal.try_producer().unwrap(); + let consumer = signal.try_consumer().unwrap(); + + producer.increment(); + producer.increment(); + + assert_eq!(consumer.take_count(), CountSnapshot { count: 2 }); + assert_eq!(consumer.take_count().count(), 0); + } + + #[test] + fn saturates_instead_of_wrapping() { + let signal = CountedSignal::new(); + signal.count.store(u32::MAX - 1, Ordering::Relaxed); + let producer = signal.try_producer().unwrap(); + let consumer = signal.try_consumer().unwrap(); + + producer.increment(); + producer.increment(); + + let snapshot = consumer.take_count(); + assert_eq!(snapshot.count(), u32::MAX); + assert!(snapshot.is_saturated()); + assert_eq!(consumer.take_count().count(), 0); + } + + #[test] + fn handles_are_exclusive_and_reusable_after_drop() { + let signal = CountedSignal::new(); + let producer = signal.try_producer().unwrap(); + let consumer = signal.try_consumer().unwrap(); + assert!(signal.try_producer().is_none()); + assert!(signal.try_consumer().is_none()); + + drop(producer); + drop(consumer); + assert!(signal.try_producer().is_some()); + assert!(signal.try_consumer().is_some()); + } + + #[test] + fn handles_are_send() { + fn assert_send() {} + assert_send::>(); + assert_send::>(); + } + + #[cfg(not(loom))] + #[test] + fn const_new_works_in_static_context() { + static SIGNAL: CountedSignal = CountedSignal::new(); + let producer = SIGNAL.try_producer().unwrap(); + let consumer = SIGNAL.try_consumer().unwrap(); + producer.increment(); + assert_eq!(consumer.take_count().count(), 1); + } + + #[cfg(not(loom))] + #[test] + fn concurrent_takes_do_not_lose_increments() { + use core::sync::atomic::{AtomicBool, Ordering as CoreOrdering}; + + let signal = CountedSignal::new(); + let producer = signal.try_producer().unwrap(); + let consumer = signal.try_consumer().unwrap(); + let done = AtomicBool::new(false); + + let total = std::thread::scope(|scope| { + let done_for_producer = &done; + scope.spawn(move || { + for _ in 0..crate::test_support::iterations(100_000) { + producer.increment(); + } + done_for_producer.store(true, CoreOrdering::Release); + }); + + let done_for_consumer = &done; + let taker = scope.spawn(move || { + let mut total = 0u64; + while !done_for_consumer.load(CoreOrdering::Acquire) { + total += u64::from(consumer.take_count().count()); + std::thread::yield_now(); + } + total + u64::from(consumer.take_count().count()) + }); + + taker.join().unwrap() + }); + + assert_eq!(total, u64::from(crate::test_support::iterations(100_000))); + } +} diff --git a/src/lib.rs b/src/lib.rs index d2c2daf..70b118b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,8 +7,10 @@ //! | [`RingBuf`] | Single-owner ring — simple, no atomics, `&mut` access. | //! | [`SeqRing`] | Lock-free SPSC ring that **overwrites** old entries (lossy, high-throughput). | //! | [`EventBuf`] | Lock-free SPSC ring with **backpressure** — rejects pushes when full. | +//! | [`CountedSignal`] | Saturating SPSC count for identical, payload-free events. | //! -//! All three are fixed-size, zero-allocation, and generic over `T: Copy`. +//! All four are fixed-size and zero-allocation. The buffer types are generic +//! over `T: Copy`; [`CountedSignal`] carries no payload. //! //! # Common traits //! @@ -200,12 +202,14 @@ enable either the portable-atomic-unsafe-assume-single-core or portable-atomic-c #[macro_use] mod macros; +pub mod counted_signal; pub mod event_buf; pub mod ring; pub mod seq_ring; pub(crate) mod sync; pub mod traits; +pub use counted_signal::{CountSnapshot, CountedSignal}; pub use event_buf::EventBuf; pub use ring::RingBuf; pub use seq_ring::{PollStats, SeqRing}; diff --git a/src/loom_tests.rs b/src/loom_tests.rs index a2d6b3f..e243322 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -19,10 +19,64 @@ // they remain public API until 0.3.0, so their orderings still need proving. #![allow(deprecated)] -use crate::{EventBuf, SeqRing}; +use crate::{CountedSignal, EventBuf, SeqRing}; use loom::sync::Arc; use loom::thread; +/// Concurrent takes partition increments between snapshots without losing or +/// duplicating them. +#[test] +fn counted_signal_take_partitions_increments() { + loom::model(|| { + let signal = Arc::new(CountedSignal::new()); + + let producer_signal = Arc::clone(&signal); + let producer = thread::spawn(move || { + let producer = producer_signal.try_producer().unwrap(); + producer.increment(); + producer.increment(); + }); + + let consumer_signal = Arc::clone(&signal); + let consumer = thread::spawn(move || { + let consumer = consumer_signal.try_consumer().unwrap(); + u64::from(consumer.take_count().count()) + u64::from(consumer.take_count().count()) + }); + + producer.join().unwrap(); + let early = consumer.join().unwrap(); + let final_count = signal.try_consumer().unwrap().take_count().count(); + assert_eq!(early + u64::from(final_count), 2); + }); +} + +/// At the saturation boundary, a take between the producer's load and +/// `fetch_add` moves the increment into the new epoch; it cannot make the RMW +/// wrap or lose the increment. +#[test] +fn counted_signal_saturation_boundary_is_linearizable() { + loom::model(|| { + let signal = Arc::new(CountedSignal::with_count_for_model(u32::MAX - 1)); + + let producer_signal = Arc::clone(&signal); + let producer = thread::spawn(move || { + producer_signal.try_producer().unwrap().increment(); + }); + + let consumer_signal = Arc::clone(&signal); + let consumer = + thread::spawn(move || consumer_signal.try_consumer().unwrap().take_count().count()); + + producer.join().unwrap(); + let early = consumer.join().unwrap(); + let final_count = signal.try_consumer().unwrap().take_count().count(); + assert_eq!( + u64::from(early) + u64::from(final_count), + u64::from(u32::MAX) + ); + }); +} + /// Every item the producer pushes is popped exactly once, in order, with no /// duplicates and no losses — under every interleaving. /// From e21c7fa13c2fd7171f1b5bf7aeb1d721dd2f0fc9 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 03:09:38 -0400 Subject: [PATCH 02/87] Develop complete block handoff candidate --- AGENTS.md | 21 ++- CHANGELOG.md | 8 + README.md | 35 +++- docs/proposals/block-buf.md | 137 ++++++++++++++- src/block.rs | 336 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 +- 6 files changed, 535 insertions(+), 7 deletions(-) create mode 100644 src/block.rs diff --git a/AGENTS.md b/AGENTS.md index 4aca151..f1a8a9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,10 @@ It ships three primitives: - **`SeqRing`** — a lock-free SPSC ring that **overwrites** old entries. Designed for high-rate telemetry where a fast producer and a potentially slower consumer run on different contexts. - **`EventBuf`** — a lock-free SPSC ring with **backpressure**. `push` returns `Err(val)` when full, so the producer always knows when delivery fails. +`Block` + `BlockBuilder` is the non-concurrent complete-window +payload/fill abstraction. It composes with those transports rather than adding +a fourth queue policy. + **Key characteristics:** - Zero runtime dependencies (`portable-atomic` is optional; `loom` is a dev-dependency gated on `--cfg loom`). Verify with `cargo tree` — it must print the crate alone. - `#![no_std]` by default (std only for testing) @@ -151,6 +155,7 @@ ph-eventing/ | Type | Purpose | |------|---------| +| `Block` / `BlockBuilder` | Complete contiguous sample payload and private fill state; no atomics | | `RingBuf` | Single-owner, stack-allocated ring buffer (no atomics) | | `SeqRing` | Lock-free SPSC ring buffer with atomic sequence tracking | | `seq_ring::Producer<'a, T, N>` | SeqRing write handle; `push(T) -> u32` returns sequence number | @@ -790,6 +795,17 @@ Tests are in `src/ring.rs`, `src/seq_ring.rs`, `src/event_buf.rs`, and `src/trai cargo test ``` +**`block::tests`:** +- `completes_only_after_n_contiguous_samples` -- complete-only publication and metadata +- `completion_resets_for_the_next_block` -- builder reuse, including `N = 1` +- `rejects_reserved_zero_without_changing_partial_block` -- reserved sequence handling +- `rejects_gap_without_hiding_loss_policy` -- explicit discontinuity and preserved partial state +- `clear_discards_a_partial_block` -- explicit teardown policy +- `sequence_wrap_skips_zero` -- contiguous wrap from `u32::MAX` to `1` +- `works_without_default_bound` -- `T: Copy` only, no `Default` +- `default_and_capacity_match_new` -- constructor/default introspection +- `event_buf_composition_queues_and_returns_a_rejected_block` -- composed FIFO/rejection policy + **`ring::tests`:** - `new_ring_is_empty` — Fresh ring state - `push_and_get` — Basic push/get/latest @@ -865,8 +881,9 @@ cargo test **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -and `src/traits.rs`. Total: 69 unit tests + 11 doctests, plus 3 `compile_fail` -doctests pinning the `N == 0` rejection (`E0080`) on all three types. +`src/block.rs`, and `src/traits.rs`. Total: 78 unit tests + 12 doctests, plus 4 +`compile_fail` doctests pinning zero-capacity rejection (`E0080`) on the three +buffers and `BlockBuilder`. ## Code Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 83507f5..c60edf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added +- `Block` and `BlockBuilder` provide complete, contiguous sample + windows without introducing another queue policy. The builder rejects gaps + explicitly, skips reserved sequence zero at wrap, and yields a public block + only after all `N` samples are initialized. Compose blocks with + `EventBuf, Q>` today or the proposed `LatestBuf>` for + freshness-first handoff. + ### Documentation - `RingBuf::new`'s docs no longer mention a `pop` method the type does not have — `pop` was deliberately rejected (its data loss would be unreportable under overwrite; see the worked diff --git a/README.md b/README.md index 348ed85..6f473d8 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ Stack-allocated ring buffers for no-std embedded targets. | Type | Use case | |------|----------| +| [`Block` / `BlockBuilder`](#complete-blocks) | Build complete contiguous sample windows, then compose them with a transport. | | [`RingBuf`](#ringbuf) | Single-owner ring buffer — simple, no atomics, `&mut` access. | | [`SeqRing`](#seqring) | Lock-free SPSC ring that **overwrites** old entries (lossy, high-throughput). | | [`EventBuf`](#eventbuf) | Lock-free SPSC ring with **backpressure** — rejects pushes when full. | -All three are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Copy`. +All types are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Copy`. ## What this optimises for @@ -48,6 +49,7 @@ panics, or hides a cost. ## Features - Three ring buffer flavours: single-owner, lossy SPSC, and backpressure SPSC. +- Complete contiguous sample blocks with an explicit fill-side builder. - Common `Sink`/`Source`/`Link` traits for writing generic event-processing code. - `forward(src, snk, max)` utility to bridge any `Source` → `Sink`. - No heap, no dynamic dispatch, no required dependencies. @@ -69,6 +71,35 @@ panics, or hides a cost. ## Usage +### Complete blocks + +`BlockBuilder` privately accumulates sequenced samples and yields a +`Block` only when all `N` contiguous samples are present. A gap is +returned to the caller without changing the partial block, and clearing or +dropping a partial builder publishes nothing. Timestamping is payload policy: +use a timestamped type for `T` when required. + +`Block` is deliberately not another queue. Compose it with the overload policy +you need: `EventBuf, Q>` queues complete blocks and rejects the +newest when full; the proposed `LatestBuf>` will retain only the +latest complete block. + +```rust +use ph_eventing::{BlockBuilder, EventBuf}; + +let mut fill = BlockBuilder::::new(); +for (sequence, sample) in [(10, 1), (11, 2), (12, 3)] { + assert!(fill.push(sequence, sample).unwrap().is_none()); +} +let block = fill.push(13, 4).unwrap().unwrap(); + +let queue = EventBuf::<_, 2>::new(); +let producer = queue.try_producer().unwrap(); +let consumer = queue.try_consumer().unwrap(); +producer.push(block).unwrap(); +assert_eq!(consumer.pop().unwrap().samples(), &[1, 2, 3, 4]); +``` + ### RingBuf A straightforward, single-owner ring buffer for collecting values when you @@ -293,7 +324,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 69 unit + 11 doctests + 3 compile-fail | Behaviour, including threaded stress tests for both SPSC types; `N == 0` rejected at compile time | +| 78 unit + 12 doctests + 4 compile-fail | Behaviour, including threaded stress tests for both SPSC types; zero capacities rejected at compile time | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index fa1c0e3..fc68c49 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -78,7 +78,7 @@ obvious. and is the strongest argument for building on [`SlotPool`](slot-pool.md) ownership transfer rather than copying. -## 3. Open questions +## 3. Initial open questions (evaluated in section 5) - Is `LatestBlockBuf` a new type or `LatestBuf>` plus a fill-side helper? What, concretely, does a separate type buy? @@ -96,7 +96,7 @@ obvious. - `Timestamped` dependency: the sketch embeds per-sample stamps — is that mandatory, or a parameterisation the caller can collapse to zero size? -## 4. Promotion bar to PROPOSED +## 4. Initial promotion bar to PROPOSED 1. Resolve D3 jointly with this document (maintainer decision). 2. Answer the type-identity questions above — in particular whether each @@ -108,3 +108,136 @@ obvious. the reference environment (publication cost vs `N` stated explicitly), Miri detector-on, Loom, and the memory cost (~3 blocks for the `Latest` variant) stated per target in the docs. + +## 5. Evaluated answers (candidate implementation) + +The candidate branch contains `Block` and `BlockBuilder`. This +small implementation exercises composition and fill-side behavior without +committing to another synchronization primitive. + +### 5.1 Type identity + +| Working name | Candidate identity | Why | +|---|---|---| +| `LatestBlockBuf` | `LatestBuf>` | Every LatestBuf clause applies unchanged: a complete block is the published `T`. A wrapper adds a name, not a guarantee. | +| `QueuedBlockBuf` | `EventBuf, Q>` | Existing FIFO admission, rejection, handles, ordering, and race-freedom are the required queued policy. | +| `DropBlockBuf` | Call-site handling of `EventBuf::push`'s `Err(block)` | Drop-new is an action after observable rejection. The caller can count, log, retry, or deliberately discard the returned complete block. | + +This resolves the architectural part of D3: sample and block are not mutually +exclusive primitive forms. `LatestBuf` remains payload-agnostic while +`Block` is a separately useful payload/fill abstraction. Which ships +first remains release scheduling, not a type-system choice. + +A named wrapper should be reconsidered only if it enforces a contract that +composition cannot, such as direct-to-granted-slot filling with zero +publication copy. That would materially be a SlotPool/grant transport. + +### 5.2 Fill-side contract + +`BlockBuilder` owns private partial storage. `push(sequence, sample)`: + +1. rejects reserved sequence `0`; +2. accepts only the next wrap-aware contiguous sequence; +3. returns `Ok(None)` for the first `N - 1` samples; +4. returns `Ok(Some(Block))` exactly after the `N`th sample; and +5. resets only after successful completion. + +A discontinuity returns the sample plus expected/received sequences without +mutating the partial block. The caller explicitly chooses to preserve it or +`clear` and retry the rejected sample as a new window. Dropping or clearing a +partial builder publishes nothing; there is no hidden drop count or panic. + +This distinguishes pre-publication **filtered/incomplete windows**, visible as +a fill error or explicit clear, from post-completion **lost blocks**, reported +by the selected transport (`PublishReport::replaced_unread` for LatestBuf or +`EventBuf::push` returning the block). + +### 5.3 Payload and timestamp policy + +`Block` stores `[T; N]` plus inclusive first/last sequences. Timestamping +is payload policy: applications needing per-sample time use a timestamped `T`; +applications needing one stamp per block use their own wrapper. The zero-stamp +case therefore has genuinely zero timestamp overhead. + +The candidate keeps `T: Copy`, matching every existing transport and allowing +direct composition. That is a baseline for measurement, not evidence that +copying a large block is cheap. + +## 6. Cost model and SlotPool decision + +Let `B = size_of::>()`. Before alignment padding, it contains +`N * size_of::() + 8` bytes. + +| Boundary | Conservative payload traffic | +|---|---:| +| Builder completion to returned `Block` | `B` | +| Publication into EventBuf or a copying LatestBuf | `B` | +| Consumer pop/take into its returned value | `B` | + +Optimization may elide a move, but the contract cannot rely on it. Publication +is O(`B`) / O(`N * size_of::()`) even though synchronization is O(1). It is +bounded and compile-time-known, but not constant across block shapes. + +Inline memory is equally explicit: + +- builder: approximately one block plus `len` and sequence metadata; +- `EventBuf, Q>`: `Q * B` plus queue atomics; +- proposed three-slot `LatestBuf>`: approximately `3 * B` plus + control atomics, in addition to fill-side storage; +- SlotPool/grants: pool slots plus control state; the producer fills the + destination directly and publication transfers an index. + +**Decision rule:** keep Copy composition when measured worst-case completion +plus publication fits the acquisition/interrupt budget on every claimed +target. Choose SlotPool/grants when it does not, or when the extra builder-sized +storage is unacceptable. Do not use an arbitrary `N` threshold; the answer +depends on sample width, target, and application budget. + +Required matrix before promotion: + +- sample widths 2, 8, and 16 bytes; +- `N = 8, 32, 128`; +- accepted and rejected EventBuf publication, plus LatestBuf when available; +- all code-size targets and the reference QEMU cycle target; and +- both instructions and bytes copied, not just atomic handoff cost. + +## 7. Deferred choices and decision evidence + +| Choice | Safe default | Evidence that changes it | +|---|---|---| +| Block or sample LatestBuf first (D3 scheduling) | Implement generic `LatestBuf`; blocks already compose | A block-first integration partner or benchmark showing the sample payload misses the relevant costs | +| Copy composition or SlotPool | Copy composition | Section 6 exceeds a named ISR/task budget or RAM envelope | +| Automatic reset after a gap | Reject without mutation | Real integrations converge on one restart and accounting policy | +| Mandatory per-sample timestamps | Caller-selected `T` | A transport-level time contract cannot be expressed in payload | +| Dedicated `DropBlockBuf` | Observe and handle `Err(block)` | A distinct synchronization/accounting contract, not convenience | +| Extra block-wide metadata | Caller wrapper | Multiple integrations need one invariant enforced by this crate | + +## 8. Block contract delta + +No concurrency contract is added by composition. Latest delivery uses the +LatestBuf contract with `T = Block`; queued delivery uses EventBuf with +the same substitution. The block-only, pre-publication delta is: + +- **F1 Complete-only:** no public `Block` is yielded before all `N` samples are + initialized. +- **F2 Contiguous:** represented sequences are consecutive under the successor + that skips reserved zero. +- **F3 Explicit interruption:** reserved/discontinuous input is returned and + does not silently alter the partial block. +- **F4 Teardown:** clearing or dropping partial state publishes nothing. +- **F5 Reuse:** completion resets the builder for a new independent block. + +Unit tests pin F1-F5, including wrap and a `T: Copy` type without `Default`. +Miri remains required because completion copies initialized `MaybeUninit` +storage. The block layer adds no atomics or shared mutable state, so Loom adds +no block-specific evidence; Loom remains required for the selected transport. + +## 9. Remaining promotion bar + +1. Maintainer confirms the type-identity recommendation and treats D3 as a + scheduling decision rather than separate sample/block primitive designs. +2. Run the section 6 matrix against named budgets; choose Copy composition or + SlotPool/grants from the result. +3. Run standard CI and Miri; run Loom for the selected transport. +4. If Copy wins, extend code-size/cycle probes with accepted block shapes. If + SlotPool wins, specify grant teardown and ownership proof before PROPOSED. diff --git a/src/block.rs b/src/block.rs new file mode 100644 index 0000000..678a543 --- /dev/null +++ b/src/block.rs @@ -0,0 +1,336 @@ +//! Complete, contiguous sample blocks and a fill-side builder. +//! +//! `Block` is deliberately a payload, not a queue. Compose it with the +//! transport whose overload policy matches the application: +//! +//! - `EventBuf, Q>` queues up to `Q` complete blocks and rejects +//! the newest block when full; +//! - the proposed `LatestBuf>` retains only the latest complete +//! block. +//! +//! Publication cannot expose a partial block because [`BlockBuilder`] only +//! yields a [`Block`] after all `N` samples have been written. Dropping or +//! clearing a partially filled builder publishes nothing. +//! +//! Timestamps are payload policy: use a timestamped sample type for `T` when +//! each sample needs a stamp. The transport does not impose one. +//! +//! # Example +//! ``` +//! use ph_eventing::{BlockBuilder, EventBuf}; +//! +//! let mut fill = BlockBuilder::::new(); +//! assert!(fill.push(10, 1).unwrap().is_none()); +//! assert!(fill.push(11, 2).unwrap().is_none()); +//! assert!(fill.push(12, 3).unwrap().is_none()); +//! let block = fill.push(13, 4).unwrap().unwrap(); +//! +//! let queue = EventBuf::<_, 2>::new(); +//! let producer = queue.try_producer().unwrap(); +//! let consumer = queue.try_consumer().unwrap(); +//! producer.push(block).unwrap(); +//! assert_eq!(consumer.pop().unwrap().samples(), &[1, 2, 3, 4]); +//! ``` +//! +//! A zero-sized block is rejected at compile time: +//! +//! ```compile_fail,E0080 +//! use ph_eventing::BlockBuilder; +//! const BAD: BlockBuilder = BlockBuilder::new(); +//! # let _ = BAD; +//! ``` + +use core::mem::MaybeUninit; + +/// A complete, contiguous block of `N` samples. +/// +/// Sequence `0` is reserved. The first and last sequence values describe the +/// inclusive range represented by `samples`; wrap skips the reserved value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Block { + first_sequence: u32, + last_sequence: u32, + samples: [T; N], +} + +impl Block { + /// Sequence of the first sample in the block. + #[must_use] + pub const fn first_sequence(&self) -> u32 { + self.first_sequence + } + + /// Sequence of the last sample in the block. + #[must_use] + pub const fn last_sequence(&self) -> u32 { + self.last_sequence + } + + /// The complete contiguous sample array. + #[must_use] + pub const fn samples(&self) -> &[T; N] { + &self.samples + } + + /// Consume the block and return its sample array. + #[must_use] + pub fn into_samples(self) -> [T; N] { + self.samples + } +} + +/// Why a sample could not be appended to a [`BlockBuilder`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FillError { + /// Sequence `0` is reserved and never identifies a sample. + ReservedSequence { + /// The rejected sample. + sample: T, + }, + /// The sequence was not the next contiguous value. + Discontinuous { + /// The required next sequence. + expected: u32, + /// The sequence supplied by the caller. + received: u32, + /// The rejected sample. + sample: T, + }, +} + +/// Privately fills one block and publishes it to the caller only when complete. +/// +/// A discontinuous sample is rejected without changing the partial block. The +/// caller can preserve it, or call [`clear`](Self::clear) and retry the returned +/// sample as the start of a new window. This keeps loss policy explicit. +pub struct BlockBuilder { + samples: [MaybeUninit; N], + len: usize, + first_sequence: u32, + last_sequence: u32, +} + +impl BlockBuilder { + /// Create an empty builder. + /// + /// `N == 0` is rejected at compile time. + #[must_use] + pub const fn new() -> Self { + const { assert!(N > 0, "BlockBuilder capacity must be greater than zero") }; + Self { + samples: [const { MaybeUninit::uninit() }; N], + len: 0, + first_sequence: 0, + last_sequence: 0, + } + } + + /// Append one sequenced sample. + /// + /// Returns `Ok(None)` while the block is partial and `Ok(Some(block))` + /// exactly when the `N`th sample completes it. Completion also resets the + /// builder, ready for the next block. + pub fn push(&mut self, sequence: u32, sample: T) -> Result>, FillError> { + if sequence == 0 { + return Err(FillError::ReservedSequence { sample }); + } + + if self.len != 0 { + let expected = next_sequence(self.last_sequence); + if sequence != expected { + return Err(FillError::Discontinuous { + expected, + received: sequence, + sample, + }); + } + } else { + self.first_sequence = sequence; + } + + self.samples[self.len].write(sample); + self.len += 1; + self.last_sequence = sequence; + + if self.len != N { + return Ok(None); + } + + // SAFETY: `len == N`, and `len` advances only after the corresponding + // slot is written. `T: Copy`, so copying the initialized array out does + // not invalidate the backing `MaybeUninit` storage. + let samples = unsafe { self.samples.as_ptr().cast::<[T; N]>().read() }; + let block = Block { + first_sequence: self.first_sequence, + last_sequence: self.last_sequence, + samples, + }; + self.clear(); + Ok(Some(block)) + } + + /// Discard the partial block, if any. + pub fn clear(&mut self) { + self.len = 0; + self.first_sequence = 0; + self.last_sequence = 0; + } + + /// Number of samples currently held in the private partial block. + #[must_use] + pub const fn len(&self) -> usize { + self.len + } + + /// Whether no partial block is being filled. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Number of samples required for a complete block. + #[must_use] + pub const fn capacity(&self) -> usize { + N + } + + /// Sequence required by the next `push`, or `None` when any non-zero + /// sequence may start a new block. + #[must_use] + pub const fn expected_sequence(&self) -> Option { + if self.len == 0 { + None + } else { + Some(next_sequence(self.last_sequence)) + } + } +} + +impl Default for BlockBuilder { + fn default() -> Self { + Self::new() + } +} + +impl core::fmt::Debug for BlockBuilder { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("BlockBuilder") + .field("len", &self.len) + .field("capacity", &N) + .field("first_sequence", &self.first_sequence) + .field("last_sequence", &self.last_sequence) + .finish_non_exhaustive() + } +} + +const fn next_sequence(sequence: u32) -> u32 { + if sequence == u32::MAX { + 1 + } else { + sequence + 1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::EventBuf; + + #[test] + fn completes_only_after_n_contiguous_samples() { + let mut fill = BlockBuilder::::new(); + assert_eq!(fill.push(41, 4), Ok(None)); + assert_eq!(fill.push(42, 5), Ok(None)); + let block = fill.push(43, 6).unwrap().unwrap(); + assert_eq!(block.first_sequence(), 41); + assert_eq!(block.last_sequence(), 43); + assert_eq!(block.samples(), &[4, 5, 6]); + assert!(fill.is_empty()); + } + + #[test] + fn completion_resets_for_the_next_block() { + let mut fill = BlockBuilder::::new(); + assert_eq!(fill.push(7, 9).unwrap().unwrap().into_samples(), [9]); + assert_eq!(fill.push(8, 10).unwrap().unwrap().into_samples(), [10]); + } + + #[test] + fn rejects_reserved_zero_without_changing_partial_block() { + let mut fill = BlockBuilder::::new(); + assert_eq!(fill.push(9, 1), Ok(None)); + assert_eq!( + fill.push(0, 2), + Err(FillError::ReservedSequence { sample: 2 }) + ); + assert_eq!(fill.len(), 1); + assert_eq!(fill.expected_sequence(), Some(10)); + } + + #[test] + fn rejects_gap_without_hiding_loss_policy() { + let mut fill = BlockBuilder::::new(); + assert_eq!(fill.push(9, 1), Ok(None)); + assert_eq!( + fill.push(11, 2), + Err(FillError::Discontinuous { + expected: 10, + received: 11, + sample: 2 + }) + ); + assert_eq!(fill.len(), 1); + } + + #[test] + fn clear_discards_a_partial_block() { + let mut fill = BlockBuilder::::new(); + assert_eq!(fill.push(1, 1), Ok(None)); + fill.clear(); + assert!(fill.is_empty()); + assert_eq!(fill.expected_sequence(), None); + assert_eq!(fill.push(20, 2), Ok(None)); + } + + #[test] + fn sequence_wrap_skips_zero() { + let mut fill = BlockBuilder::::new(); + assert_eq!(fill.push(u32::MAX, 1), Ok(None)); + let block = fill.push(1, 2).unwrap().unwrap(); + assert_eq!(block.first_sequence(), u32::MAX); + assert_eq!(block.last_sequence(), 1); + } + + #[test] + fn works_without_default_bound() { + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct NoDefault(u8); + + let mut fill = BlockBuilder::::new(); + let block = fill.push(1, NoDefault(3)).unwrap().unwrap(); + assert_eq!(block.samples(), &[NoDefault(3)]); + } + + #[test] + fn default_and_capacity_match_new() { + let fill = BlockBuilder::::default(); + assert_eq!(fill.capacity(), 8); + assert_eq!(fill.len(), 0); + } + + #[test] + fn event_buf_composition_queues_and_returns_a_rejected_block() { + let mut fill = BlockBuilder::::new(); + assert_eq!(fill.push(1, 10), Ok(None)); + let first = fill.push(2, 11).unwrap().unwrap(); + assert_eq!(fill.push(3, 12), Ok(None)); + let second = fill.push(4, 13).unwrap().unwrap(); + + let queue = EventBuf::, 1>::new(); + let producer = queue.try_producer().unwrap(); + let consumer = queue.try_consumer().unwrap(); + assert_eq!(producer.push(first), Ok(())); + assert_eq!(producer.push(second), Err(second)); + assert_eq!(consumer.pop(), Some(first)); + } +} diff --git a/src/lib.rs b/src/lib.rs index d2c2daf..01296ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,11 +4,12 @@ //! //! | Type | When to reach for it | //! |------|----------------------| +//! | [`Block`] / [`BlockBuilder`] | Complete contiguous sample windows; compose with a transport. | //! | [`RingBuf`] | Single-owner ring — simple, no atomics, `&mut` access. | //! | [`SeqRing`] | Lock-free SPSC ring that **overwrites** old entries (lossy, high-throughput). | //! | [`EventBuf`] | Lock-free SPSC ring with **backpressure** — rejects pushes when full. | //! -//! All three are fixed-size, zero-allocation, and generic over `T: Copy`. +//! All are fixed-size, zero-allocation, and generic over `T: Copy`. //! //! # Common traits //! @@ -200,12 +201,14 @@ enable either the portable-atomic-unsafe-assume-single-core or portable-atomic-c #[macro_use] mod macros; +pub mod block; pub mod event_buf; pub mod ring; pub mod seq_ring; pub(crate) mod sync; pub mod traits; +pub use block::{Block, BlockBuilder, FillError}; pub use event_buf::EventBuf; pub use ring::RingBuf; pub use seq_ring::{PollStats, SeqRing}; From 445c2b01ca10dd9f74464c1e543a3c98df490a0f Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 03:17:09 -0400 Subject: [PATCH 03/87] Develop EventFlags decision package --- docs/proposals/event-flags.md | 237 ++++++++++++++++++++++++++++++---- 1 file changed, 211 insertions(+), 26 deletions(-) diff --git a/docs/proposals/event-flags.md b/docs/proposals/event-flags.md index 215f2c4..169b107 100644 --- a/docs/proposals/event-flags.md +++ b/docs/proposals/event-flags.md @@ -92,29 +92,214 @@ it is frozen. word — but the model, not the prose, is the evidence, and the model also pins it against future "optimisation." -## 4. Open questions - -- Handle model: does `&self` on `raise` imply multiple raisers are sound - (an `AtomicU32` `fetch_or` is), and if so, is this deliberately the - crate's first non-SPSC primitive — or is the SPSC handle discipline kept - anyway for uniformity and future-proofing? -- Typed conditions: raw `u32` mask, a `bitflags`-style wrapper (no new - dependency — hand-rolled), or a const-generic/enum-driven API? What does - each cost in flash and in `missing_docs`-grade API surface? -- Width: is `u32` (32 conditions) the only offering, or does the type - parameterise over the atomic width the target supports? -- Does `take_all` need a peek variant (read without clearing), and does - that stay race-free in the same trivial way? -- Is a `Sink`/`Source` bridge meaningful at all here, or is the signal - vocabulary deliberately disjoint from the stream vocabulary (the taxonomy - implies disjoint — confirm and document)? - -## 5. Promotion bar to PROPOSED - -1. Settle the handle-model and typed-conditions questions. -2. Write the contract (short — the four clauses in §1 are most of it) with - citable clause IDs. -3. Standard evidence bar, with emphasis inverted from the ring primitives: - the codesize and cycles evidence (especially the ISR-side `raise` on - portable-atomic targets) *is* the case for admission; Loom/Miri close - the lost-event and coalescing clauses. +## 4. Exploratory decision package (2026-08-11) + +The questions below remain decisions, not accidental consequences of the first +implementation that happens to compile. Three isolated prototypes were built +from the same `master` commit so the choices can be evaluated independently: + +| Prototype | Conditions | Handles | Consumer | Deliberate omissions | +|-----------|------------|---------|----------|----------------------| +| A — raw shared word | `u32` | `EventFlags` itself; unlimited `&self` callers | any caller may `take_all(&self)` | claims, peek, traits, width genericity | +| B — enum-driven MPSC | user `EventFlag::bit() -> u8`; checked at runtime | unlimited `Copy + Sync` `Raiser`s | one claimed `Send + !Sync` `Taker` | width genericity, stream traits | +| C — SPSC typed mask | transparent `EventMask(u32)` | claimed `Producer`, `Send + !Sync` | claimed `Consumer`, `Send + !Sync` | peek, traits, width genericity | + +All three use one `AtomicU32`, `fetch_or(Release)` to raise, and `swap(0, +Acquire)` to take. All are `no_std`, allocation-free, panic-free on their hot +paths, const/static constructible outside Loom, and add no normal dependency. +They are exploration vehicles, not three APIs proposed for shipment. + +### 4.1 Candidate contract + +These clauses are implementation-independent. Their IDs are candidates for +the permanent contract; once tests and user documentation cite them, they must +not be renumbered. + +- **M1 — Initial state.** The pending condition set is initially empty. +- **M2 — Linearization.** Every raise and take has one instant between its + invocation and return at which it takes effect. +- **R1 — Raise.** At `raise(m)`'s linearization point, pending becomes the set + union of its prior value and `m`; raising the empty set changes nothing. +- **R2 — Coalescing.** Pending records only whether each condition occurred. + Duplicate raises may coalesce; multiplicity is not observable. +- **T1 — Take.** A take returns exactly the pending set immediately before its + linearization point and makes pending empty at that point. +- **T2 — Empty take.** A take linearized while pending is empty returns the + empty set and changes no observable state. +- **C1 — Window exactness.** For each condition, a take contains it if and only + if at least one matching raise linearized after the preceding take and before + this take (or after initialization for the first take). +- **C2 — Concurrent boundary.** A raise racing a take is ordered by their + linearization points. If the raise is first, the condition is in that take; + otherwise it remains pending for a later take. Clearing never erases the + later raise. +- **C3 — No fabrication.** A take returns no condition absent a matching raise + in its take window. +- **O1 — Unordered.** There is no FIFO, timestamp, multiplicity, or + cross-condition ordering guarantee. +- **S1 — Publication.** Memory actions sequenced before a raise happen-before + memory actions sequenced after a take that observes that raise. This is why + the prototypes use Release/Acquire rather than Relaxed operations. +- **B1 — Bounded hot paths.** Raise and take each perform one signal-word + operation. Neither contains a retry loop, allocation, callback, panic path, + or work proportional to history, occupancy, or the number of set bits. +- **B2 — Independence.** Producer work never waits for or invokes consumer + work, and consumer work never waits for or invokes producer work. + +Handle clauses depend on D1 and therefore cannot yet be frozen. The +conservative candidate is: at most one active handle per role; acquisition is +fallible and non-panicking; handles are `Send + !Sync`; dropping a handle makes +only that role re-acquirable; pending state survives drop/reacquisition; the +container is const/static constructible. + +### 4.2 D1 — handle and concurrency model (deferred) + +`raise(&self)` does **not** by itself imply a shareable handle. Existing crate +handles already use `&self` hot-path methods while `PhantomData>` +makes the handle `!Sync`. The auto traits, not receiver mutability, define the +concurrency contract. + +| Option | Predictability | Efficiency | Shared answer with `CountedSignal` | Cost / risk | +|--------|----------------|------------|------------------------------------|-------------| +| A: direct shared container | Atomic operations are sound with many raisers and takers, but multiple takers distribute observations nondeterministically | smallest state and API; no claim acquisition | poor — it would commit the vocabulary to contention before bounded exact saturation is solved | no exclusive observer; expands Loom and publication obligations | +| B: many raisers, one taker | one observation owner; natural fit for atomic OR | raiser is zero-claim and copyable | uncertain — exact saturating multi-raiser increment is the unresolved core of `CountedSignal` | asymmetric first non-SPSC contract | +| C: one handle per role | matches existing ownership and makes the smallest shared promise | two claim bits and one extra handle indirection; hot word operation remains one RMW | strongest — does not force `CountedSignal` to promise a bounded contended increment | leaves hardware-supported EventFlags concurrency unused | + +The current recommendation is **C**, deliberately conservative. A future MPSC +type can be added after independent evidence; an MPSC promise cannot be taken +back compatibly. This is a recommendation for the shared planning decision, +not a decision silently made on this branch. + +### 4.3 D2 — condition representation (deferred) + +| Option | Strength | Runtime / flash expectation | API and regression burden | +|--------|----------|-----------------------------|---------------------------| +| raw `u32` | exact mechanism, but unrelated domains and arbitrary bits mix freely | baseline minimum | smallest surface, weakest semantic boundary | +| transparent `EventMask(u32)` | makes set operations explicit; checked constructors can prevent shift panics; named application constants remain possible | should erase to the raw mask; preliminary results are within 2 bytes | small hand-rolled surface, no dependency | +| `EventFlag` enum trait | strong call-site vocabulary and namespace | checked shift/validation is visible in the hot path | user owns uniqueness/range correctness; largest docs and semver surface | +| const-generic/enum macro | can recover named ergonomics at compile time | must be proved per expansion/monomorphization | macro diagnostics, generated docs, and a much larger evidence matrix | + +The current recommendation is the transparent mask, with raw conversion kept +explicit and panic-free. Enum ergonomics can be layered on later by a macro if +real callers demonstrate that it earns the surface. The prototype exposed an +important limit: `EventFlag::bit()` can validate range, but cannot prove that +two enum variants do not alias the same bit. + +### 4.4 D3 — width (deferred, recommendation: `u32` only) + +`u32` matches the crate's existing atomic shim and sequence width and provides +one contract on every shipped 32-bit target. A generic atomic-word trait would +expose target-dependent atomic availability, fallback behaviour, code size, +and monomorphization as public API. `u64` is particularly misleading on these +targets: “one word” need not mean one native or one critical-section operation. + +The admission candidate should therefore provide exactly 32 conditions. +Additional named widths remain possible later, but only with a concrete user +and a separate Loom/Miri/codesize/cycles matrix. + +### 4.5 D4 — non-clearing observation (deferred, recommendation: omit) + +An atomic load is race-free and linearizable as a snapshot, but the result is +intrinsically advisory: another take may clear it immediately and a concurrent +raise may arrive immediately after it. A `peek` name invites check-then-act +reasoning that the primitive cannot uphold. Omit it from the initial surface. +If demonstrated demand later earns it, call it `snapshot_pending` or +`load_pending` and state explicitly that it predicts no later take. + +### 4.6 D5 — traits (deferred, recommendation: keep disjoint) + +Do not implement stream `Sink`/`Source`/`Link`. Coalesced state is not an item +stream, and `forward()` could destructively take a mask and then lose it when a +destination rejects the value. It also cannot report per-condition +coalescing. + +Do not freeze `SignalSink`/`SignalSource` yet. `CountedSignal` has not shown +that `raise(S) -> take_pending() -> S` is honest vocabulary: its producer +operation is an increment and its take likely returns a count-plus-saturation +report. If both primitives survive, associated `Signal` and `Pending` types on +traits implemented by handles are a better direction than one shared generic +`S`, but the second implementation must prove it first. + +## 5. Preliminary implementation evidence + +### 5.1 Behaviour and build checks + +The isolated prototypes were checked without modifying the candidate branch: + +| Prototype | Native tests | Additional checks | +|-----------|--------------|-------------------| +| A — raw shared word | 74 unit + 11 doctests + 3 compile-fail | fmt, clippy, threaded multi-raiser and raise-vs-take stress | +| B — enum-driven MPSC | 73 unit + 15 doctests | fmt, clippy, `thumbv7em`, zero normal dependencies, focused Loom raise-vs-take model | +| C — SPSC typed mask | 75 unit + 11 doctests + 3 compile-fail | fmt, clippy, `thumbv7em`, focused Loom raise-vs-take model | + +These checks establish that each API shape is viable. The focused model checks +only C2 for the prototype; it is not the complete evidence map below. + +### 5.2 Exploratory hot-path code size + +Minimal `opt-level = "z"`, LTO static-library probes were built with the pinned +rustc for three representative installed targets. Each row is the isolated +`.text.` size in bytes; handle acquisition and application call-site +code are excluded. + +| Target | raw raise / take | enum MPSC raise / take | SPSC mask raise / take | +|--------|-----------------:|-----------------------:|-----------------------:| +| `thumbv6m-none-eabi` (portable-atomic single-core) | 22 / 24 | 38 / 24 | 24 / 24 | +| `thumbv7m-none-eabi` | 24 / 26 | 36 / 26 | 26 / 26 | +| `riscv32imac-unknown-none-elf` | 6 / 6 | 20 / 8 | 8 / 8 | + +This is decision evidence, not an admission measurement: it covers three of +the eleven target rows, uses standalone probes rather than the committed gate, +and does not measure interrupt-disabled duration. It nevertheless answers two +questions usefully: + +1. The transparent mask erases as intended; the SPSC handle costs only one + extra pointer load in these non-inlined boundary probes (2 bytes per hot + function). +2. The enum trait's range check and mapping remain visible: raise is 12–16 + bytes larger than the SPSC mask and 14 bytes larger than raw on the measured + targets. Ergonomics is therefore not free on the ISR path. + +No cycle count is recorded here. Local QEMU was unavailable, and the existing +reference probe measures Cortex-M3 rather than the portable-atomic critical +section that carries this primitive's admission case. Treating a host or one +native-atomic number as closure would violate the reason this issue exists. + +## 6. Evidence map required before promotion + +- **Contract/unit:** M1–T2, empty and all-bit masks, bit 31, duplicate and + multi-bit raises, no fabrication, take clears, claim failure, static handles, + and drop/reacquisition preserving pending state. +- **Loom C1–C3:** raise racing take is observed now or later but never neither; + distinct raises partition exactly across takes plus final pending; duplicates + coalesce; no condition is fabricated or returned twice without re-raise. +- **Loom S1:** a separate payload-publication litmus. Mutating either Release or + Acquire to Relaxed must make this model fail; the bit-conservation model alone + cannot distinguish the orderings. +- **Miri:** detector-on unit/stress and handle transfer/reacquisition, including + a 32-bit std target. There is no unsafe slot access here, so EventFlags must + not inherit the `SeqRing` detector exception. +- **Code size:** committed rows for acquisition, raise, and empty/non-empty take + across all eight gated upstream targets plus the three opt-in Xtensa rows; + compare the chosen wrapper with a raw atomic baseline and confirm no panic + strings or startup data. +- **Cycles/latency:** reference-image Cortex-M3 counts for raise with a clear and + already-set bit and take when empty/non-empty; plus target-credible + instruction and maximum interrupt-disabled-window measurements for + thumbv6m and ESP32-S2/S3 portable-atomic paths. Verify exactly one RMW and no + hidden compare-exchange loop. +- **Integration:** full `verify.sh` with zero skips; README, crate docs, + CHANGELOG, AGENTS memory-ordering notes and test counts; `Cargo.toml` include + allowlist; normal `cargo tree` still the crate alone. + +## 7. Promotion bar to PROPOSED + +1. Record D1 and D2 as shared planning decisions; D1 is one answer for this + primitive and `CountedSignal`. +2. Confirm or change the recommendations for D3–D5 explicitly. +3. Freeze the accepted subset of the §4.1 clauses and handle clauses. +4. Implement the §6 evidence map. The portable-atomic ISR measurement is the + admission case; Loom/Miri close the semantic and publication clauses. + +Until steps 1–3 are explicit, status remains **EXPLORATORY** and the three +prototypes remain options rather than a de facto public API. From fd7816d620e4f5dcb893229b8eeba7c61b738e07 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 03:19:25 -0400 Subject: [PATCH 04/87] Prototype freshness-first LatestBuf channel --- AGENTS.md | 10 +- CHANGELOG.md | 9 + README.md | 31 ++- src/latest_buf.rs | 568 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 7 +- src/loom_tests.rs | 72 +++++- src/traits.rs | 17 ++ 7 files changed, 706 insertions(+), 8 deletions(-) create mode 100644 src/latest_buf.rs diff --git a/AGENTS.md b/AGENTS.md index 4aca151..9a48814 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -862,11 +862,17 @@ cargo test - `generic_drain_seq` — Trait-generic code with SeqRing - `generic_drain_event` — Trait-generic code with EventBuf +**`latest_buf::tests`:** +- publication/take, replacement evidence, and at-most-once observation +- handle uniqueness, `Send`, static construction, and stateful reacquisition +- generation wrap plus the documented beyond-span approximation +- complete-block payload support and concurrent complete-value stress + **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -and `src/traits.rs`. Total: 69 unit tests + 11 doctests, plus 3 `compile_fail` -doctests pinning the `N == 0` rejection (`E0080`) on all three types. +`src/latest_buf.rs`, and `src/traits.rs`. Total: 78 unit tests + 12 doctests, plus 3 `compile_fail` +doctests pinning the `N == 0` rejection (`E0080`) on all three ring types. ## Code Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 83507f5..5f5b66d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added +- Evaluable `LatestBuf` prototype: a three-slot, freshness-first SPSC + snapshot channel with bounded single-swap publish/take operations, + replacement and skipped-generation evidence, and channel-resident endpoint + state so handle reacquisition continues rather than restarting. Deferred + assumptions are exact accounting within one non-zero `u32` wrap (approximate + beyond it), no `Source` implementation, and generic payloads supporting + samples or complete blocks. + ### Documentation - `RingBuf::new`'s docs no longer mention a `pop` method the type does not have — `pop` was deliberately rejected (its data loss would be unreportable under overwrite; see the worked diff --git a/README.md b/README.md index 348ed85..f96a9ba 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ Stack-allocated ring buffers for no-std embedded targets. | [`RingBuf`](#ringbuf) | Single-owner ring buffer — simple, no atomics, `&mut` access. | | [`SeqRing`](#seqring) | Lock-free SPSC ring that **overwrites** old entries (lossy, high-throughput). | | [`EventBuf`](#eventbuf) | Lock-free SPSC ring with **backpressure** — rejects pushes when full. | +| [`LatestBuf`](#latestbuf) | Freshness-first SPSC snapshot — retains one newest unread value. | -All three are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Copy`. +All four are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Copy`. ## What this optimises for @@ -47,7 +48,7 @@ tooling that costs nothing at runtime — not a friendlier API that allocates, panics, or hides a cost. ## Features -- Three ring buffer flavours: single-owner, lossy SPSC, and backpressure SPSC. +- Three ring buffer flavours plus a freshness-first SPSC snapshot channel. - Common `Sink`/`Source`/`Link` traits for writing generic event-processing code. - `forward(src, snk, max)` utility to bridge any `Source` → `Sink`. - No heap, no dynamic dispatch, no required dependencies. @@ -58,6 +59,7 @@ panics, or hides a cost. - MSRV: Rust 1.92.0. - `SeqRing::new()` and `EventBuf::new()` assert `N > 0`. - `SeqRing` and `EventBuf` require 32-bit atomics by default. +- `LatestBuf` also requires 32-bit atomics and stores exactly three payload slots. - For `thumbv6m-none-eabi` (and other no-atomic targets), enable one of: - `portable-atomic-unsafe-assume-single-core` - `portable-atomic-critical-section` (requires a critical-section implementation in the binary) @@ -109,6 +111,29 @@ assert_eq!(consumer.poll_one_value(), Some((1, 123))); // consumer.poll_one(|seq, v| { ... }); ``` +### LatestBuf + +A three-slot SPSC snapshot channel for state where freshness dominates FIFO +delivery. Publishing never rejects; it reports whether an unread value was +replaced. Taking returns the newest complete value with generation and skipped +counts. Exact skipped counts are guaranteed within one non-zero `u32` wrap; +beyond a full cycle the wrapped `u32` count is only an approximation. The +consumer intentionally implements `LatestSource`, not `Source`, so gap evidence +is not silently discarded. `T` may be one sample or a complete block. + +```rust +use ph_eventing::LatestBuf; + +let channel = LatestBuf::::new(); +let producer = channel.try_producer().expect("producer"); +let consumer = channel.try_consumer().expect("consumer"); + +let _ = producer.publish(10); +assert!(producer.publish(20).replaced_unread); +let item = consumer.take_latest().expect("latest"); +assert_eq!((item.value, item.generation, item.skipped), (20, 2, 1)); +``` + ### EventBuf A bounded SPSC queue with backpressure. When the buffer is full, `push` @@ -293,7 +318,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 69 unit + 11 doctests + 3 compile-fail | Behaviour, including threaded stress tests for both SPSC types; `N == 0` rejected at compile time | +| 78 unit + 12 doctests + 3 compile-fail | Behaviour, including threaded stress tests for all SPSC types; `N == 0` rejected at compile time | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/src/latest_buf.rs b/src/latest_buf.rs new file mode 100644 index 0000000..ac54413 --- /dev/null +++ b/src/latest_buf.rs @@ -0,0 +1,568 @@ +//! Freshness-first SPSC snapshot channel. +//! +//! [`LatestBuf`] retains at most one unread publication. A producer always +//! publishes the newest complete `T`; if an older unread value is displaced, +//! [`PublishReport::replaced_unread`] reports it. The consumer takes only the +//! latest value and receives its generation plus the exact number skipped. +//! +//! The prototype uses three slots with exclusive ownership: one producer +//! slot, one consumer slot, and one slot named by an atomic exchange state. +//! An endpoint accesses a slot only after acquiring it through a single +//! atomic swap. Producer and consumer therefore never touch the same payload +//! bytes concurrently, unlike a seqlock. +//! +//! Endpoint cursors live in the channel rather than the handles. Handles are +//! stateless and dropping/reacquiring one continues its slot ownership, +//! generation sequence, and skipped accounting. +//! +//! # Deferred-policy assumptions +//! +//! This evaluable prototype makes the narrow choices requested by issue #27: +//! +//! - skipped counts are exact while fewer than `u32::MAX` non-zero +//! generations separate successful takes; beyond that full wrap span the +//! `u32` result is inherently ambiguous; +//! - [`Consumer`] deliberately does not implement [`crate::Source`], because +//! doing so would discard replacement evidence; use [`crate::LatestSource`]; +//! - `T` is generic, so it can be one sample or a caller-defined complete +//! block without committing the first-deliverable policy. +//! +//! # Example +//! +//! ``` +//! use ph_eventing::LatestBuf; +//! +//! let channel = LatestBuf::::new(); +//! let producer = channel.try_producer().expect("producer"); +//! let consumer = channel.try_consumer().expect("consumer"); +//! +//! assert!(!producer.publish(10).replaced_unread); +//! assert!(producer.publish(20).replaced_unread); +//! let item = consumer.take_latest().expect("latest value"); +//! assert_eq!((item.value, item.generation, item.skipped), (20, 2, 1)); +//! ``` + +use crate::sync::{AtomicBool, AtomicU32, Ordering, TrackedCell}; +use core::cell::Cell; +use core::marker::PhantomData; +use core::mem::MaybeUninit; + +const SLOT_MASK: u32 = 0b11; +const READY_BIT: u32 = 0b100; + +#[derive(Clone, Copy)] +struct Entry { + generation: u32, + value: T, +} + +#[derive(Clone, Copy)] +struct ProducerState { + back: u32, + next_generation: u32, +} + +#[derive(Clone, Copy)] +struct ConsumerState { + front: u32, + last_generation: u32, +} + +#[cfg(not(loom))] +const fn slot_array() -> [TrackedCell>>; 3] { + [const { TrackedCell::new(MaybeUninit::uninit()) }; 3] +} + +#[cfg(loom)] +fn slot_array() -> [TrackedCell>>; 3] { + core::array::from_fn(|_| TrackedCell::new(MaybeUninit::uninit())) +} + +/// Result of one successful latest-value publication. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use] +pub struct PublishReport { + /// Non-zero transport generation assigned to this publication. + pub generation: u32, + /// Whether this publication displaced an unread older publication. + pub replaced_unread: bool, +} + +/// A value claimed from a [`LatestBuf`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use] +pub struct LatestItem { + /// Complete value copied from the claimed publication. + pub value: T, + /// Non-zero transport generation assigned when the value was published. + pub generation: u32, + /// Publications assigned after the prior take and before this one. + /// + /// This is exact within one complete non-zero `u32` generation span. + /// Beyond that span this prototype returns modular arithmetic's + /// approximation; a full cycle that reuses the prior generation reports + /// zero because the true count cannot be recovered from two `u32` values. + pub skipped: u32, +} + +/// Three-slot freshness-first SPSC snapshot channel. +/// +/// `LatestBuf` has fixed storage for three `T` values and never allocates. +/// Publishing always succeeds and takes one atomic exchange. Taking is also +/// bounded to one exchange and at most one payload copy. +pub struct LatestBuf { + exchange: AtomicU32, + slots: [TrackedCell>>; 3], + producer_state: TrackedCell, + consumer_state: TrackedCell, + producer_taken: AtomicBool, + consumer_taken: AtomicBool, +} + +// SAFETY: the handle-acquisition flags enforce one endpoint per role. Each +// payload slot belongs exclusively to the producer, consumer, or atomic +// exchange state, and AcqRel swaps transfer both ownership and visibility. +// The role-state cells are accessed only by their unique active handle; the +// Release drop / AcqRel acquisition pair orders access across reacquisition. +unsafe impl Sync for LatestBuf {} + +impl LatestBuf { + /// Create an empty channel. + /// + /// On normal builds this is `const`, so a channel may live in a `static`. + /// Under `--cfg loom` it is non-const because Loom's primitives are not + /// const-constructible. + #[cfg(not(loom))] + pub const fn new() -> Self { + Self { + exchange: AtomicU32::new(0), + slots: slot_array(), + producer_state: TrackedCell::new(ProducerState { + back: 1, + next_generation: 0, + }), + consumer_state: TrackedCell::new(ConsumerState { + front: 2, + last_generation: 0, + }), + producer_taken: AtomicBool::new(false), + consumer_taken: AtomicBool::new(false), + } + } + + /// Create an empty channel for Loom model checking. + #[cfg(loom)] + pub fn new() -> Self { + Self { + exchange: AtomicU32::new(0), + slots: slot_array(), + producer_state: TrackedCell::new(ProducerState { + back: 1, + next_generation: 0, + }), + consumer_state: TrackedCell::new(ConsumerState { + front: 2, + last_generation: 0, + }), + producer_taken: AtomicBool::new(false), + consumer_taken: AtomicBool::new(false), + } + } + + /// Try to acquire the unique producer role. + /// + /// Returns `None` while another producer handle is active. Dropping the + /// active handle makes the role available without resetting its state. + #[inline] + pub fn try_producer(&self) -> Option> { + if self.producer_taken.swap(true, Ordering::AcqRel) { + None + } else { + Some(Producer { + buf: self, + _not_sync: PhantomData, + }) + } + } + + /// Try to acquire the unique consumer role. + /// + /// Returns `None` while another consumer handle is active. Dropping the + /// active handle makes the role available without resetting its state. + #[inline] + pub fn try_consumer(&self) -> Option> { + if self.consumer_taken.swap(true, Ordering::AcqRel) { + None + } else { + Some(Consumer { + buf: self, + _not_sync: PhantomData, + }) + } + } + + #[inline(always)] + const fn encode(slot: u32, ready: bool) -> u32 { + slot | if ready { READY_BIT } else { 0 } + } + + #[inline(always)] + const fn slot(state: u32) -> usize { + (state & SLOT_MASK) as usize + } + + #[inline(always)] + const fn ready(state: u32) -> bool { + state & READY_BIT != 0 + } + + #[inline(always)] + const fn next_generation(current: u32) -> u32 { + match current.wrapping_add(1) { + 0 => 1, + generation => generation, + } + } + + /// Assigned-generation distance in `(from, to]`, excluding reserved zero. + #[inline(always)] + const fn generation_distance(from: u32, to: u32) -> u32 { + let raw = to.wrapping_sub(from); + if to < from { raw - 1 } else { raw } + } +} + +impl Default for LatestBuf { + fn default() -> Self { + Self::new() + } +} + +impl core::fmt::Debug for LatestBuf { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("LatestBuf").finish_non_exhaustive() + } +} + +/// Unique, stateless write handle for a [`LatestBuf`]. +pub struct Producer<'a, T: Copy> { + buf: &'a LatestBuf, + _not_sync: PhantomData>, +} + +impl Producer<'_, T> { + /// Publish a complete value as the newest channel state. + /// + /// This always succeeds, executes one payload write and one atomic swap, + /// and never waits for consumer progress. + #[inline] + pub fn publish(&self, value: T) -> PublishReport { + // SAFETY: producer_taken grants the unique producer handle exclusive + // access to producer_state for its lifetime. Reacquisition is ordered + // by the handle's Release drop and AcqRel acquisition. + let (back, generation) = self.buf.producer_state.with_mut(|state| unsafe { + let state = &mut *state; + let generation = LatestBuf::::next_generation(state.next_generation); + state.next_generation = generation; + (state.back, generation) + }); + + // SAFETY: `back` is exclusively producer-owned. The producer does not + // relinquish it until the following atomic exchange. + self.buf.slots[back as usize].with_mut(|slot| unsafe { + (*slot).write(Entry { generation, value }); + }); + + let previous = self + .buf + .exchange + .swap(LatestBuf::::encode(back, true), Ordering::AcqRel); + + // SAFETY: the exchange transferred its previous slot exclusively to + // the producer. No other producer handle exists. + self.buf + .producer_state + .with_mut(|state| unsafe { (*state).back = previous & SLOT_MASK }); + + PublishReport { + generation, + replaced_unread: LatestBuf::::ready(previous), + } + } +} + +impl Drop for Producer<'_, T> { + fn drop(&mut self) { + self.buf.producer_taken.store(false, Ordering::Release); + } +} + +impl core::fmt::Debug for Producer<'_, T> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("latest_buf::Producer").finish() + } +} + +/// Unique, stateless read handle for a [`LatestBuf`]. +pub struct Consumer<'a, T: Copy> { + buf: &'a LatestBuf, + _not_sync: PhantomData>, +} + +impl Consumer<'_, T> { + /// Claim and copy the latest unread publication. + /// + /// Returns `None` when no publication is pending. The operation performs + /// one atomic swap unconditionally; this keeps the ownership protocol + /// simple while the optional empty-poll load fast path remains under + /// evaluation. + #[inline] + pub fn take_latest(&self) -> Option> { + // SAFETY: consumer_taken grants this handle exclusive role-state + // access, ordered across reacquisition by Release/AcqRel. + let front = self + .buf + .consumer_state + .with(|state| unsafe { (*state).front }); + + let previous = self + .buf + .exchange + .swap(LatestBuf::::encode(front, false), Ordering::AcqRel); + let claimed = previous & SLOT_MASK; + + // SAFETY: the exchange transferred `claimed` exclusively to this + // consumer; preserving it even on the empty path maintains the three + // disjoint ownership roles. + self.buf + .consumer_state + .with_mut(|state| unsafe { (*state).front = claimed }); + + if !LatestBuf::::ready(previous) { + return None; + } + + // SAFETY: the ready bit means the producer initialized this Entry + // before publishing it. AcqRel swap acquired both ownership and the + // initialized bytes, and no producer can reacquire the slot until a + // later exchange relinquishes it. + let entry = self.buf.slots[LatestBuf::::slot(previous)] + .with(|slot| unsafe { (*slot).assume_init_read() }); + + // SAFETY: this unique consumer handle exclusively owns consumer_state. + let skipped = self.buf.consumer_state.with_mut(|state| unsafe { + let state = &mut *state; + let distance = + LatestBuf::::generation_distance(state.last_generation, entry.generation); + state.last_generation = entry.generation; + distance.saturating_sub(1) + }); + + Some(LatestItem { + value: entry.value, + generation: entry.generation, + skipped, + }) + } +} + +impl Drop for Consumer<'_, T> { + fn drop(&mut self) { + self.buf.consumer_taken.store(false, Ordering::Release); + } +} + +impl core::fmt::Debug for Consumer<'_, T> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("latest_buf::Consumer").finish() + } +} + +impl crate::traits::LatestSink for Producer<'_, T> { + #[inline] + fn publish_latest(&mut self, value: T) -> PublishReport { + self.publish(value) + } +} + +impl crate::traits::LatestSource for Consumer<'_, T> { + #[inline] + fn try_take_latest(&mut self) -> Option> { + self.take_latest() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_empty_and_takes_each_publication_at_most_once() { + let channel = LatestBuf::::new(); + let producer = channel.try_producer().unwrap(); + let consumer = channel.try_consumer().unwrap(); + assert_eq!(consumer.take_latest(), None); + assert_eq!(producer.publish(7).generation, 1); + assert_eq!( + consumer.take_latest(), + Some(LatestItem { + value: 7, + generation: 1, + skipped: 0 + }) + ); + assert_eq!(consumer.take_latest(), None); + } + + #[test] + fn replacement_is_reported_on_both_endpoints() { + let channel = LatestBuf::::new(); + let producer = channel.try_producer().unwrap(); + let consumer = channel.try_consumer().unwrap(); + assert!(!producer.publish(10).replaced_unread); + assert!(producer.publish(20).replaced_unread); + assert!(producer.publish(30).replaced_unread); + assert_eq!( + consumer.take_latest(), + Some(LatestItem { + value: 30, + generation: 3, + skipped: 2 + }) + ); + } + + #[test] + fn handle_reacquisition_continues_role_state() { + let channel = LatestBuf::::new(); + { + let producer = channel.try_producer().unwrap(); + assert_eq!(producer.publish(1).generation, 1); + } + let producer = channel.try_producer().unwrap(); + assert_eq!( + producer.publish(2), + PublishReport { + generation: 2, + replaced_unread: true + } + ); + + { + let consumer = channel.try_consumer().unwrap(); + assert_eq!(consumer.take_latest().unwrap().skipped, 1); + } + let _ = producer.publish(3); + let _ = producer.publish(4); + let consumer = channel.try_consumer().unwrap(); + assert_eq!( + consumer.take_latest(), + Some(LatestItem { + value: 4, + generation: 4, + skipped: 1 + }) + ); + } + + #[test] + fn role_acquisition_is_unique_and_handles_are_send() { + fn assert_send() {} + assert_send::>(); + assert_send::>(); + + let channel = LatestBuf::::new(); + let producer = channel.try_producer().unwrap(); + let consumer = channel.try_consumer().unwrap(); + assert!(channel.try_producer().is_none()); + assert!(channel.try_consumer().is_none()); + drop(producer); + drop(consumer); + assert!(channel.try_producer().is_some()); + assert!(channel.try_consumer().is_some()); + } + + #[cfg(not(loom))] + #[test] + fn static_channel_yields_static_sendable_handles() { + static CHANNEL: LatestBuf = LatestBuf::new(); + fn producer() -> Producer<'static, u32> { + CHANNEL.try_producer().unwrap() + } + fn consumer() -> Consumer<'static, u32> { + CHANNEL.try_consumer().unwrap() + } + let producer = producer(); + let consumer = consumer(); + let _ = producer.publish(42); + assert_eq!(consumer.take_latest().unwrap().value, 42); + } + + #[test] + fn generation_wrap_skips_zero_and_counts_gap_exactly() { + let channel = LatestBuf::::new(); + // SAFETY: no producer handle exists while the test seeds role state. + channel.producer_state.with_mut(|state| unsafe { + (*state).next_generation = u32::MAX - 1; + }); + // SAFETY: no consumer handle exists while the test seeds role state. + channel.consumer_state.with_mut(|state| unsafe { + (*state).last_generation = u32::MAX - 1; + }); + let producer = channel.try_producer().unwrap(); + let consumer = channel.try_consumer().unwrap(); + assert_eq!(producer.publish(1).generation, u32::MAX); + assert_eq!(producer.publish(2).generation, 1); + assert_eq!( + consumer.take_latest(), + Some(LatestItem { + value: 2, + generation: 1, + skipped: 1 + }) + ); + assert_eq!(LatestBuf::::generation_distance(u32::MAX, 1), 1); + } + + #[test] + fn full_generation_cycle_uses_documented_approximation() { + // Equal endpoints are indistinguishable from no progress after a full + // generation cycle. D1 remains deferred, so this prototype documents + // and pins its modular approximation instead of claiming exactness. + assert_eq!(LatestBuf::::generation_distance(17, 17), 0); + } + + #[test] + fn generic_payload_can_be_a_complete_block() { + let channel = LatestBuf::<[u16; 4]>::new(); + let producer = channel.try_producer().unwrap(); + let consumer = channel.try_consumer().unwrap(); + let _ = producer.publish([1, 2, 3, 4]); + assert_eq!(consumer.take_latest().unwrap().value, [1, 2, 3, 4]); + } + + #[test] + fn concurrent_publication_never_returns_torn_value() { + let channel = LatestBuf::<[u32; 4]>::new(); + let total = crate::test_support::iterations(50_000); + std::thread::scope(|scope| { + scope.spawn(|| { + let producer = channel.try_producer().unwrap(); + for value in 1..=total { + let _ = producer.publish([value; 4]); + } + }); + let consumer = channel.try_consumer().unwrap(); + let mut last_generation = 0; + while last_generation < total { + if let Some(item) = consumer.take_latest() { + assert!(item.generation > last_generation); + assert_eq!(item.value, [item.value[0]; 4]); + last_generation = item.generation; + } else { + std::thread::yield_now(); + } + } + }); + } +} diff --git a/src/lib.rs b/src/lib.rs index d2c2daf..ea5e2f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,8 +7,9 @@ //! | [`RingBuf`] | Single-owner ring — simple, no atomics, `&mut` access. | //! | [`SeqRing`] | Lock-free SPSC ring that **overwrites** old entries (lossy, high-throughput). | //! | [`EventBuf`] | Lock-free SPSC ring with **backpressure** — rejects pushes when full. | +//! | [`LatestBuf`] | Freshness-first SPSC snapshot — retains one newest unread value. | //! -//! All three are fixed-size, zero-allocation, and generic over `T: Copy`. +//! All four are fixed-size, zero-allocation, and generic over `T: Copy`. //! //! # Common traits //! @@ -201,15 +202,17 @@ enable either the portable-atomic-unsafe-assume-single-core or portable-atomic-c mod macros; pub mod event_buf; +pub mod latest_buf; pub mod ring; pub mod seq_ring; pub(crate) mod sync; pub mod traits; pub use event_buf::EventBuf; +pub use latest_buf::{LatestBuf, LatestItem, PublishReport}; pub use ring::RingBuf; pub use seq_ring::{PollStats, SeqRing}; -pub use traits::{Link, Sink, Source}; +pub use traits::{LatestSink, LatestSource, Link, Sink, Source}; #[cfg(all(loom, test))] mod loom_tests; diff --git a/src/loom_tests.rs b/src/loom_tests.rs index a2d6b3f..2dfdf18 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -19,10 +19,80 @@ // they remain public API until 0.3.0, so their orderings still need proving. #![allow(deprecated)] -use crate::{EventBuf, SeqRing}; +use crate::{EventBuf, LatestBuf, SeqRing}; use loom::sync::Arc; +use loom::sync::atomic::{AtomicBool, Ordering}; use loom::thread; +/// The three-slot exchange transfers complete payload ownership in both +/// directions. Loom's tracked cells make either a missing Release (offered +/// slot) or missing Acquire (claimed slot) observable as an access violation. +#[test] +fn latest_buf_returns_only_complete_publications() { + loom::model(|| { + let channel = Arc::new(LatestBuf::<[u32; 2]>::new()); + + let producer_channel = Arc::clone(&channel); + let producer = thread::spawn(move || { + let producer = producer_channel.try_producer().unwrap(); + let _ = producer.publish([1, 1]); + let _ = producer.publish([2, 2]); + }); + + let consumer = thread::spawn(move || { + let consumer = channel.try_consumer().unwrap(); + for _ in 0..2 { + if let Some(item) = consumer.take_latest() { + assert!(item.generation == 1 || item.generation == 2); + assert_eq!(item.value, [item.generation; 2]); + } + thread::yield_now(); + } + }); + + producer.join().unwrap(); + consumer.join().unwrap(); + }); +} + +/// Channel-resident producer state is published by handle drop and acquired +/// by the next handle, so reacquisition in another context resumes generation. +#[test] +fn latest_buf_producer_reacquisition_continues_across_threads() { + loom::model(|| { + let channel = Arc::new(LatestBuf::::new()); + let first_acquired = Arc::new(AtomicBool::new(false)); + let first_channel = Arc::clone(&channel); + let first_signal = Arc::clone(&first_acquired); + let first = thread::spawn(move || { + let producer = first_channel.try_producer().unwrap(); + // Signal before mutating role state. This orders initial role + // selection but deliberately does not publish the continuation + // write; that must travel through handle drop/acquisition. + first_signal.store(true, Ordering::Release); + assert_eq!(producer.publish(1).generation, 1); + }); + + let second = thread::spawn(move || { + if first_acquired.load(Ordering::Acquire) { + channel + .try_producer() + .map(|producer| producer.publish(2).generation) + } else { + None + } + }); + + first.join().unwrap(); + // Loom explores both outcomes: acquisition while the first handle is + // live fails, while acquisition after its Release drop succeeds and + // must observe the channel-resident continuation state. + if let Some(generation) = second.join().unwrap() { + assert_eq!(generation, 2); + } + }); +} + /// Every item the producer pushes is popped exactly once, in order, with no /// duplicates and no losses — under every interleaving. /// diff --git a/src/traits.rs b/src/traits.rs index eb3de0c..97587ad 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -49,6 +49,23 @@ pub trait Source { fn try_pop(&mut self) -> Option; } +/// Publish complete newest-state values with replacement evidence. +/// +/// Unlike [`Sink`], this trait exposes whether an unread older value was +/// displaced by the publication. +pub trait LatestSink { + /// Publish `value` and report its generation and any replacement. + fn publish_latest(&mut self, value: T) -> crate::latest_buf::PublishReport; +} + +/// Take the latest complete state together with generation and gap evidence. +/// +/// This is deliberately distinct from FIFO-oriented [`Source`]. +pub trait LatestSource { + /// Claim the newest unread publication, or return `None` when empty. + fn try_take_latest(&mut self) -> Option>; +} + /// A bidirectional pass-through: accepts `In` and yields `Out`. /// /// This is automatically implemented for any type that is both From 72a5546ad5e1aa855118c69404f7baa08f3976ee Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 03:18:57 -0400 Subject: [PATCH 05/87] Add LatestBuf implementation evaluation scaffold --- docs/proposals/latest-buf-evaluation.md | 188 ++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/proposals/latest-buf-evaluation.md diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md new file mode 100644 index 0000000..ed5cb4d --- /dev/null +++ b/docs/proposals/latest-buf-evaluation.md @@ -0,0 +1,188 @@ +# LatestBuf implementation evaluation + +- **Purpose:** implementation-independent comparison record for issue #27. +- **Inputs:** [`latest-buf.md`](latest-buf.md), + [`latest-buf-contract.md`](latest-buf-contract.md), and the BlockBuf candidate. +- **Status:** evaluation scaffold; it does not select an implementation before + the evidence below exists. + +## 1. Decisions sufficient for an evaluable prototype + +These defaults make development possible without pretending the deferred +choices have received a permanent API decision. + +| Decision | Prototype default | Evidence or decision that would change it | +|---|---|---| +| D1, generation ambiguity | Exact within one non-zero `u32` span. Beyond that span use the documented approximation `generation_distance(last, current).saturating_sub(1)`; a repeated generation after exactly one full cycle therefore reports zero rather than panicking. Applications requiring a longer identity span carry a wider producer-assigned sequence in `T`. | Adopt an explicit `Gap::Unknown`-style API only with a concrete mechanism that can detect the wrap (extra epoch metadata, not inference from equal `u32` values), plus its RAM, flash, and cycle results. | +| D2, `Source` | No `Source` implementation. Provide `LatestSource` so replacement evidence remains structural. | A design that preserves loss evidence through generic `Source`/`forward`; documentation alone is insufficient. | +| D3, sample or block | Implement generic `LatestBuf` first. A complete block is a `T`; the BlockBuf candidate demonstrates `LatestBuf>` for latest and `EventBuf, Q>` for queued delivery. | A separate block transport must enforce a guarantee composition cannot, such as direct-to-granted-slot filling with a measured copy/RAM win. | +| A.3, role continuation | Compare channel-resident role state with handle-resident state persisted on `Drop`. Keep H2/H4 in both candidates. | Narrow H2 only if both candidates fail the evidence bar; reacquisition must not silently restart. | + +The BlockBuf branch also makes `DropBlockBuf` a caller policy over the block +returned by `EventBuf::push`, not a third synchronization primitive. A named +type earns its place only through a distinct synchronization or accounting +contract. + +## 2. State-persistence comparison + +Both candidates use the same three exclusive roles and a single encoded +exchange atomic. Both endpoint exchanges must be `AcqRel`: the Release half +relinquishes the offered slot, while the Acquire half makes the acquired old +exchange slot safe to read or reuse. + +| Property | Channel-resident role state | Persist-on-drop handle state | +|---|---|---| +| Steady-state role fields | `{back, next_generation}` and `{front, last_generation}` in role-owned channel cells | Fields in the active handles, with saved copies in the channel between handles | +| Reacquisition proof | Drop Release-clears `*_taken`; successful AcqRel acquisition orders all prior role-cell access | `Drop` must save every field before Release-clearing `*_taken`; acquisition must Acquire before loading every saved field | +| Failure surface | Follows current stateless-handle precedent; no state-copying Drop path | Missing or reordered Drop field strands a slot or restarts generation/accounting | +| Expected performance | May reload/store role fields through the channel on every operation | Fields may remain in registers across calls; Drop/reacquire does extra work | +| Handle size | Reference plus `!Sync` marker | Reference, role fields, and `!Sync` marker | +| Selection evidence | Loom/Miri correctness, then cycles and code size | Same; an unmeasured register-residency claim is not a win | + +For channel-resident state, producer and consumer state require separate +tracked cells. They are role-owned, not concurrently shared. Reacquisition is +nevertheless a cross-context handoff and depends on the taken flag's +Release/Acquire pair. For persist-on-drop, a joined first thread must not be +used as the only model: `join` adds an independent happens-before edge that +can conceal a broken taken-flag handoff. + +Handles in both candidates must be `Send + !Sync`. A reference to a `Sync` +channel otherwise makes a marker-free handle `Sync` automatically. + +## 3. Required Loom models + +Use tracked payload and role-state cells. A model that replaces payloads with +atomics proves only state encoding, not exclusive access. + +### L1 — two publications versus two takes + +One producer publishes distinct `(generation, value)` pairs 1 and 2 while one +consumer calls `take_latest` twice. + +Assert after every explored execution: + +- the first report has `replaced_unread == false`; +- the second report has `replaced_unread == !consumer_observed_generation_1`; +- every returned generation maps to exactly its published value; +- no publication instance is returned twice; +- returned generations do not go backwards for this no-wrap model; +- each successful take reports + `skipped == generation_distance(previous, current) - 1`; and +- returned, skipped, pending, and displaced-awaiting-report publications + satisfy O2 without double counting. + +This model covers P1-P6, C1-C6, and O1-O3. Fixed calls are preferable to an +unbounded drain loop. + +### L2 — stalled consumer + +The consumer claims generation 1 and retains its private front role while the +producer publishes 2, 3, and 4. Every publish must complete, the producer must +never access the retained slot, and the next successful take must return 4 +with two skipped publications. This is the direct evidence for C7 rather than +an inference from the absence of loops. + +### L3 — role drop and cross-context reacquisition + +The old handle mutates all continuation fields, drops, and a concurrently +running second thread retries acquisition with yields until it succeeds. The +old thread must not be joined before acquisition. Assert generation resumes, +gap accounting resumes, and tracked cells report no overlapping ownership. +Model producer and consumer roles separately so a failure identifies the +broken handoff. + +### L4 — optional empty-poll fast path + +If A.1 is implemented, interleave its ready-bit load with publication before, +during, and after the load. A false load returns without touching a slot and +can linearize before a concurrent publication. A true load must still use the +normal `AcqRel` exchange; a pre-load is not an ownership transfer. Compare +this model with the unconditional-swap reference before measuring it. + +## 4. Ordering mutation matrix + +A passing model is not ordering evidence until plausible weakenings fail. +Run each mutation independently and record which model rejects it. + +| Mutation | Obligation removed | Expected detector | +|---|---|---| +| Producer exchange `AcqRel -> Acquire` | Release of the newly published slot | L1/L2 tracked payload race or stale value | +| Producer exchange `AcqRel -> Release` | Acquire of its next writable slot | L1/L2 tracked payload race | +| Producer exchange `AcqRel -> Relaxed` | Both halves | L1/L2 | +| Consumer exchange `AcqRel -> Acquire` | Release of its old private slot | L1/L2 tracked payload race | +| Consumer exchange `AcqRel -> Release` | Acquire of the claimed publication | L1 stale/unpublished value or tracked-cell failure | +| Consumer exchange `AcqRel -> Relaxed` | Both halves | L1/L2 | +| Handle drop `Release -> Relaxed` | Publication of saved/role-owned continuation state | L3 | +| Handle acquisition loses Acquire | Visibility of prior handle state | L3 | + +If a mutation survives, first strengthen the model so it observes the stated +obligation. Do not strengthen production ordering merely to make a mutation +test convenient. + +## 5. Unit and stress evidence + +Sequential unit tests pin API semantics and arithmetic: + +- new channel empty; publication 1 taken once, then empty; +- three publishes before a take yield replacement reports `false, true, + true`, generation 3, and `skipped == 2`; +- drop/reacquire each role independently and together; +- second producer/consumer acquisition fails without panic; +- static construction and `Send + !Sync` compile assertions; +- `T: Copy` without `Default`, plus an array/block payload; +- successor `u32::MAX -> 1`, never zero; +- `(last, current) = (u32::MAX, 1)` gives distance 1 and skipped 0; +- `(u32::MAX - 1, 1)` gives skipped 1; and +- `last == current` at the full-cycle ambiguity seam does not underflow or + panic and matches the selected D1 approximation. + +A threaded stress test uses a patterned multiword payload and checks that a +returned value belongs wholly to one generation. It is useful scale evidence, +but it is not the race-freedom claim: Miri with the race detector on is. + +The wording "bit-for-bit the value passed" in P6 is too strong for generic +Rust `T`: `Copy` does not promise preservation of padding bytes, and inspecting +padding is not a valid generic test. Before adoption, P6/C6 should say that the +returned `T` is the value from one publication under Rust value semantics, +never a torn or mixed value. Patterned payload tests can then exercise that +claim honestly. + +## 6. Miri and bounded-cost evidence + +The detector-on Miri pass must include LatestBuf rather than placing it in the +known SeqRing exclusion. It covers normal publication/take, replacement, +stalled-consumer reuse, and both role-reacquisition paths. A detector-off pass +or a native stress pass does not satisfy P6/C6. + +Measure these regions separately: + +- first publication and replacement publication; +- empty and pending `take_latest`; +- channel-state and persist-on-drop steady-state operations; +- handle drop and reacquisition for both candidates; +- `u32`, 16-byte, and representative block payloads; and +- unconditional empty swap versus A.1, if retained. + +Publication and take may scale with `size_of::()`, but not with lag, +occupancy, or history. Report handle size, channel size, and three-slot payload +RAM explicitly. Run code size across the complete target matrix, with +thumbv6m and ESP32-S2 treated as the kill-criterion rows because every RMW may +become an interrupt-disabled critical section. + +## 7. Acceptance record + +An implementation is ready to compare only when its PR can fill every cell: + +| Evidence | Channel state | Persist on drop | +|---|---|---| +| Unit semantics and wrap seam | pending | pending | +| L1-L4 Loom models | pending | pending | +| All applicable ordering mutations fail | pending | pending | +| Miri race detector on | pending | pending | +| Native patterned-payload stress | pending | pending | +| Full code-size matrix | pending | pending | +| Cycle regions above | pending | pending | +| Handle/channel/RAM sizes | pending | pending | + +Correctness removes a candidate; measurements select between candidates that +remain. Ergonomics is evaluated only after those results. From 3065335daf45a8a8bea3fcc5759bd459fdcc60d0 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 03:22:06 -0400 Subject: [PATCH 06/87] Strengthen LatestBuf evaluation evidence --- docs/proposals/latest-buf-contract.md | 11 ++--- docs/proposals/latest-buf-evaluation.md | 17 +++++--- src/loom_tests.rs | 57 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/docs/proposals/latest-buf-contract.md b/docs/proposals/latest-buf-contract.md index 2e2cee2..6e3dde4 100644 --- a/docs/proposals/latest-buf-contract.md +++ b/docs/proposals/latest-buf-contract.md @@ -67,8 +67,9 @@ and all clauses below are stated against the sequence of those instants. - **P6.** `publish` publishes the complete value or, if the operation never linearizes (e.g. the producer is torn down mid-call by the environment), nothing. No partial publication is ever observable. ("Complete" means: - the value later observed under `g` is bit-for-bit the `v` passed to this - call.) + the value later observed under `g` comes from that one `v` under Rust value + semantics; it is never torn or mixed with another publication. This does + not promise preservation or observability of padding bytes.) ## 4. Consumer clauses (C) @@ -99,9 +100,9 @@ and all clauses below are stated against the sequence of those instants. previously seen generation value, so the comparison is inherently ambiguous there — closing D1 must define the ordering behaviour for that case along with the gap reporting. -- **C6.** The value returned under generation `g` is exactly and completely - the value published under `g` (with P6: no torn, mixed, or partially - initialized value is ever returned). +- **C6.** The value returned under generation `g` is the complete Rust value + published under `g` (with P6: no torn, mixed, or partially initialized value + is ever returned). - **C7.** `take_latest` never waits for the producer, never invokes it, and never delays it: its cost bound (B2) is independent of producer activity, and the consumer may hold a returned value indefinitely diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index ed5cb4d..8fff15d 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -175,14 +175,21 @@ An implementation is ready to compare only when its PR can fill every cell: | Evidence | Channel state | Persist on drop | |---|---|---| -| Unit semantics and wrap seam | pending | pending | -| L1-L4 Loom models | pending | pending | -| All applicable ordering mutations fail | pending | pending | -| Miri race detector on | pending | pending | -| Native patterned-payload stress | pending | pending | +| Unit semantics and wrap seam | pass: 9 focused tests | pass: 8 focused tests on comparison branch | +| L1-L4 Loom models | partial: payload ownership plus producer and consumer L3 pass; L2 and optional L4 remain | partial: six models pass, but its joined L3 does not isolate the taken-flag handoff | +| All applicable ordering mutations fail | pending | partial: Drop `Release -> Relaxed` and claim `AcqRel -> Release` fail Loom; exchange matrix remains | +| Miri race detector on | pass: 9 focused tests, including threaded patterned payload | pass: 8 focused tests on comparison branch | +| Native patterned-payload stress | pass | not implemented | +| Embedded compile matrix | pass: thumbv6m portable-atomic, thumbv7em, riscv32imac | pass: same library paths, candidate-specific rerun pending | | Full code-size matrix | pending | pending | | Cycle regions above | pending | pending | | Handle/channel/RAM sizes | pending | pending | Correctness removes a candidate; measurements select between candidates that remain. Ergonomics is evaluated only after those results. + +The channel-state prototype is the integration default because it follows the +crate's stateless-handle precedent and has no Drop-time state-copying path. +The persist-on-drop prototype remains on its independent comparison branch; +it is not rejected, but its possible register-residency advantage must be +measured and its L3 model repaired before it can displace the default. diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 2dfdf18..cc96bd3 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -93,6 +93,63 @@ fn latest_buf_producer_reacquisition_continues_across_threads() { }); } +/// Consumer continuation state crosses the taken flag's Release/Acquire +/// handoff. The relaxed `first_took` signal controls the model without adding +/// an independent happens-before edge for `last_generation`. +#[test] +fn latest_buf_consumer_reacquisition_continues_across_threads() { + loom::model(|| { + let channel = Arc::new(LatestBuf::::new()); + { + let producer = channel.try_producer().unwrap(); + assert_eq!(producer.publish(1).generation, 1); + } + + let first_took = Arc::new(AtomicBool::new(false)); + let later_published = Arc::new(AtomicBool::new(false)); + + let first_channel = Arc::clone(&channel); + let first_signal = Arc::clone(&first_took); + let first = thread::spawn(move || { + let consumer = first_channel.try_consumer().unwrap(); + assert_eq!(consumer.take_latest().unwrap().generation, 1); + // Relaxed deliberately: successful reacquisition, not this test + // signal, must publish the role-owned continuation state. + first_signal.store(true, Ordering::Relaxed); + }); + + let publisher_channel = Arc::clone(&channel); + let publisher_start = Arc::clone(&first_took); + let publisher_done = Arc::clone(&later_published); + let publisher = thread::spawn(move || { + if publisher_start.load(Ordering::Relaxed) { + let producer = publisher_channel.try_producer().unwrap(); + let _ = producer.publish(2); + let _ = producer.publish(3); + publisher_done.store(true, Ordering::Release); + } + }); + + let second = thread::spawn(move || { + if later_published.load(Ordering::Acquire) { + channel + .try_consumer() + .and_then(|consumer| consumer.take_latest()) + } else { + None + } + }); + + first.join().unwrap(); + publisher.join().unwrap(); + if let Some(item) = second.join().unwrap() { + assert_eq!(item.generation, 3); + assert_eq!(item.value, 3); + assert_eq!(item.skipped, 1); + } + }); +} + /// Every item the producer pushes is popped exactly once, in order, with no /// duplicates and no losses — under every interleaving. /// From 72cb22f21df63d3be15f6f6ac2c20b90e546c0d2 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 03:27:52 -0400 Subject: [PATCH 07/87] Validate LatestBuf ordering mutations --- docs/proposals/latest-buf-evaluation.md | 10 +++- src/loom_tests.rs | 69 +++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index 8fff15d..e270e12 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -177,7 +177,7 @@ An implementation is ready to compare only when its PR can fill every cell: |---|---|---| | Unit semantics and wrap seam | pass: 9 focused tests | pass: 8 focused tests on comparison branch | | L1-L4 Loom models | partial: payload ownership plus producer and consumer L3 pass; L2 and optional L4 remain | partial: six models pass, but its joined L3 does not isolate the taken-flag handoff | -| All applicable ordering mutations fail | pending | partial: Drop `Release -> Relaxed` and claim `AcqRel -> Release` fail Loom; exchange matrix remains | +| All applicable ordering mutations fail | pass: all six exchange and four role-handoff weakenings detected | partial: Drop `Release -> Relaxed` and claim `AcqRel -> Release` fail Loom; exchange matrix remains | | Miri race detector on | pass: 9 focused tests, including threaded patterned payload | pass: 8 focused tests on comparison branch | | Native patterned-payload stress | pass | not implemented | | Embedded compile matrix | pass: thumbv6m portable-atomic, thumbv7em, riscv32imac | pass: same library paths, candidate-specific rerun pending | @@ -193,3 +193,11 @@ crate's stateless-handle precedent and has no Drop-time state-copying path. The persist-on-drop prototype remains on its independent comparison branch; it is not rejected, but its possible register-residency advantage must be measured and its L3 model repaired before it can displace the default. + +For the channel-state mutation run, Loom rejects producer `Acquire`/`Relaxed` +and consumer `Release`/`Relaxed` exchange orderings, plus both roles' weakened +Drop and reacquisition orderings. Miri's race detector rejects the two +relinquish-only exchange mutations (`producer -> Release`, `consumer -> +Acquire`) that Loom's tracked-cell model does not expose, with failing seeds +53 and 47 respectively. Production remains `AcqRel` on both exchanges and +Release/Acquire on both role handoffs. diff --git a/src/loom_tests.rs b/src/loom_tests.rs index cc96bd3..139712c 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -21,7 +21,7 @@ use crate::{EventBuf, LatestBuf, SeqRing}; use loom::sync::Arc; -use loom::sync::atomic::{AtomicBool, Ordering}; +use loom::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use loom::thread; /// The three-slot exchange transfers complete payload ownership in both @@ -37,13 +37,14 @@ fn latest_buf_returns_only_complete_publications() { let producer = producer_channel.try_producer().unwrap(); let _ = producer.publish([1, 1]); let _ = producer.publish([2, 2]); + let _ = producer.publish([3, 3]); }); let consumer = thread::spawn(move || { let consumer = channel.try_consumer().unwrap(); - for _ in 0..2 { + for _ in 0..3 { if let Some(item) = consumer.take_latest() { - assert!(item.generation == 1 || item.generation == 2); + assert!((1..=3).contains(&item.generation)); assert_eq!(item.value, [item.generation; 2]); } thread::yield_now(); @@ -55,6 +56,68 @@ fn latest_buf_returns_only_complete_publications() { }); } +/// Forces a slot to travel producer -> consumer -> exchange -> producer. +/// Relaxed phase markers influence scheduling without supplying the +/// happens-before edges that the exchange itself must provide. +#[test] +fn latest_buf_reused_slot_keeps_exclusive_ownership() { + loom::model(|| { + let channel = Arc::new(LatestBuf::<[u32; 2]>::new()); + let phase = Arc::new(AtomicU32::new(0)); + + let producer_channel = Arc::clone(&channel); + let producer_phase = Arc::clone(&phase); + let producer = thread::spawn(move || { + let producer = producer_channel.try_producer().unwrap(); + let _ = producer.publish([1, 1]); + producer_phase.store(1, Ordering::Relaxed); + for _ in 0..3 { + if producer_phase.load(Ordering::Relaxed) >= 2 { + let _ = producer.publish([2, 2]); + producer_phase.store(3, Ordering::Relaxed); + break; + } + thread::yield_now(); + } + for _ in 0..3 { + if producer_phase.load(Ordering::Relaxed) >= 4 { + let _ = producer.publish([3, 3]); + producer_phase.store(5, Ordering::Relaxed); + break; + } + thread::yield_now(); + } + }); + + let consumer = thread::spawn(move || { + let consumer = channel.try_consumer().unwrap(); + for _ in 0..3 { + if phase.load(Ordering::Relaxed) >= 1 { + if let Some(item) = consumer.take_latest() { + assert_eq!(item.value, [item.generation; 2]); + phase.store(2, Ordering::Relaxed); + } + break; + } + thread::yield_now(); + } + for _ in 0..3 { + if phase.load(Ordering::Relaxed) >= 3 { + if let Some(item) = consumer.take_latest() { + assert_eq!(item.value, [item.generation; 2]); + phase.store(4, Ordering::Relaxed); + } + break; + } + thread::yield_now(); + } + }); + + producer.join().unwrap(); + consumer.join().unwrap(); + }); +} + /// Channel-resident producer state is published by handle drop and acquired /// by the next handle, so reacquisition in another context resumes generation. #[test] From bc54a9a1c3bc6ba03379dc0bd410f32e37a9a858 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 04:07:46 -0400 Subject: [PATCH 08/87] Measure BlockBuf publication costs --- AGENTS.md | 11 ++ docs/proposals/block-buf-measurements.md | 102 ++++++++++++++++++ docs/proposals/block-buf.md | 30 +++++- scripts/codesize.sh | 55 +++++++++- scripts/codesize/Cargo.toml | 2 + scripts/codesize/src/lib.rs | 44 ++++++++ scripts/cycles.sh | 46 ++++++++- scripts/cycles/Cargo.toml | 5 + scripts/cycles/src/main.rs | 126 ++++++++++++++++++++++- scripts/verify.sh | 6 +- 10 files changed, 412 insertions(+), 15 deletions(-) create mode 100644 docs/proposals/block-buf-measurements.md diff --git a/AGENTS.md b/AGENTS.md index f1a8a9c..636ab1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -633,6 +633,7 @@ Re-bless deliberately, never reflexively — the diff is the review: ```bash ./scripts/codesize.sh # baseline, 8 upstream targets ./scripts/codesize.sh split # include try_split, on branches that have it +./scripts/codesize.sh block-matrix # BlockBuilder completion + EventBuf publication XTENSA=1 ./scripts/codesize.sh # add the 3 ESP32 rows ``` @@ -666,6 +667,16 @@ environment*, not universal: a 10.2 QEMU build shifted two of the eighteen regions by exactly one instruction (trace boundary attribution, not codegen). Cross-environment diffs of ±1 are noise; compare inside the image. +The BlockBuf candidate's large-payload mode is isolated from the standard +probe so its monomorphisations cannot perturb the standard LTO decisions: + +```bash +./scripts/cycles.sh block-matrix +./scripts/verify.sh cycles block-matrix # pinned reference environment +``` + +Run the default probe as well when changing shared measurement infrastructure. + | | empty | loaded | rejected/empty | |---|---:|---:|---:| | `EventBuf::push` | 25 | **25** (7 of 8) | 19 (full, rejected) | diff --git a/docs/proposals/block-buf-measurements.md b/docs/proposals/block-buf-measurements.md new file mode 100644 index 0000000..1dbcd9c --- /dev/null +++ b/docs/proposals/block-buf-measurements.md @@ -0,0 +1,102 @@ +# BlockBuf publication-cost matrix + +- **Measured:** 2026-08-11 +- **Source branch:** `candidate/block-buf` at `e21c7fa`, plus the isolated + measurement harness recorded with this document. +- **Compiler:** `rustc 1.92.0 (ded5c06cf 2025-12-08)`. +- **Cycle reference:** `QEMU emulator version 10.0.11`, from the repository's + `ph-eventing-verify` image. +- **Shapes:** sample widths 2, 8, and 16 bytes; `N = 8, 32, 128`. + +This is the measurement required by [block-buf.md](block-buf.md) section 6 and +decision **P** on issue #26. It measures the final `BlockBuilder::push` that +completes a block together with `EventBuf, 1>::push`. The builder is +pre-filled with `N - 1` samples outside the measured region, so the acquisition +loop is not charged to publication. + +Accepted and rejected publication are separate instruction regions. Rejection +preserves and returns the complete block, so both paths cross two logical +block-sized value boundaries after the final sample: + +- accepted: builder completion, then slot publication; +- rejected: builder completion, then return of the rejected block. + +The byte columns below are those conservative logical boundaries. Compiler +move elision is deliberately not claimed; the retired-instruction counts are +the measurement of the optimized result. + +## Reference instruction counts + +Run with: + +```sh +./scripts/verify.sh cycles block-matrix +``` + +| Width | N | `Block` bytes | Logical bytes/path | Accepted instructions | Rejected instructions | +|---:|---:|---:|---:|---:|---:| +| 2 | 8 | 24 | 48 | 159 | 151 | +| 2 | 32 | 72 | 144 | 606 | 595 | +| 2 | 128 | 264 | 528 | 1,373 | 1,362 | +| 8 | 8 | 72 | 144 | 618 | 611 | +| 8 | 32 | 264 | 528 | 1,385 | 1,378 | +| 8 | 128 | 1,032 | 2,064 | 4,502 | 4,497 | +| 16 | 8 | 136 | 272 | 875 | 844 | +| 16 | 32 | 520 | 1,040 | 2,415 | 2,406 | +| 16 | 128 | 2,056 | 4,112 | 8,658 | 8,644 | + +The same probe under local QEMU 10.2.1 reproduced all 18 rows exactly. The +decision record nevertheless uses the pinned 10.0.11 values above because the +runner's trace-boundary attribution is only guaranteed per environment. + +## Code size across all gated targets + +Run with: + +```sh +./scripts/codesize.sh block-matrix +``` + +Each entry is emitted flash bytes for one final-completion-plus-publication API +shape. Accepted and rejected execution share the same function; their runtime +paths are separated by the instruction probe above. + +| Width × N | M0 `thumbv6m` | M23 `thumbv8m.base` | M3 `thumbv7m` | M4 `thumbv7em` | M33 `thumbv8m.main` | ARMv7-R | ARMv7-A | RV32IMAC | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 2 × 8 | 138 | 136 | 136 | 136 | 138 | 212 | 212 | 150 | +| 2 × 32 | 168 | 168 | 132 | 132 | 126 | 248 | 248 | 190 | +| 2 × 128 | 200 | 190 | 130 | 130 | 122 | 252 | 252 | 210 | +| 8 × 8 | 192 | 192 | 120 | 120 | 114 | 312 | 312 | 202 | +| 8 × 32 | 224 | 228 | 118 | 118 | 110 | 312 | 312 | 222 | +| 8 × 128 | 240 | 244 | 178 | 178 | 182 | 312 | 312 | 252 | +| 16 × 8 | 242 | 242 | 200 | 200 | 204 | 296 | 296 | 250 | +| 16 × 32 | 254 | 250 | 208 | 208 | 212 | 288 | 288 | 248 | +| 16 × 128 | 268 | 264 | 216 | 216 | 216 | 300 | 300 | 300 | + +The non-monotonic flash rows are expected: larger payload moves often lower to +shared copy routines, so payload traffic grows without duplicating the same +amount of inline code. This is why flash and instructions are both required. + +## Result for decisions P and S + +The matrix establishes that Copy composition is bounded but not constant in +block size. On the reference Cortex-M3, accepted completion plus publication +ranges from 159 to 8,658 retired instructions; preserving a rejected complete +block is within 5–31 instructions of the accepted path rather than being a +cheap scalar error return. + +The record still contains no named ISR/task instruction budget and no RAM +envelope. Therefore this measurement does **not** close decision **P** and does +not authorize either branch of **S**: + +- if a selected shape's budget covers its accepted row and the extra + builder-sized storage is acceptable, Copy composition remains the safe + default; +- if the accepted row exceeds that budget, or the extra builder-sized storage + is unacceptable, the next authorized work is the representative + BlockBuf-over-SlotPool integration. + +Choosing a threshold here would substitute an arbitrary library-wide number +for the application budget the proposal explicitly requires. The maintainer's +next input is a named instruction/time budget and RAM envelope for the shapes +the cycle intends to support. diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index fc68c49..07cc196 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -201,6 +201,26 @@ Required matrix before promotion: - all code-size targets and the reference QEMU cycle target; and - both instructions and bytes copied, not just atomic handoff cost. +### 6.1 Measurement result (2026-08-11) + +The full matrix is recorded in +[`block-buf-measurements.md`](block-buf-measurements.md). It includes exact +code-size rows for all eight gated targets and accepted/rejected instruction +counts from the pinned QEMU 10.0.11 reference environment. + +On the reference Cortex-M3, accepted completion plus publication ranges from +159 instructions for 2-byte samples at `N = 8` to 8,658 instructions for +16-byte samples at `N = 128`. Rejection is within 5–31 instructions of the +accepted path because the complete rejected block is preserved and returned to +the caller. Logical payload traffic ranges from 48 to 4,112 bytes per path. + +This supplies the missing measurements but does not close the foundation +decision: no named ISR/task instruction budget or RAM envelope exists in the +record. Decision **P** therefore remains open, and decision **S** must continue +to wait. The next maintainer input is the budget for the supported shapes; only +then can the branch choose Copy composition or authorize the representative +BlockBuf-over-SlotPool integration. + ## 7. Deferred choices and decision evidence | Choice | Safe default | Evidence that changes it | @@ -236,8 +256,10 @@ no block-specific evidence; Loom remains required for the selected transport. 1. Maintainer confirms the type-identity recommendation and treats D3 as a scheduling decision rather than separate sample/block primitive designs. -2. Run the section 6 matrix against named budgets; choose Copy composition or - SlotPool/grants from the result. +2. Compare the completed section 6 matrix against named budgets; choose Copy + composition or SlotPool/grants from the result. The measurement is complete; + naming the instruction/time and RAM budgets remains a maintainer decision. 3. Run standard CI and Miri; run Loom for the selected transport. -4. If Copy wins, extend code-size/cycle probes with accepted block shapes. If - SlotPool wins, specify grant teardown and ownership proof before PROPOSED. +4. If Copy wins, decide which completed matrix rows become release baselines. + If SlotPool wins, specify grant teardown and ownership proof before + PROPOSED. diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 4665d38..10e6cae 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -28,6 +28,7 @@ # Usage: # ./scripts/codesize.sh # baseline API, upstream targets # ./scripts/codesize.sh split # also measure try_split, where present +# ./scripts/codesize.sh block-matrix # Block completion + publication shapes # XTENSA=1 ./scripts/codesize.sh # add ESP32 rows (needs esp-rs fork) # # Reading the output: each number is the byte size of one function, so it @@ -46,9 +47,14 @@ export CARGO_INCREMENTAL PROBE_FEATURES="" BLESS=0 +BLOCK_MATRIX=0 for arg in "$@"; do case "$arg" in split) PROBE_FEATURES="split" ;; + block-matrix) + PROBE_FEATURES="block-matrix" + BLOCK_MATRIX=1 + ;; --bless) BLESS=1 ;; # Not 2: that is reserved for "could not run", which ci.sh maps to SKIP. *) printf 'unknown argument: %s @@ -114,11 +120,19 @@ fn_size() { RESULTS="$(mktemp)" trap 'rm -f "$RESULTS"' EXIT -printf '%-30s %10s %8s %8s %6s\n' TARGET two_calls split bss data -printf '%-30s %10s %8s %8s %6s\n' '------------------------------' '---------' '-----' '---' '----' +if [ "$BLOCK_MATRIX" = "1" ]; then + printf '%-30s %-10s %8s %8s %10s %10s\n' \ + TARGET SHAPE code_B block_B accepted_B rejected_B + printf '%-30s %-10s %8s %8s %10s %10s\n' \ + '------------------------------' '----------' '------' '-------' '----------' '----------' +else + printf '%-30s %10s %8s %8s %6s\n' TARGET two_calls split bss data + printf '%-30s %10s %8s %8s %6s\n' '------------------------------' '---------' '-----' '---' '----' +fi skipped=0 failed=0 +matrix_missing=0 for entry in $TARGETS; do target="$(printf '%s' "$entry" | cut -d'|' -f1)" @@ -160,6 +174,27 @@ for entry in $TARGETS; do fi ar="scripts/codesize/target/$target/release/libph_eventing_codesize.a" + + if [ "$BLOCK_MATRIX" = "1" ]; then + for shape in w2_n8 w2_n32 w2_n128 w8_n8 w8_n32 w8_n128 w16_n8 w16_n32 w16_n128; do + width="$(printf '%s' "$shape" | sed 's/^w\([0-9]*\)_.*/\1/')" + count="$(printf '%s' "$shape" | sed 's/.*_n//')" + code="$(fn_size "$ar" "block_${shape}_publish")" + block_bytes=$((width * count + 8)) + accepted_bytes=$((block_bytes * 2)) + # A rejected push preserves and returns the complete block, so it + # crosses a caller-visible result boundary instead of a slot-write + # boundary. Both paths therefore carry two block-sized moves after + # the final sample: builder completion plus publish-or-return. + rejected_bytes=$((block_bytes * 2)) + [ -z "$code" ] && matrix_missing=$((matrix_missing + 1)) + printf '%-30s %-10s %8s %8s %10s %10s\n' \ + "$target" "$shape" "${code:--}" "$block_bytes" \ + "$accepted_bytes" "$rejected_bytes" + done + continue + fi + two="$(fn_size "$ar" bringup_two_calls)" spl="$(fn_size "$ar" bringup_split)" bss="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.bss\..*3BUF/ { print $2; exit }')" @@ -182,6 +217,22 @@ if [ "$skipped" -gt 0 ]; then printf 'per-architecture differences this script exists to find.\n\n' fi [ "$failed" -gt 0 ] && printf '%s target(s) failed to build.\n\n' "$failed" + +if [ "$BLOCK_MATRIX" = "1" ]; then + if [ "$matrix_missing" -gt 0 ]; then + printf '%s block-matrix function(s) had no code-size measurement.\n' \ + "$matrix_missing" >&2 + exit 1 + fi + [ "$failed" -gt 0 ] && exit 1 + [ "$skipped" -gt 0 ] && exit 2 + printf 'block_B is size_of::>(). accepted_B counts builder\n' + printf 'completion plus publication; rejected_B counts completion plus\n' + printf 'returning the complete rejected block to the caller.\n' + printf 'These are logical payload-traffic bounds; code_B is emitted flash.\n' + printf 'Run scripts/cycles.sh for accepted/rejected instruction paths.\n' + exit 0 +fi # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Baseline gate diff --git a/scripts/codesize/Cargo.toml b/scripts/codesize/Cargo.toml index 2d9cd93..407227a 100644 --- a/scripts/codesize/Cargo.toml +++ b/scripts/codesize/Cargo.toml @@ -27,6 +27,8 @@ ph-eventing = { path = "../.." } cm0 = ["ph-eventing/portable-atomic-unsafe-assume-single-core"] # Enable on a branch that has `try_split`; absent on release branches. split = [] +# Enable the BlockBuilder completion + EventBuf publication matrix. +block-matrix = [] [profile.release] # Match what an embedded consumer would realistically ship. diff --git a/scripts/codesize/src/lib.rs b/scripts/codesize/src/lib.rs index 36f38d3..2806b9a 100644 --- a/scripts/codesize/src/lib.rs +++ b/scripts/codesize/src/lib.rs @@ -7,6 +7,8 @@ use core::panic::PanicInfo; use ph_eventing::EventBuf; +#[cfg(feature = "block-matrix")] +use ph_eventing::{Block, BlockBuilder, event_buf::Producer}; #[panic_handler] fn panic(_: &PanicInfo) -> ! { @@ -56,3 +58,45 @@ pub extern "C" fn bringup_split() -> i32 { None => -4, } } + +// Each function below starts from a builder that the caller has already +// filled with N - 1 samples. Its section therefore attributes only the final +// completion and EventBuf publication shape, not the O(N) acquisition loop. +// Accepted and rejected publication share this code; the cycle probe measures +// both runtime paths separately. +#[cfg(feature = "block-matrix")] +macro_rules! block_publish_probe { + ($name:ident, $sample:ty, $n:literal) => { + #[unsafe(no_mangle)] + pub fn $name( + fill: &mut BlockBuilder<$sample, $n>, + tx: &Producer<'_, Block<$sample, $n>, 1>, + sequence: u32, + sample: $sample, + ) -> bool { + match fill.push(sequence, sample) { + Ok(Some(block)) => tx.push(block).is_ok(), + Ok(None) | Err(_) => false, + } + } + }; +} + +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w2_n8_publish, u16, 8); +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w2_n32_publish, u16, 32); +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w2_n128_publish, u16, 128); +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w8_n8_publish, u64, 8); +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w8_n32_publish, u64, 32); +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w8_n128_publish, u64, 128); +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w16_n8_publish, [u64; 2], 8); +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w16_n32_publish, [u64; 2], 32); +#[cfg(feature = "block-matrix")] +block_publish_probe!(block_w16_n128_publish, [u64; 2], 128); diff --git a/scripts/cycles.sh b/scripts/cycles.sh index d46c08c..140af87 100755 --- a/scripts/cycles.sh +++ b/scripts/cycles.sh @@ -31,13 +31,14 @@ # target. Unlike the rest of the tooling this is NOT satisfied by # rust-toolchain.toml alone -- QEMU is a system package the toolchain file # cannot pin, and the counts are stable per QEMU build, not across builds: -# two of the eighteen regions were observed to differ by one instruction +# two of the original regions were observed to differ by one instruction # between a 10.0 and a 10.2 build. scripts/verify/Dockerfile pins the # environment the documented numbers were measured in; run this script inside # it via ./scripts/verify.sh cycles when comparing against those numbers. # # Usage: # ./scripts/cycles.sh # local qemu-system-arm +# ./scripts/cycles.sh block-matrix # ./scripts/verify.sh cycles # same, inside the reference image set -u @@ -47,6 +48,14 @@ cd "$(dirname "$0")/.." || exit 1 CARGO_INCREMENTAL=0 export CARGO_INCREMENTAL +PROBE_FEATURES="" +for arg in "$@"; do + case "$arg" in + block-matrix) PROBE_FEATURES="block-matrix" ;; + *) printf 'unknown argument: %s\n' "$arg" >&2; exit 64 ;; + esac +done + PROBE_DIR="scripts/cycles" ELF="$PROBE_DIR/target/thumbv7m-none-eabi/release/ph-eventing-cycles" LOG="$PROBE_DIR/target/qemu-exec.log" @@ -89,9 +98,14 @@ printf '==> building probe\n' # from the working directory, not from --manifest-path. Building from the repo # root silently picks up the root config instead, targets the host, and fails to # link against libc. -if ! ( cd "$PROBE_DIR" && cargo build --release >/dev/null 2>&1 ); then +if [ -n "$PROBE_FEATURES" ]; then + build_probe() { ( cd "$PROBE_DIR" && cargo build --release --features "$PROBE_FEATURES" ); } +else + build_probe() { ( cd "$PROBE_DIR" && cargo build --release ); } +fi +if ! build_probe >/dev/null 2>&1; then printf 'error: probe failed to build\n' >&2 - ( cd "$PROBE_DIR" && cargo build --release 2>&1 | tail -20 >&2 ) + build_probe 2>&1 | tail -20 >&2 exit 1 fi @@ -185,7 +199,21 @@ report="$(awk ' if (open != "m_overhead") { pretty = substr(open, 3) gsub(/_/, " ", pretty) - printf " %-26s %5d\n", pretty, n + if (substr(open, 3, 2) == "bp") { + split(open, part, "_") + width = substr(part[3], 2) + 0 + samples = substr(part[4], 2) + 0 + block_bytes = width * samples + 8 + # Accepted publication writes the complete block to + # a slot. Rejection returns that same complete block + # to the caller. Both follow builder completion, so + # both paths cross two block-sized value boundaries. + logical_bytes = block_bytes * 2 + printf " %-30s %7d %8d %8d\n", \ + pretty, n, block_bytes, logical_bytes + } else { + printf " %-26s %5d\n", pretty, n + } } } open = "" @@ -196,6 +224,11 @@ report="$(awk ' if (group == "eb") printf "\nEventBuf (backpressure SPSC)\n" else if (group == "sr") printf "\nSeqRing (overwrite SPSC)\n" else if (group == "rb") printf "\nRingBuf (single owner)\n" + else if (group == "bp") { + printf "\nBlock completion + EventBuf publication\n" + printf " %-30s %7s %8s %8s\n", \ + "SHAPE", "instr", "block_B", "logical_B" + } } } next @@ -227,6 +260,11 @@ printf 'Deterministic per environment: -icount shift=0 pins one instruction to\n printf 'one tick, so the same ELF under the same QEMU build yields the same\n' printf 'counts on any host. Different QEMU builds can shift region boundaries\n' printf 'by an instruction -- compare inside the reference image (verify.sh).\n' +if printf '%s\n' "$report" | grep -q '^ bp '; then + printf 'For block rows, logical_B is conservative payload traffic. Both paths\n' + printf 'count builder completion plus one complete-block transfer: slot\n' + printf 'publication when accepted, or return to the caller when rejected.\n' +fi rm -f "$SYMS" rm -f "$LOG" diff --git a/scripts/cycles/Cargo.toml b/scripts/cycles/Cargo.toml index 56e76cb..25f5320 100644 --- a/scripts/cycles/Cargo.toml +++ b/scripts/cycles/Cargo.toml @@ -15,6 +15,11 @@ cortex-m = "0.7" cortex-m-rt = "0.7" cortex-m-semihosting = "0.5" +[features] +# Isolate the large-payload completion/publication matrix from the standard +# hot-path probe so adding matrix rows cannot perturb its LTO decisions. +block-matrix = [] + [profile.release] opt-level = "z" lto = true diff --git a/scripts/cycles/src/main.rs b/scripts/cycles/src/main.rs index 72c6b31..1bf58e5 100644 --- a/scripts/cycles/src/main.rs +++ b/scripts/cycles/src/main.rs @@ -26,7 +26,11 @@ use core::hint::black_box; use cortex_m_rt::entry; use cortex_m_semihosting::debug; -use ph_eventing::{EventBuf, RingBuf, SeqRing}; +use ph_eventing::EventBuf; +#[cfg(feature = "block-matrix")] +use ph_eventing::{Block, BlockBuilder}; +#[cfg(not(feature = "block-matrix"))] +use ph_eventing::{RingBuf, SeqRing}; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { @@ -59,6 +63,7 @@ macro_rules! markers { }; } +#[cfg(not(feature = "block-matrix"))] markers! { 0 => m_end, 1 => m_overhead, @@ -85,6 +90,32 @@ markers! { 19 => m_sr_poll_lagged_far, } +#[cfg(feature = "block-matrix")] +markers! { + 0 => m_end, + 1 => m_overhead, + // BlockBuilder completion + EventBuf publication. Width is bytes/sample. + 20 => m_bp_w2_n8_accepted, + 21 => m_bp_w2_n8_rejected, + 22 => m_bp_w2_n32_accepted, + 23 => m_bp_w2_n32_rejected, + 24 => m_bp_w2_n128_accepted, + 25 => m_bp_w2_n128_rejected, + 26 => m_bp_w8_n8_accepted, + 27 => m_bp_w8_n8_rejected, + 28 => m_bp_w8_n32_accepted, + 29 => m_bp_w8_n32_rejected, + 30 => m_bp_w8_n128_accepted, + 31 => m_bp_w8_n128_rejected, + 32 => m_bp_w16_n8_accepted, + 33 => m_bp_w16_n8_rejected, + 34 => m_bp_w16_n32_accepted, + 35 => m_bp_w16_n32_rejected, + 36 => m_bp_w16_n128_accepted, + 37 => m_bp_w16_n128_rejected, +} + +#[cfg(not(feature = "block-matrix"))] fn event_buf_costs() { let buf = EventBuf::::new(); let tx = buf.try_producer().expect("producer"); @@ -125,6 +156,7 @@ fn event_buf_costs() { m_end(); } +#[cfg(not(feature = "block-matrix"))] fn seq_ring_costs() { let ring = SeqRing::::new(); let tx = ring.try_producer().expect("producer"); @@ -173,6 +205,7 @@ fn seq_ring_costs() { m_end(); } +#[cfg(not(feature = "block-matrix"))] fn ring_buf_costs() { let mut ring = RingBuf::::new(); @@ -197,6 +230,86 @@ fn ring_buf_costs() { m_end(); } +#[cfg(feature = "block-matrix")] +macro_rules! measure_block_shape { + ($sample:ty, $n:literal, $value:expr, $accepted:ident, $rejected:ident) => {{ + let mut accepted_fill = BlockBuilder::<$sample, $n>::new(); + for sequence in 1..$n { + let _ = accepted_fill.push(sequence as u32, black_box($value)); + } + + let queue = EventBuf::, 1>::new(); + let tx = queue.try_producer().expect("producer"); + + $accepted(); + let block = accepted_fill + .push($n as u32, black_box($value)) + .expect("contiguous") + .expect("complete"); + let _ = black_box(tx.push(block)); + m_end(); + + let mut rejected_fill = BlockBuilder::<$sample, $n>::new(); + for sequence in 1..$n { + let _ = rejected_fill.push(sequence as u32, black_box($value)); + } + + $rejected(); + let block = rejected_fill + .push($n as u32, black_box($value)) + .expect("contiguous") + .expect("complete"); + let _ = black_box(tx.push(block)); + m_end(); + }}; +} + +#[cfg(feature = "block-matrix")] +fn block_publication_costs() { + measure_block_shape!(u16, 8, 1_u16, m_bp_w2_n8_accepted, m_bp_w2_n8_rejected); + measure_block_shape!(u16, 32, 1_u16, m_bp_w2_n32_accepted, m_bp_w2_n32_rejected); + measure_block_shape!( + u16, + 128, + 1_u16, + m_bp_w2_n128_accepted, + m_bp_w2_n128_rejected + ); + measure_block_shape!(u64, 8, 1_u64, m_bp_w8_n8_accepted, m_bp_w8_n8_rejected); + measure_block_shape!(u64, 32, 1_u64, m_bp_w8_n32_accepted, m_bp_w8_n32_rejected); + measure_block_shape!( + u64, + 128, + 1_u64, + m_bp_w8_n128_accepted, + m_bp_w8_n128_rejected + ); + measure_block_shape!( + [u64; 2], + 8, + [1_u64; 2], + m_bp_w16_n8_accepted, + m_bp_w16_n8_rejected + ); + measure_block_shape!( + [u64; 2], + 32, + [1_u64; 2], + m_bp_w16_n32_accepted, + m_bp_w16_n32_rejected + ); + measure_block_shape!( + [u64; 2], + 128, + [1_u64; 2], + m_bp_w16_n128_accepted, + m_bp_w16_n128_rejected + ); +} + +// Semihosting exit terminates QEMU, but its signature is not `!`; the fallback +// must remain side-effect-free so it cannot contaminate any measured region. +#[allow(clippy::empty_loop)] #[entry] fn main() -> ! { // Two adjacent markers: the cost of the markers themselves, subtracted @@ -204,9 +317,14 @@ fn main() -> ! { m_overhead(); m_end(); - event_buf_costs(); - seq_ring_costs(); - ring_buf_costs(); + #[cfg(not(feature = "block-matrix"))] + { + event_buf_costs(); + seq_ring_costs(); + ring_buf_costs(); + } + #[cfg(feature = "block-matrix")] + block_publication_costs(); debug::exit(debug::EXIT_SUCCESS); loop {} diff --git a/scripts/verify.sh b/scripts/verify.sh index e27eb66..61fa2ff 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -18,6 +18,7 @@ # ./scripts/verify.sh miri # ./scripts/verify.sh loom # ./scripts/verify.sh cycles +# ./scripts/verify.sh cycles block-matrix # ./scripts/verify.sh shell # interactive shell in the image # # Requires Docker. Everything else is inside the image. @@ -73,7 +74,10 @@ case "${1:-all}" in ci) run sh scripts/ci.sh ;; miri) run sh scripts/miri.sh ;; loom) run sh scripts/loom.sh ;; - cycles) run sh scripts/cycles.sh ;; + cycles) + shift + run sh scripts/cycles.sh "$@" + ;; all) # Version stamp first, so any pasted output carries its environment. run sh -c 'rustc --version; rustc "+$MIRI_TOOLCHAIN" --version; qemu-system-arm --version | head -1' From 6f46da35b62ade2edcb43a9a475b9a828af4d283 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 12:42:45 -0400 Subject: [PATCH 09/87] Measure and optimize LatestBuf costs --- AGENTS.md | 43 ++++++- CHANGELOG.md | 9 +- README.md | 6 +- docs/0.3.0-candidates.md | 34 +++--- docs/proposals/latest-buf-evaluation.md | 39 ++++--- docs/proposals/latest-buf-measurements.md | 133 ++++++++++++++++++++++ scripts/codesize.sh | 89 ++++++++++++++- scripts/codesize/Cargo.toml | 2 + scripts/codesize/src/lib.rs | 73 ++++++++++++ scripts/cycles.sh | 19 +++- scripts/cycles/Cargo.toml | 5 + scripts/cycles/src/main.rs | 128 ++++++++++++++++++++- scripts/verify.sh | 6 +- src/latest_buf.rs | 79 ++++++++++--- src/loom_tests.rs | 39 +++++++ 15 files changed, 644 insertions(+), 60 deletions(-) create mode 100644 docs/proposals/latest-buf-measurements.md diff --git a/AGENTS.md b/AGENTS.md index 9a48814..98913f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,10 +26,11 @@ prose when it disagrees. **ph-eventing** provides stack-allocated ring buffers for no-std embedded targets. -It ships three primitives: +It ships four primitives: - **`RingBuf`** — a single-owner ring buffer (no atomics, `&mut` access). Ideal for local event logs, sample windows, and single-context collection. - **`SeqRing`** — a lock-free SPSC ring that **overwrites** old entries. Designed for high-rate telemetry where a fast producer and a potentially slower consumer run on different contexts. - **`EventBuf`** — a lock-free SPSC ring with **backpressure**. `push` returns `Err(val)` when full, so the producer always knows when delivery fails. +- **`LatestBuf`** — a freshness-first SPSC snapshot channel that retains one newest unread publication and reports replacement/skipped evidence. **Key characteristics:** - Zero runtime dependencies (`portable-atomic` is optional; `loom` is a dev-dependency gated on `--cfg loom`). Verify with `cargo tree` — it must print the crate alone. @@ -38,6 +39,7 @@ It ships three primitives: - `SeqRing`: producer never blocks; consumer drops old events when lagging - `SeqRing`: sequence-based tracking; a raced read is detected and discarded rather than returned - `EventBuf`: producer gets explicit backpressure; no data silently lost +- `LatestBuf`: producer never waits; consumer receives only the newest complete value with observable loss - Common `Sink`/`Source`/`Link` traits unify producers and consumers across buffer types - `forward()` utility bridges any `Source` into any `Sink` @@ -138,6 +140,7 @@ ph-eventing/ ├── lib.rs # Crate root, public exports, doctests ├── macros.rs # static_spsc! -- declarative static bring-up ├── event_buf.rs # Bounded SPSC event buffer with backpressure + ├── latest_buf.rs # Three-slot freshness-first SPSC snapshot channel ├── ring.rs # Single-owner stack-allocated ring buffer ├── seq_ring.rs # Lock-free SPSC overwrite ring with sequence tracking ├── sync.rs # Atomic/cell shim — swaps in Loom's primitives under --cfg loom @@ -157,6 +160,7 @@ ph-eventing/ | `seq_ring::Consumer<'a, T, N>` | SeqRing read handle with multiple polling modes | | `PollStats` | Statistics returned from SeqRing poll operations | | `EventBuf` | Bounded SPSC ring with backpressure (push returns `Result`) | +| `LatestBuf` | Three-slot SPSC snapshot channel with observable replacement and gaps | | `event_buf::Producer<'a, T, N>` | EventBuf write handle; `push(T) -> Result<(), T>` | | `event_buf::Consumer<'a, T, N>` | EventBuf read handle; `pop() -> Option`, `peek()`, `drain()` | | `Sink` | Trait — accept events via `try_push(&mut self, T) -> Result<(), Error>` | @@ -184,6 +188,26 @@ The `SeqRing` implementation uses careful atomic ordering for thread safety: - Producer and consumer never touch the same slot, so the slots themselves are race-free; only the cursors are shared. - `len()` is the one observer reading both cursors. It brackets its `head` load between two `tail` samples (Acquire load, then `fence(Acquire)`), retries a bounded number of times, then falls back to a clamped estimate so it is always wait-free. +### Memory Ordering Strategy (LatestBuf) + +`LatestBuf` transfers three exclusive slot roles rather than validating a racy +copy after the fact: + +- producer and consumer each own one private slot; the encoded exchange atomic + owns the third; +- both endpoint swaps are `AcqRel`: Release relinquishes the offered slot and + Acquire makes the claimed slot's payload/ownership visible; +- an empty consumer poll first Acquire-loads the ready bit. A false load can + linearize before a concurrent publication and returns without touching any + slot; a true load is only a hint and the `AcqRel` swap remains the ownership + transfer; +- handle drop Release-clears the role's taken flag, and the next successful + `AcqRel` claim makes channel-resident continuation state visible; and +- producer/consumer slot indices are XOR-encoded only in their private role + cells so the initial representation is all zero and static channels land in + `.bss`. Decode before slot access; encode only when saving a newly claimed + role. Encoded role values must never be written to the shared exchange. + #### Why `EventBuf::len` cannot exceed capacity on a successful snapshot The tempting counterexample is a capacity-2 queue where the consumer frees one @@ -628,6 +652,7 @@ Re-bless deliberately, never reflexively — the diff is the review: ```bash ./scripts/codesize.sh # baseline, 8 upstream targets ./scripts/codesize.sh split # include try_split, on branches that have it +./scripts/codesize.sh latest-matrix # LatestBuf operations and payload layouts XTENSA=1 ./scripts/codesize.sh # add the 3 ESP32 rows ``` @@ -674,6 +699,19 @@ Cross-environment diffs of ±1 are noise; compare inside the image. | `RingBuf::push` | 20 | **20** (overwriting) | | | `RingBuf::get` / `latest` | 22 / 16 | | | +LatestBuf uses a separate payload matrix (`./scripts/verify.sh cycles +latest-matrix`) so the larger stack shapes do not disturb the standing probe: + +| payload | publish first/replacement | take pending/empty | +|---|---:|---:| +| `u32` | 41 / 40 | 46 / 15 | +| 16 bytes | 70 / 70 | 51 / 17 | +| 128 bytes | 482 / 483 | 185 / 18 | + +Role drop/reacquisition costs 5/15 instructions for the producer and 5/14 for +the consumer. The target/code-size/RAM and A.1 comparison record is +`docs/proposals/latest-buf-measurements.md`. + Two results carry the argument: 1. **Every `push` is constant.** Empty vs loaded differs by at most one @@ -948,6 +986,9 @@ The project supports these targets (defined in `rust-toolchain.toml`): - `SeqRing`: Producer and Consumer handles are `Send + !Sync` - `SeqRing`: the seqlock data race is **known and documented**, not an oversight. Do not "fix" it by weakening the sequence guards, and do not silence it by disabling Miri's race detector globally — the split-pass structure in `scripts/miri.sh` exists so everything else stays fully checked - `EventBuf`: race-free by construction — producer and consumer never touch the same slot. If a change makes them share one, that is a design break, not a tuning decision +- `LatestBuf`: the producer, consumer, and exchange state always name three distinct decoded slots; only an `AcqRel` exchange transfers ownership +- `LatestBuf`: a false ready-bit load performs no role-state or slot access; a true load never replaces the ownership-transferring swap +- `LatestBuf`: private role slot indices are XOR-encoded at rest so `new()` remains an all-zero `.bss` image; decode before indexing and never put an encoded index in `exchange` ### Common Tasks diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5b66d..60f582f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,19 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Added - Evaluable `LatestBuf` prototype: a three-slot, freshness-first SPSC - snapshot channel with bounded single-swap publish/take operations, + snapshot channel with bounded single-swap publication and at-most-one-swap + take operations, replacement and skipped-generation evidence, and channel-resident endpoint state so handle reacquisition continues rather than restarting. Deferred assumptions are exact accounting within one non-zero `u32` wrap (approximate beyond it), no `Source` implementation, and generic payloads supporting samples or complete blocks. +- LatestBuf target/payload measurement mode for all 11 embedded targets plus + pinned QEMU instruction regions. The measured A.1 Acquire-load fast path + removes the atomic RMW from empty polls for +6-16 bytes of `take_latest` + flash, including +8 bytes on ESP32-S2. Private role indices now have an + all-zero encoding, moving const-initialized channels from `.data` to `.bss` + and removing 48-420 bytes of flash/startup copy in the measured payloads. ### Documentation - `RingBuf::new`'s docs no longer mention a `pop` method the type does not have — `pop` was diff --git a/README.md b/README.md index f96a9ba..5ce300c 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,11 @@ replaced. Taking returns the newest complete value with generation and skipped counts. Exact skipped counts are guaranteed within one non-zero `u32` wrap; beyond a full cycle the wrapped `u32` count is only an approximation. The consumer intentionally implements `LatestSource`, not `Source`, so gap evidence -is not silently discarded. `T` may be one sample or a complete block. +is not silently discarded. `T` may be one sample or a complete block. Empty +polls use an Acquire load rather than an atomic RMW; pending polls transfer +ownership with one `AcqRel` swap. The all-zero initial representation keeps a +const-initialized channel in `.bss` with no payload-proportional flash or +startup-copy cost. ```rust use ph_eventing::LatestBuf; diff --git a/docs/0.3.0-candidates.md b/docs/0.3.0-candidates.md index f029cb6..9a6e8d4 100644 --- a/docs/0.3.0-candidates.md +++ b/docs/0.3.0-candidates.md @@ -75,7 +75,7 @@ publish; listed only so the 0.3.0 notes account for it. --- -## 3. `LatestBuf` — freshness-first SPSC snapshot channel — PROPOSED +## 3. `LatestBuf` — freshness-first SPSC snapshot channel — PROTOTYPE / EVALUATING Design document received 2026-08-11 and captured in full at [`docs/proposals/latest-buf.md`](proposals/latest-buf.md). This section is @@ -85,8 +85,9 @@ the cycle-tracking summary; the proposal is the reference. only the latest complete publication (or latest complete block). At every instant one slot is producer-owned, one consumer-owned, one held by an atomic exchange state; ownership transfers *before* access, so producer and consumer -never touch the same bytes. Both operations are a single atomic `swap` — no -CAS retry loop. Overload appears as observable replacement +never touch the same bytes. Publication and pending take each use one atomic +`swap`; an empty take returns after one Acquire load. There is no CAS retry +loop. Overload appears as observable replacement (`PublishReport::replaced_unread`), consumer-side loss as an observable gap (`LatestItem::skipped`). @@ -136,21 +137,18 @@ Loom → Miri → ordering mutation → codesize + cycles → sample-vs-block decision → traits only with the primitive that needs them → harness integration. -**Cycle state (2026-08-11):** step 1 is drafted — the semantic contract -lives at [`proposals/latest-buf-contract.md`](proposals/latest-buf-contract.md) -with clause IDs, an evidence map, and three decision points (D1 wrap policy, -D2 `Source` policy, D3 sample-vs-block) **deferred open by maintainer -decision (2026-08-11)** — to be closed before implementation begins, not -before. Three -review caveats are recorded in the proposal's Appendix A for validation at -implementation time: the empty-poll RMW cost (A.1), the `generation_gap` -off-by-one and wrap arithmetic (A.2), and the handle-state continuation gap -behind contract clause H4 (A.3, found by review on the capture PR — a -blocker to resolve before reacquisition can be implemented safely). -**Development is paused here by maintainer decision** — steps 2 onward -(prototype and beyond) start when the maintainer re-opens them. Traits `ObservedSource` and the payload metadata traits are -explicitly gated on a *second* implementation proving the vocabulary honest — -they are not part of the initial acceptance question. +**Cycle state (2026-08-11):** the three-slot channel-state prototype has passed +unit/stress, detector-on Miri, focused Loom, ordering-mutation, embedded +compile, and target/payload measurement stages. The live comparison record is +[`proposals/latest-buf-evaluation.md`](proposals/latest-buf-evaluation.md) and +the 11-target code-size, pinned-cycle, A.1, and RAM results are in +[`proposals/latest-buf-measurements.md`](proposals/latest-buf-measurements.md). +A.1 closes in favour of an Acquire-load empty fast path; A.2 is pinned by the +wrap tests. D1-D3 and A.3 remain prototype defaults pending maintainer closure. +The next development item is the joint complete-block composition measurement +with the BlockBuf lane, not more soundness design. Traits `ObservedSource` and +the payload metadata traits remain gated on a second implementation proving +the vocabulary honest; they are not part of the initial acceptance question. **Composition evidence is deliberately deferred.** Hardware-in-the-loop evaluation of the full producer→transport→consumer composition is the last diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index e270e12..8d358c0 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -3,8 +3,9 @@ - **Purpose:** implementation-independent comparison record for issue #27. - **Inputs:** [`latest-buf.md`](latest-buf.md), [`latest-buf-contract.md`](latest-buf-contract.md), and the BlockBuf candidate. -- **Status:** evaluation scaffold; it does not select an implementation before - the evidence below exists. +- **Status:** live evaluation record. Soundness, target/payload cost, A.1, and + channel-state layout evidence now exist; D3 and maintainer closure remain. +- **Measurements:** [`latest-buf-measurements.md`](latest-buf-measurements.md). ## 1. Decisions sufficient for an evaluable prototype @@ -17,6 +18,7 @@ choices have received a permanent API decision. | D2, `Source` | No `Source` implementation. Provide `LatestSource` so replacement evidence remains structural. | A design that preserves loss evidence through generic `Source`/`forward`; documentation alone is insufficient. | | D3, sample or block | Implement generic `LatestBuf` first. A complete block is a `T`; the BlockBuf candidate demonstrates `LatestBuf>` for latest and `EventBuf, Q>` for queued delivery. | A separate block transport must enforce a guarantee composition cannot, such as direct-to-granted-slot filling with a measured copy/RAM win. | | A.3, role continuation | Compare channel-resident role state with handle-resident state persisted on `Drop`. Keep H2/H4 in both candidates. | Narrow H2 only if both candidates fail the evidence bar; reacquisition must not silently restart. | +| A.1, empty poll | Acquire-load the ready bit and return when false; a true load still proceeds through the `AcqRel` ownership swap. | Revert to the unconditional swap only if a target regresses beyond the code-size tolerance or the Loom equivalence model fails. Neither occurred in the recorded matrix. | The BlockBuf branch also makes `DropBlockBuf` a caller policy over the block returned by `EventBuf::push`, not a third synchronization primitive. A named @@ -91,13 +93,14 @@ gap accounting resumes, and tracked cells report no overlapping ownership. Model producer and consumer roles separately so a failure identifies the broken handoff. -### L4 — optional empty-poll fast path +### L4 — empty-poll fast path -If A.1 is implemented, interleave its ready-bit load with publication before, -during, and after the load. A false load returns without touching a slot and -can linearize before a concurrent publication. A true load must still use the -normal `AcqRel` exchange; a pre-load is not an ownership transfer. Compare -this model with the unconditional-swap reference before measuring it. +Interleave its ready-bit load with publication before, during, and after the +load. A false load returns without touching a slot and can linearize before a +concurrent publication. A true load must still use the normal `AcqRel` +exchange; a pre-load is not an ownership transfer. The implemented model also +asserts that a publication concurrent with a false load remains pending for +the next poll. ## 4. Ordering mutation matrix @@ -161,7 +164,8 @@ Measure these regions separately: - channel-state and persist-on-drop steady-state operations; - handle drop and reacquisition for both candidates; - `u32`, 16-byte, and representative block payloads; and -- unconditional empty swap versus A.1, if retained. +- unconditional empty swap versus the A.1 fast path (completed in the linked + measurement record). Publication and take may scale with `size_of::()`, but not with lag, occupancy, or history. Report handle size, channel size, and three-slot payload @@ -169,6 +173,15 @@ RAM explicitly. Run code size across the complete target matrix, with thumbv6m and ESP32-S2 treated as the kill-criterion rows because every RMW may become an interrupt-disabled critical section. +The completed record is +[`latest-buf-measurements.md`](latest-buf-measurements.md): all eleven targets, +three payload shapes, pinned Cortex-M3 instruction regions, both A.1 variants, +role drop/reacquisition, and target-object channel layout. A.1 selects the +Acquire-load fast path. Private role indices are XOR-encoded so the all-zero +initial channel lands in `.bss`; the small bounded operation cost removes +48-420 bytes of payload-proportional flash and startup copy in the measured +shapes. + ## 7. Acceptance record An implementation is ready to compare only when its PR can fill every cell: @@ -176,14 +189,14 @@ An implementation is ready to compare only when its PR can fill every cell: | Evidence | Channel state | Persist on drop | |---|---|---| | Unit semantics and wrap seam | pass: 9 focused tests | pass: 8 focused tests on comparison branch | -| L1-L4 Loom models | partial: payload ownership plus producer and consumer L3 pass; L2 and optional L4 remain | partial: six models pass, but its joined L3 does not isolate the taken-flag handoff | +| L1-L4 Loom models | pass: five focused models cover payload/slot reuse, both cross-context roles, and A.1 publication interleavings | partial: six models pass, but its joined L3 does not isolate the taken-flag handoff | | All applicable ordering mutations fail | pass: all six exchange and four role-handoff weakenings detected | partial: Drop `Release -> Relaxed` and claim `AcqRel -> Release` fail Loom; exchange matrix remains | | Miri race detector on | pass: 9 focused tests, including threaded patterned payload | pass: 8 focused tests on comparison branch | | Native patterned-payload stress | pass | not implemented | | Embedded compile matrix | pass: thumbv6m portable-atomic, thumbv7em, riscv32imac | pass: same library paths, candidate-specific rerun pending | -| Full code-size matrix | pending | pending | -| Cycle regions above | pending | pending | -| Handle/channel/RAM sizes | pending | pending | +| Full code-size matrix | pass: 11 targets × 3 payloads, including thumbv6m and ESP32-S2 | pending | +| Cycle regions above | pass: first/replacement publish, empty/pending take, and both role handoffs in pinned QEMU | pending | +| Handle/channel/RAM sizes | pass: 4-byte handles on shipped targets; 48/84/420-byte channels in `.bss` | pending | Correctness removes a candidate; measurements select between candidates that remain. Ergonomics is evaluated only after those results. diff --git a/docs/proposals/latest-buf-measurements.md b/docs/proposals/latest-buf-measurements.md new file mode 100644 index 0000000..c3987ea --- /dev/null +++ b/docs/proposals/latest-buf-measurements.md @@ -0,0 +1,133 @@ +# LatestBuf target, payload, and empty-poll measurements + +- **Measured:** 2026-08-11 +- **Source:** `candidate/latest-buf` from `72cb22f`, with the isolated + measurement harness and optimizations recorded with this document. +- **Compiler:** `rustc 1.92.0 (ded5c06cf 2025-12-08)`. +- **Cycle reference:** `QEMU emulator version 10.0.11`, from the repository's + `ph-eventing-verify` image. +- **Payloads:** `u32`, 16 bytes (`[u32; 4]`), and a representative 128-byte + complete payload (`[u32; 32]`). + +This is the cost evidence requested by issue #27 and +[latest-buf-evaluation.md](latest-buf-evaluation.md) section 6. The 128-byte +array measures the transport's complete-payload scaling without importing the +BlockBuf candidate. It is not the joint `Block` composition campaign, +which remains separately coupled to issue #28. + +Reproduce the final implementation with: + +```sh +XTENSA=1 ./scripts/codesize.sh latest-matrix +./scripts/verify.sh cycles latest-matrix +``` + +## Reference instruction counts + +| Payload | Publish first | Publish replacement | Take pending | Take empty | +|---|---:|---:|---:|---:| +| `u32` | 41 | 40 | 46 | 15 | +| 16 bytes | 70 | 70 | 51 | 17 | +| 128 bytes | 482 | 483 | 185 | 18 | + +Channel-resident stateless role handles cost 5 instructions to drop and 15 +to reacquire for the producer; the consumer costs 5 and 14 respectively. +First and replacement publication differ by at most one instruction. Payload +copy cost scales with `size_of::()`; neither path depends on lag, occupancy, +or history. + +## Code size across the target and payload matrix + +Each `P/T` entry is emitted flash bytes for one `publish` / `take_latest` +monomorph. The final `roles P/C` columns measure producer / consumer +claim-and-release functions. Xtensa totals include both `.text` and `.literal`. + +| Target | `u32` P/T | 16 B P/T | 128 B P/T | roles P/C | +|---|---:|---:|---:|---:| +| thumbv6m | 76 / 96 | 98 / 114 | 94 / 118 | 42 / 42 | +| thumbv8m.base | 78 / 100 | 100 / 116 | 96 / 120 | 34 / 34 | +| thumbv7m | 88 / 102 | 116 / 122 | 112 / 134 | 46 / 46 | +| thumbv7em | 88 / 102 | 116 / 122 | 112 / 134 | 46 / 46 | +| thumbv8m.main | 80 / 102 | 108 / 122 | 102 / 124 | 36 / 36 | +| armv7r | 112 / 152 | 124 / 168 | 128 / 172 | 64 / 64 | +| armv7a | 112 / 152 | 124 / 168 | 128 / 172 | 64 / 64 | +| riscv32imac | 72 / 106 | 100 / 130 | 124 / 158 | 34 / 48 | +| ESP32 | 101 / 139 | 119 / 155 | 125 / 161 | 76 / 104 | +| ESP32-S2 | 79 / 114 | 97 / 130 | 103 / 136 | 47 / 47 | +| ESP32-S3 | 101 / 139 | 119 / 155 | 125 / 161 | 76 / 104 | + +The non-monotonic payload rows are normal optimized codegen: larger copies +may lower to shared copy routines instead of more inline instructions. That is +why emitted flash and retired instructions are both recorded. + +## A.1: empty-poll Acquire-load fast path + +The reference prototype swapped unconditionally. The selected implementation +first Acquire-loads the ready bit; a false load returns without transferring a +slot, while a true load still performs the same `AcqRel` ownership exchange. +The focused Loom model explores publication before, during, and after the +load. If the load returns false, the publication remains pending for the next +poll. + +Isolated Cortex-M3 comparison, before the role-index layout optimization: + +| Payload | Pending, swap -> load+swap | Empty, swap -> load | +|---|---:|---:| +| `u32` | 36 -> 42 (+6) | 22 -> 15 (-7) | +| 16 bytes | 41 -> 47 (+6) | 24 -> 17 (-7) | +| 128 bytes | 178 -> 183 (+5) | 26 -> 18 (-8) | + +The isolated `take_latest` flash deltas stayed inside the existing +16-byte +gate tolerance on every target and payload: + +| Target family | `u32` | 16 B | 128 B | +|---|---:|---:|---:| +| thumbv6m | +8 | +14 | +8 | +| thumbv8m.base | +6 | +10 | +6 | +| thumbv7m / thumbv7em | +10 | +10 | +10 | +| thumbv8m.main | +8 | +8 | +8 | +| armv7r / armv7a | +16 | +16 | +16 | +| riscv32imac | +10 | +10 | +10 | +| ESP32 / ESP32-S3 | +11 | +11 | +11 | +| ESP32-S2 | +8 | +8 | +8 | + +The Cortex-M3 count understates the named hazard-row benefit. On thumbv6m and +ESP32-S2 the eliminated empty `swap` is a portable-atomic critical section, so +the fast path removes interrupt-disable latency from every idle poll. The +pending path pays one bounded load and branch. A.1 therefore closes in favour +of the Acquire-load fast path. + +## Static layout and role-index encoding + +The first matrix run found that literal initial roles (`back = 1`, `front = 2`) +placed the entire const-initialized channel in `.data`. That charged flash and +startup copy for all three payload slots. The final implementation XOR-encodes +the private role indices so the initial representation is all zero: + +| Payload | Channel RAM | Before | Final | Flash/startup-copy removed | +|---|---:|---|---|---:| +| `u32` | 48 B | `.data` | `.bss` | 48 B | +| 16 bytes | 84 B | `.data` | `.bss` | 84 B | +| 128 bytes | 420 B | `.data` | `.bss` | 420 B | + +On every shipped target a producer or consumer handle is one pointer (4 B); +the marker is zero-sized. The channel is three `Entry` slots plus 24 bytes +of exchange, role, generation, and acquisition state for these 4-byte-aligned +payloads. + +Encoding adds at most 10 bytes to either measured operation across the matrix. +On the reference Cortex-M3 it adds 1-2 instructions to publication, 2-4 to a +pending take, and zero to an empty take. That bounded hot-path cost removes a +payload-proportional flash and startup-copy cost, so the encoded `.bss` layout +is retained. + +## Result and remaining gate + +The soundness-stage implementation now has the requested target/payload code +size, pinned instruction regions, A.1 comparison, and explicit handle/channel +RAM record. Neither constrained kill row shows a code-size outlier, and idle +polling no longer enters a portable-atomic critical section. + +This does not close D3. Joint sample-versus-`Block` composition numbers +against the BlockBuf lane remain the next development item, followed by +maintainer closure of D1-D3 and A.3. diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 4665d38..2b06e74 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -28,6 +28,7 @@ # Usage: # ./scripts/codesize.sh # baseline API, upstream targets # ./scripts/codesize.sh split # also measure try_split, where present +# ./scripts/codesize.sh latest-matrix # LatestBuf target/payload matrix # XTENSA=1 ./scripts/codesize.sh # add ESP32 rows (needs esp-rs fork) # # Reading the output: each number is the byte size of one function, so it @@ -46,9 +47,14 @@ export CARGO_INCREMENTAL PROBE_FEATURES="" BLESS=0 +LATEST_MATRIX=0 for arg in "$@"; do case "$arg" in split) PROBE_FEATURES="split" ;; + latest-matrix) + PROBE_FEATURES="latest-matrix" + LATEST_MATRIX=1 + ;; --bless) BLESS=1 ;; # Not 2: that is reserved for "could not run", which ci.sh maps to SKIP. *) printf 'unknown argument: %s @@ -111,14 +117,28 @@ fn_size() { END { if (found) print total }' } +section_size() { + "$SIZE" -A "$1" 2>/dev/null | awk -v s="$2" ' + $1 == s { total += $2; found = 1 } + END { if (found) print total }' +} + RESULTS="$(mktemp)" trap 'rm -f "$RESULTS"' EXIT -printf '%-30s %10s %8s %8s %6s\n' TARGET two_calls split bss data -printf '%-30s %10s %8s %8s %6s\n' '------------------------------' '---------' '-----' '---' '----' +if [ "$LATEST_MATRIX" = "1" ]; then + printf '%-30s %-10s %10s %10s %10s %6s\n' \ + TARGET PAYLOAD publish_B take_B channel_B init + printf '%-30s %-10s %10s %10s %10s %6s\n' \ + '------------------------------' '----------' '---------' '------' '---------' '----' +else + printf '%-30s %10s %8s %8s %6s\n' TARGET two_calls split bss data + printf '%-30s %10s %8s %8s %6s\n' '------------------------------' '---------' '-----' '---' '----' +fi skipped=0 failed=0 +matrix_missing=0 for entry in $TARGETS; do target="$(printf '%s' "$entry" | cut -d'|' -f1)" @@ -160,6 +180,54 @@ for entry in $TARGETS; do fi ar="scripts/codesize/target/$target/release/libph_eventing_codesize.a" + + if [ "$LATEST_MATRIX" = "1" ]; then + for shape in u32 w16 block128; do + case "$shape" in + u32) + publish_fn=latest_u32_publish + take_fn=latest_u32_take + static_name=LATEST_U32_BUF + ;; + w16) + publish_fn=latest_w16_publish + take_fn=latest_w16_take + static_name=LATEST_W16_BUF + ;; + block128) + publish_fn=latest_block_publish + take_fn=latest_block_take + static_name=LATEST_BLOCK_BUF + ;; + esac + publish="$(fn_size "$ar" "$publish_fn")" + take="$(fn_size "$ar" "$take_fn")" + channel_data="$(section_size "$ar" ".data.$static_name")" + channel_bss="$(section_size "$ar" ".bss.$static_name")" + if [ -n "$channel_data" ]; then + channel="$channel_data" + init=data + else + channel="$channel_bss" + init=bss + fi + [ -z "$publish" ] && matrix_missing=$((matrix_missing + 1)) + [ -z "$take" ] && matrix_missing=$((matrix_missing + 1)) + [ -z "$channel" ] && matrix_missing=$((matrix_missing + 1)) + printf '%-30s %-10s %10s %10s %10s %6s\n' \ + "$target" "$shape" "${publish:--}" "${take:--}" \ + "${channel:--}" "${init:--}" + done + + producer_role="$(fn_size "$ar" latest_producer_reacquire)" + consumer_role="$(fn_size "$ar" latest_consumer_reacquire)" + [ -z "$producer_role" ] && matrix_missing=$((matrix_missing + 1)) + [ -z "$consumer_role" ] && matrix_missing=$((matrix_missing + 1)) + printf '%-30s %-10s %10s %10s %10s %6s\n' \ + "$target" roles "${producer_role:--}" "${consumer_role:--}" '-' '-' + continue + fi + two="$(fn_size "$ar" bringup_two_calls)" spl="$(fn_size "$ar" bringup_split)" bss="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.bss\..*3BUF/ { print $2; exit }')" @@ -182,6 +250,23 @@ if [ "$skipped" -gt 0 ]; then printf 'per-architecture differences this script exists to find.\n\n' fi [ "$failed" -gt 0 ] && printf '%s target(s) failed to build.\n\n' "$failed" + +if [ "$LATEST_MATRIX" = "1" ]; then + if [ "$matrix_missing" -gt 0 ]; then + printf '%s latest-matrix section(s) had no code-size measurement.\n' \ + "$matrix_missing" >&2 + exit 1 + fi + [ "$failed" -gt 0 ] && exit 1 + [ "$skipped" -gt 0 ] && exit 2 + printf 'publish_B and take_B are emitted flash for one operation monomorph.\n' + printf 'The roles row reports producer and consumer claim+release code size.\n' + printf 'channel_B is target-object RAM for three slots plus channel state.\n' + printf 'init reports whether that const-initialized image is .data or .bss;\n' + printf '.data also occupies flash and is copied during startup.\n' + printf 'Run scripts/cycles.sh latest-matrix for state-dependent paths.\n' + exit 0 +fi # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Baseline gate diff --git a/scripts/codesize/Cargo.toml b/scripts/codesize/Cargo.toml index 2d9cd93..f699b81 100644 --- a/scripts/codesize/Cargo.toml +++ b/scripts/codesize/Cargo.toml @@ -27,6 +27,8 @@ ph-eventing = { path = "../.." } cm0 = ["ph-eventing/portable-atomic-unsafe-assume-single-core"] # Enable on a branch that has `try_split`; absent on release branches. split = [] +# Enable the LatestBuf operation and payload-size matrix. +latest-matrix = [] [profile.release] # Match what an embedded consumer would realistically ship. diff --git a/scripts/codesize/src/lib.rs b/scripts/codesize/src/lib.rs index 36f38d3..343be79 100644 --- a/scripts/codesize/src/lib.rs +++ b/scripts/codesize/src/lib.rs @@ -7,6 +7,11 @@ use core::panic::PanicInfo; use ph_eventing::EventBuf; +#[cfg(feature = "latest-matrix")] +use ph_eventing::{ + LatestBuf, LatestItem, PublishReport, + latest_buf::{Consumer as LatestConsumer, Producer as LatestProducer}, +}; #[panic_handler] fn panic(_: &PanicInfo) -> ! { @@ -56,3 +61,71 @@ pub extern "C" fn bringup_split() -> i32 { None => -4, } } + +// These exported statics make the exact channel layouts visible as individual +// object sections. LatestBuf's private role indices are encoded so the initial +// image is all zero and can land in `.bss`; extracting the target object pins +// that no-flash/no-startup-copy property rather than assuming it from source. +#[cfg(feature = "latest-matrix")] +#[unsafe(no_mangle)] +pub static LATEST_U32_BUF: LatestBuf = LatestBuf::new(); + +#[cfg(feature = "latest-matrix")] +#[unsafe(no_mangle)] +pub static LATEST_W16_BUF: LatestBuf<[u32; 4]> = LatestBuf::new(); + +#[cfg(feature = "latest-matrix")] +#[unsafe(no_mangle)] +pub static LATEST_BLOCK_BUF: LatestBuf<[u32; 32]> = LatestBuf::new(); + +// Code size does not depend on whether the runtime path is first/replacement +// publication or empty/pending take, so each payload needs one publish and one +// take monomorph. The cycle probe separates those runtime paths. +#[cfg(feature = "latest-matrix")] +macro_rules! latest_operation_probe { + ($publish:ident, $take:ident, $payload:ty) => { + #[unsafe(no_mangle)] + pub fn $publish(producer: &LatestProducer<'_, $payload>, value: $payload) -> PublishReport { + producer.publish(value) + } + + #[unsafe(no_mangle)] + pub fn $take(consumer: &LatestConsumer<'_, $payload>) -> Option> { + consumer.take_latest() + } + }; +} + +#[cfg(feature = "latest-matrix")] +latest_operation_probe!(latest_u32_publish, latest_u32_take, u32); +#[cfg(feature = "latest-matrix")] +latest_operation_probe!(latest_w16_publish, latest_w16_take, [u32; 4]); +#[cfg(feature = "latest-matrix")] +latest_operation_probe!(latest_block_publish, latest_block_take, [u32; 32]); + +// Role acquisition and release are payload-independent. Keep producer and +// consumer paths separate so a future state-persistence candidate can be +// compared without changing the runner. +#[cfg(feature = "latest-matrix")] +#[unsafe(no_mangle)] +pub fn latest_producer_reacquire(channel: &LatestBuf) -> bool { + match channel.try_producer() { + Some(producer) => { + drop(producer); + true + } + None => false, + } +} + +#[cfg(feature = "latest-matrix")] +#[unsafe(no_mangle)] +pub fn latest_consumer_reacquire(channel: &LatestBuf) -> bool { + match channel.try_consumer() { + Some(consumer) => { + drop(consumer); + true + } + None => false, + } +} diff --git a/scripts/cycles.sh b/scripts/cycles.sh index d46c08c..a63ca00 100755 --- a/scripts/cycles.sh +++ b/scripts/cycles.sh @@ -38,6 +38,7 @@ # # Usage: # ./scripts/cycles.sh # local qemu-system-arm +# ./scripts/cycles.sh latest-matrix # ./scripts/verify.sh cycles # same, inside the reference image set -u @@ -47,6 +48,14 @@ cd "$(dirname "$0")/.." || exit 1 CARGO_INCREMENTAL=0 export CARGO_INCREMENTAL +PROBE_FEATURES="" +for arg in "$@"; do + case "$arg" in + latest-matrix) PROBE_FEATURES="latest-matrix" ;; + *) printf 'unknown argument: %s\n' "$arg" >&2; exit 64 ;; + esac +done + PROBE_DIR="scripts/cycles" ELF="$PROBE_DIR/target/thumbv7m-none-eabi/release/ph-eventing-cycles" LOG="$PROBE_DIR/target/qemu-exec.log" @@ -89,9 +98,14 @@ printf '==> building probe\n' # from the working directory, not from --manifest-path. Building from the repo # root silently picks up the root config instead, targets the host, and fails to # link against libc. -if ! ( cd "$PROBE_DIR" && cargo build --release >/dev/null 2>&1 ); then +if [ -n "$PROBE_FEATURES" ]; then + build_probe() { ( cd "$PROBE_DIR" && cargo build --release --features "$PROBE_FEATURES" ); } +else + build_probe() { ( cd "$PROBE_DIR" && cargo build --release ); } +fi +if ! build_probe >/dev/null 2>&1; then printf 'error: probe failed to build\n' >&2 - ( cd "$PROBE_DIR" && cargo build --release 2>&1 | tail -20 >&2 ) + build_probe 2>&1 | tail -20 >&2 exit 1 fi @@ -196,6 +210,7 @@ report="$(awk ' if (group == "eb") printf "\nEventBuf (backpressure SPSC)\n" else if (group == "sr") printf "\nSeqRing (overwrite SPSC)\n" else if (group == "rb") printf "\nRingBuf (single owner)\n" + else if (group == "lb") printf "\nLatestBuf (freshness-first SPSC)\n" } } next diff --git a/scripts/cycles/Cargo.toml b/scripts/cycles/Cargo.toml index 56e76cb..7cc5066 100644 --- a/scripts/cycles/Cargo.toml +++ b/scripts/cycles/Cargo.toml @@ -15,6 +15,11 @@ cortex-m = "0.7" cortex-m-rt = "0.7" cortex-m-semihosting = "0.5" +[features] +# Keep the larger LatestBuf payload matrix out of the standing probe so its +# stack use and marker set remain independent. +latest-matrix = [] + [profile.release] opt-level = "z" lto = true diff --git a/scripts/cycles/src/main.rs b/scripts/cycles/src/main.rs index 72c6b31..c773923 100644 --- a/scripts/cycles/src/main.rs +++ b/scripts/cycles/src/main.rs @@ -26,6 +26,9 @@ use core::hint::black_box; use cortex_m_rt::entry; use cortex_m_semihosting::debug; +#[cfg(feature = "latest-matrix")] +use ph_eventing::LatestBuf; +#[cfg(not(feature = "latest-matrix"))] use ph_eventing::{EventBuf, RingBuf, SeqRing}; #[panic_handler] @@ -59,6 +62,7 @@ macro_rules! markers { }; } +#[cfg(not(feature = "latest-matrix"))] markers! { 0 => m_end, 1 => m_overhead, @@ -85,6 +89,31 @@ markers! { 19 => m_sr_poll_lagged_far, } +#[cfg(feature = "latest-matrix")] +markers! { + 0 => m_end, + 1 => m_overhead, + // LatestBuf -- payload width is part of each label. + 20 => m_lb_u32_publish_first, + 21 => m_lb_u32_publish_replace, + 22 => m_lb_u32_take_pending, + 23 => m_lb_u32_take_empty, + 24 => m_lb_w16_publish_first, + 25 => m_lb_w16_publish_replace, + 26 => m_lb_w16_take_pending, + 27 => m_lb_w16_take_empty, + 28 => m_lb_block128_publish_first, + 29 => m_lb_block128_publish_replace, + 30 => m_lb_block128_take_pending, + 31 => m_lb_block128_take_empty, + // Channel-resident role state / stateless handle costs (A.3 evidence). + 32 => m_lb_producer_drop, + 33 => m_lb_producer_reacquire, + 34 => m_lb_consumer_drop, + 35 => m_lb_consumer_reacquire, +} + +#[cfg(not(feature = "latest-matrix"))] fn event_buf_costs() { let buf = EventBuf::::new(); let tx = buf.try_producer().expect("producer"); @@ -125,6 +154,7 @@ fn event_buf_costs() { m_end(); } +#[cfg(not(feature = "latest-matrix"))] fn seq_ring_costs() { let ring = SeqRing::::new(); let tx = ring.try_producer().expect("producer"); @@ -173,6 +203,7 @@ fn seq_ring_costs() { m_end(); } +#[cfg(not(feature = "latest-matrix"))] fn ring_buf_costs() { let mut ring = RingBuf::::new(); @@ -197,6 +228,92 @@ fn ring_buf_costs() { m_end(); } +#[cfg(feature = "latest-matrix")] +macro_rules! measure_latest_payload { + ( + $payload:ty, + $first:expr, + $second:expr, + $publish_first:ident, + $publish_replace:ident, + $take_pending:ident, + $take_empty:ident + ) => {{ + let channel = LatestBuf::<$payload>::new(); + let producer = channel.try_producer().expect("producer"); + let consumer = channel.try_consumer().expect("consumer"); + + $publish_first(); + let _ = black_box(producer.publish(black_box($first))); + m_end(); + + $publish_replace(); + let _ = black_box(producer.publish(black_box($second))); + m_end(); + + $take_pending(); + let _ = black_box(consumer.take_latest()); + m_end(); + + $take_empty(); + let _ = black_box(consumer.take_latest()); + m_end(); + }}; +} + +#[cfg(feature = "latest-matrix")] +fn latest_buf_costs() { + measure_latest_payload!( + u32, + 1_u32, + 2_u32, + m_lb_u32_publish_first, + m_lb_u32_publish_replace, + m_lb_u32_take_pending, + m_lb_u32_take_empty + ); + measure_latest_payload!( + [u32; 4], + [1_u32; 4], + [2_u32; 4], + m_lb_w16_publish_first, + m_lb_w16_publish_replace, + m_lb_w16_take_pending, + m_lb_w16_take_empty + ); + measure_latest_payload!( + [u32; 32], + [1_u32; 32], + [2_u32; 32], + m_lb_block128_publish_first, + m_lb_block128_publish_replace, + m_lb_block128_take_pending, + m_lb_block128_take_empty + ); + + let channel = LatestBuf::::new(); + let producer = channel.try_producer().expect("producer"); + let consumer = channel.try_consumer().expect("consumer"); + + m_lb_producer_drop(); + drop(black_box(producer)); + m_end(); + + m_lb_producer_reacquire(); + let producer = black_box(channel.try_producer()); + m_end(); + drop(producer.expect("reacquired producer")); + + m_lb_consumer_drop(); + drop(black_box(consumer)); + m_end(); + + m_lb_consumer_reacquire(); + let consumer = black_box(channel.try_consumer()); + m_end(); + drop(consumer.expect("reacquired consumer")); +} + #[entry] fn main() -> ! { // Two adjacent markers: the cost of the markers themselves, subtracted @@ -204,9 +321,14 @@ fn main() -> ! { m_overhead(); m_end(); - event_buf_costs(); - seq_ring_costs(); - ring_buf_costs(); + #[cfg(not(feature = "latest-matrix"))] + { + event_buf_costs(); + seq_ring_costs(); + ring_buf_costs(); + } + #[cfg(feature = "latest-matrix")] + latest_buf_costs(); debug::exit(debug::EXIT_SUCCESS); loop {} diff --git a/scripts/verify.sh b/scripts/verify.sh index e27eb66..35a034e 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -18,6 +18,7 @@ # ./scripts/verify.sh miri # ./scripts/verify.sh loom # ./scripts/verify.sh cycles +# ./scripts/verify.sh cycles latest-matrix # ./scripts/verify.sh shell # interactive shell in the image # # Requires Docker. Everything else is inside the image. @@ -73,7 +74,10 @@ case "${1:-all}" in ci) run sh scripts/ci.sh ;; miri) run sh scripts/miri.sh ;; loom) run sh scripts/loom.sh ;; - cycles) run sh scripts/cycles.sh ;; + cycles) + shift + run sh scripts/cycles.sh "$@" + ;; all) # Version stamp first, so any pasted output carries its environment. run sh -c 'rustc --version; rustc "+$MIRI_TOOLCHAIN" --version; qemu-system-arm --version | head -1' diff --git a/src/latest_buf.rs b/src/latest_buf.rs index ac54413..7786b85 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -15,6 +15,12 @@ //! stateless and dropping/reacquiring one continues its slot ownership, //! generation sequence, and skipped accounting. //! +//! An empty consumer poll first Acquire-loads the ready bit and returns without +//! an atomic read-modify-write. A pending poll still performs the same `AcqRel` +//! swap that transfers slot ownership. The private initial role indices are +//! encoded as zero so a const-initialized channel lands in `.bss` rather than +//! carrying its three payload slots in the flash-backed `.data` image. +//! //! # Deferred-policy assumptions //! //! This evaluable prototype makes the narrow choices requested by issue #27: @@ -49,6 +55,11 @@ use core::mem::MaybeUninit; const SLOT_MASK: u32 = 0b11; const READY_BIT: u32 = 0b100; +// Encode the initially owned slots as zero so a const-initialized channel is +// an all-zero image and lands in `.bss`. XOR is its own inverse, and these +// role-owned fields never enter the shared exchange state. +const PRODUCER_SLOT_XOR: u32 = 1; +const CONSUMER_SLOT_XOR: u32 = 2; #[derive(Clone, Copy)] struct Entry { @@ -58,13 +69,13 @@ struct Entry { #[derive(Clone, Copy)] struct ProducerState { - back: u32, + back_encoded: u32, next_generation: u32, } #[derive(Clone, Copy)] struct ConsumerState { - front: u32, + front_encoded: u32, last_generation: u32, } @@ -109,7 +120,7 @@ pub struct LatestItem { /// /// `LatestBuf` has fixed storage for three `T` values and never allocates. /// Publishing always succeeds and takes one atomic exchange. Taking is also -/// bounded to one exchange and at most one payload copy. +/// bounded to one load, at most one exchange, and at most one payload copy. pub struct LatestBuf { exchange: AtomicU32, slots: [TrackedCell>>; 3], @@ -138,11 +149,11 @@ impl LatestBuf { exchange: AtomicU32::new(0), slots: slot_array(), producer_state: TrackedCell::new(ProducerState { - back: 1, + back_encoded: 0, next_generation: 0, }), consumer_state: TrackedCell::new(ConsumerState { - front: 2, + front_encoded: 0, last_generation: 0, }), producer_taken: AtomicBool::new(false), @@ -157,11 +168,11 @@ impl LatestBuf { exchange: AtomicU32::new(0), slots: slot_array(), producer_state: TrackedCell::new(ProducerState { - back: 1, + back_encoded: 0, next_generation: 0, }), consumer_state: TrackedCell::new(ConsumerState { - front: 2, + front_encoded: 0, last_generation: 0, }), producer_taken: AtomicBool::new(false), @@ -216,6 +227,26 @@ impl LatestBuf { state & READY_BIT != 0 } + #[inline(always)] + const fn producer_slot(encoded: u32) -> u32 { + encoded ^ PRODUCER_SLOT_XOR + } + + #[inline(always)] + const fn encode_producer_slot(slot: u32) -> u32 { + slot ^ PRODUCER_SLOT_XOR + } + + #[inline(always)] + const fn consumer_slot(encoded: u32) -> u32 { + encoded ^ CONSUMER_SLOT_XOR + } + + #[inline(always)] + const fn encode_consumer_slot(slot: u32) -> u32 { + slot ^ CONSUMER_SLOT_XOR + } + #[inline(always)] const fn next_generation(current: u32) -> u32 { match current.wrapping_add(1) { @@ -264,7 +295,10 @@ impl Producer<'_, T> { let state = &mut *state; let generation = LatestBuf::::next_generation(state.next_generation); state.next_generation = generation; - (state.back, generation) + ( + LatestBuf::::producer_slot(state.back_encoded), + generation, + ) }); // SAFETY: `back` is exclusively producer-owned. The producer does not @@ -280,9 +314,9 @@ impl Producer<'_, T> { // SAFETY: the exchange transferred its previous slot exclusively to // the producer. No other producer handle exists. - self.buf - .producer_state - .with_mut(|state| unsafe { (*state).back = previous & SLOT_MASK }); + self.buf.producer_state.with_mut(|state| unsafe { + (*state).back_encoded = LatestBuf::::encode_producer_slot(previous & SLOT_MASK); + }); PublishReport { generation, @@ -313,17 +347,26 @@ impl Consumer<'_, T> { /// Claim and copy the latest unread publication. /// /// Returns `None` when no publication is pending. The operation performs - /// one atomic swap unconditionally; this keeps the ownership protocol - /// simple while the optional empty-poll load fast path remains under - /// evaluation. + /// an `Acquire` load first, so an empty poll returns without an atomic + /// read-modify-write. A pending publication is still claimed with the + /// ownership-transferring `AcqRel` swap. #[inline] pub fn take_latest(&self) -> Option> { + // A false load can linearize before a concurrent publication: the + // publication remains pending for the next poll. This path transfers + // no slot and therefore must not access or update either owned role. + // A true load is only a hint; the AcqRel swap below remains the actual + // ownership transfer and may claim a newer replacement publication. + if !LatestBuf::::ready(self.buf.exchange.load(Ordering::Acquire)) { + return None; + } + // SAFETY: consumer_taken grants this handle exclusive role-state // access, ordered across reacquisition by Release/AcqRel. let front = self .buf .consumer_state - .with(|state| unsafe { (*state).front }); + .with(|state| unsafe { LatestBuf::::consumer_slot((*state).front_encoded) }); let previous = self .buf @@ -334,9 +377,9 @@ impl Consumer<'_, T> { // SAFETY: the exchange transferred `claimed` exclusively to this // consumer; preserving it even on the empty path maintains the three // disjoint ownership roles. - self.buf - .consumer_state - .with_mut(|state| unsafe { (*state).front = claimed }); + self.buf.consumer_state.with_mut(|state| unsafe { + (*state).front_encoded = LatestBuf::::encode_consumer_slot(claimed); + }); if !LatestBuf::::ready(previous) { return None; diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 139712c..1c69fe5 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -118,6 +118,45 @@ fn latest_buf_reused_slot_keeps_exclusive_ownership() { }); } +/// The empty-poll Acquire load either observes a pending publication and +/// proceeds through the normal ownership swap, or returns before a concurrent +/// publication. In the latter case that publication must remain pending for +/// the next poll rather than being lost. +#[test] +fn latest_buf_empty_fast_path_preserves_concurrent_publication() { + loom::model(|| { + let channel = Arc::new(LatestBuf::::new()); + + let producer_channel = Arc::clone(&channel); + let producer = thread::spawn(move || { + let producer = producer_channel.try_producer().unwrap(); + producer.publish(7) + }); + + let consumer_channel = Arc::clone(&channel); + let consumer = thread::spawn(move || { + let consumer = consumer_channel.try_consumer().unwrap(); + consumer.take_latest() + }); + + assert_eq!(producer.join().unwrap().generation, 1); + let first = consumer.join().unwrap(); + + let consumer = channel.try_consumer().unwrap(); + let second = consumer.take_latest(); + match first { + Some(item) => { + assert_eq!((item.generation, item.value), (1, 7)); + assert_eq!(second, None); + } + None => { + let item = second.expect("publication remains pending after an empty poll"); + assert_eq!((item.generation, item.value), (1, 7)); + } + } + }); +} + /// Channel-resident producer state is published by handle drop and acquired /// by the next handle, so reacquisition in another context resumes generation. #[test] From 0a22adaf5a8df1f6a6a5c3b7f432b7ce46b123a6 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 13:01:34 -0400 Subject: [PATCH 10/87] Complete CountedSignal admission evidence --- AGENTS.md | 12 +- README.md | 2 +- docs/0.3.0-candidates.md | 4 +- docs/proposals/counted-signal-contract.md | 143 ++++++++++++++++++++++ docs/proposals/counted-signal.md | 45 +++++-- docs/proposals/exploratory-primitives.md | 5 +- src/counted_signal.rs | 31 ++++- src/loom_tests.rs | 4 +- 8 files changed, 223 insertions(+), 23 deletions(-) create mode 100644 docs/proposals/counted-signal-contract.md diff --git a/AGENTS.md b/AGENTS.md index 09d7ad1..b604f70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -690,8 +690,9 @@ Cross-environment diffs of ±1 are noise; compare inside the image. | `SeqRing::latest_value` | — | 30 | | | `RingBuf::push` | 20 | **20** (overwriting) | | | `RingBuf::get` / `latest` | 22 / 16 | | | +| `CountedSignal::increment` / `take_count` | 8 / 7 | | | -Two results carry the argument: +Three results carry the argument: 1. **Every `push` is constant.** Empty vs loaded differs by at most one instruction on all three types. Cost does not scale with occupancy or `N`. @@ -699,6 +700,10 @@ Two results carry the argument: ~2000 behind both cost **115 instructions**. If recovery walked the backlog the second would be two orders of magnitude larger. It is a jump, and now that is measured rather than asserted. +3. **CountedSignal's SPSC hot paths are small and bounded.** `increment` + retires 8 instructions and `take_count` retires 7 in the reference + Cortex-M3 environment. The producer region contains no CAS retry loop; its + fixed count is the measured counterpart to the sole-producer no-wrap proof. The rejected push is *cheaper* than an accepted one — backpressure is an early return, not extra work. @@ -882,8 +887,9 @@ cargo test **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -and `src/traits.rs`. Total: 75 unit tests + 11 doctests, plus 3 `compile_fail` -doctests pinning the `N == 0` rejection (`E0080`) on all three types. +and `src/traits.rs`. Total: 75 unit tests + 11 doctests, plus 5 `compile_fail` +doctests: three pin the `N == 0` rejection (`E0080`) on the buffer types, and +two pin the CountedSignal handles' `!Sync` contract. ## Code Conventions diff --git a/README.md b/README.md index 6cbdd50..df62055 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 75 unit + 11 doctests + 3 compile-fail | Behaviour, including threaded stress tests for all concurrent types; `N == 0` rejected at compile time | +| 75 unit + 11 doctests + 5 compile-fail | Behaviour, including threaded stress tests for all concurrent types; `N == 0` and the CountedSignal handles' `!Sync` contract rejected at compile time | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/docs/0.3.0-candidates.md b/docs/0.3.0-candidates.md index f029cb6..ec2f1b6 100644 --- a/docs/0.3.0-candidates.md +++ b/docs/0.3.0-candidates.md @@ -226,7 +226,9 @@ position for maintainer triage, not a decision. `&self`-operation primitive (the sketched `SignalSink::raise` takes `&self`) — a handle-model question to settle at design time. - **`CountedSignal`** (saturating per-condition counter) — design - document: [`proposals/counted-signal.md`](proposals/counted-signal.md). + document: [`proposals/counted-signal.md`](proposals/counted-signal.md); + clause-numbered contract: + [`proposals/counted-signal-contract.md`](proposals/counted-signal-contract.md). Same niche and same triage notes as `EventFlags` (cross-referenced, not duplicated); the saturate-don't-wrap rule matches the `dropped_accum` convention already in the codebase. Separate design documents by diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md new file mode 100644 index 0000000..a51bd0e --- /dev/null +++ b/docs/proposals/counted-signal-contract.md @@ -0,0 +1,143 @@ +# CountedSignal semantic contract (draft) + +- **Status:** Draft for review — promotion-bar item 3 in + [`counted-signal.md`](counted-signal.md) §4. +- **Rule of this document:** the clauses describe an **abstract counted + signal**, independently of atomics, memory orderings, or the candidate + algorithm. The design and its evidence must satisfy the clauses; they do + not define them. +- **Clause IDs are load-bearing.** Tests, models, and documentation cite these + IDs. Do not renumber an existing clause; append a new one to its group. +- **Handle-decision status:** the H clauses state the lane's recommended answer + to the still-open shared decision with EventFlags. Accepting this contract + accepts that answer for CountedSignal; choosing shareable raisers requires a + revised contract, algorithm, and evidence case. See §8. + +## 1. Abstract model + +A **signal** records identical, payload-free occurrences from one producer +handle for one consumer handle. Its abstract state is one of: + +- `Exact(n)`, where `0 <= n < u32::MAX`; or +- `Saturated`, representing at least `u32::MAX` occurrences. + +A new signal starts in `Exact(0)`. It has two operations: + +- `increment() -> ()` — producer; +- `take_count() -> CountSnapshot` — consumer. + +Both operations are **linearizable**: every completed call takes effect once at +some instant between invocation and return. An occurrence belongs to the take +interval containing its `increment` linearization point, regardless of when +the call was invoked. + +## 2. Increment clauses (I) + +- **I1.** If the state is `Exact(n)` for `n < u32::MAX - 1` at an + `increment` linearization point, the state becomes `Exact(n + 1)`. +- **I2.** If the state is `Exact(u32::MAX - 1)` at an `increment` + linearization point, the state becomes `Saturated`. +- **I3.** If the state is `Saturated` at an `increment` linearization point, + it remains `Saturated`. Saturation never wraps or returns to an exact state + except through `take_count` (T2). +- **I4.** `increment` has no rejection or failure result. Every completed call + linearizes exactly once; while saturated, further calls are represented by + the already-observable saturated state rather than by an exact excess count. + +## 3. Take clauses (T) + +- **T1.** If the state is `Exact(n)` at a `take_count` linearization point, + the call returns a snapshot whose `count()` is `n` and whose + `is_saturated()` is `false`, then the state becomes `Exact(0)`. +- **T2.** If the state is `Saturated` at a `take_count` linearization point, + the call returns a snapshot whose `count()` is `u32::MAX` and whose + `is_saturated()` is `true`, then the state becomes `Exact(0)`. +- **T3.** A concurrent `increment` linearizes either before a take and is + represented by that take's snapshot, or after it and belongs to the following + interval. It is never represented in both intervals and, subject to + saturation's explicit loss of excess precision (A2), never disappears from + both. +- **T4.** Taking an empty signal returns exact zero and leaves it empty. + +## 4. Accounting and overload clauses (A) + +- **A1.** A non-saturated snapshot with count `n` represents exactly `n` + `increment` calls linearized since the preceding take, or since + initialization for the first take. No count is fabricated and no call is + duplicated. +- **A2.** A saturated snapshot represents at least `u32::MAX` `increment` + calls in that interval. The exact excess is deliberately unrecoverable, but + the fact that exact accounting was lost remains observable in the snapshot. +- **A3.** Saturation evidence is sticky until the take that returns it. Neither + an additional increment nor counter-width overflow can erase that evidence. + +## 5. Boundedness clauses (B) + +Algorithmic bounds live here; instruction counts remain measured claims tied +to a target, toolchain, and reference environment. + +- **B1.** `increment` performs a statically bounded amount of work independent + of signal history and consumer activity: no retry loop, no dynamic + allocation, no user code, and no wait for the consumer. +- **B2.** `take_count` performs a statically bounded amount of work independent + of the number of increments in the interval and producer activity: no retry + loop, no dynamic allocation, no user code, and no wait for the producer. +- **B3.** No panic is reachable from either hot-path operation. + +## 6. Handle clauses (H) + +- **H1.** At most one producer handle and at most one consumer handle are + active per signal. Acquisition is fallible and non-panicking; a second + acquisition of an active role fails. +- **H2.** Handles are `Send + !Sync`. They may move between execution contexts + but may not be shared between them. An `&self` hot-path receiver does not + weaken this ownership rule. +- **H3.** Dropping a handle makes its role re-acquirable without resetting the + signal. After reacquisition, all I/T/A clauses continue from the existing + state. +- **H4.** A signal is constructible in static storage on the normal build. + +## 7. What the contract does not promise (X) + +- **X1.** Ordering or identity for individual occurrences. Only their count in + a take interval is retained. +- **X2.** Payload publication or memory visibility for unrelated data. An + occurrence carries no payload. +- **X3.** The exact number of occurrences beyond saturation. A2 promises a + lower bound and observable loss of precision, not an unbounded total. +- **X4.** Multiple producers, multiple consumers, or a shareable handle. +- **X5.** A universal wall-clock bound. B1–B3 are algorithmic guarantees; + instruction and latency claims are environment-specific measurements. + +## 8. Shared handle decision (H-decision) + +The candidate answer shared with EventFlags is H1–H4: sole-role +`Send + !Sync` handles with `&self` hot-path operations and state resident in +the signal. For CountedSignal, this is a correctness choice rather than an API +style preference. The bounded candidate implementation reads the state and +then conditionally increments it. Sole-producer ownership means the consumer +is the only possible intervening writer and can only reset the state; a second +raiser would invalidate the no-wrap proof and the current evidence for B1. + +The independent LatestBuf A.3 decision currently recommends the same handle +shape. Whether H and A.3 close as one crate-wide doctrine or as separate +decisions remains a maintainer decision on issue #26; this contract does not +silently take that broader decision. + +## 9. Evidence map + +| Clauses | Evidence on `candidate/counted-signal` | +|---|---| +| I1, T1, T4, A1 | `increments_accumulate_and_take_clears`; the threaded `concurrent_takes_do_not_lose_increments` sum check | +| I2–I3, T2, A2–A3 | `saturates_instead_of_wrapping`; Loom's `counted_signal_saturation_boundary_is_linearizable` model | +| T1–T3, A1 | Loom's `counted_signal_take_partitions_increments` model; threaded take stress | +| B1–B2 | Source review (no loops); eight-target gated code-size rows; pinned Cortex-M3 probe: 8 retired instructions for `increment`, 7 for `take_count` under rustc 1.92.0 (`ded5c06cf`) and QEMU 10.0.11 | +| B3 | Source review plus normal, Miri, Loom, and embedded-target executions of both hot paths | +| H1, H3 | `handles_are_exclusive_and_reusable_after_drop`, including state continuation after reacquisition | +| H2 | `handles_are_send`; producer and consumer compile-fail doctests pin `!Sync` | +| H4 | `const_new_works_in_static_context` | + +The candidate's relaxed atomic orderings and its load-plus-conditional-RMW +proof are implementation evidence for this abstract contract. They remain in +[`counted-signal.md`](counted-signal.md) §3.1 and the module documentation, +not in the semantic clauses above. diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index c19f60a..efa9344 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -6,6 +6,8 @@ 2026-08-11); triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. - **Taxonomy row:** retention *count per condition* · overload *counter saturates* · representative use *pulse/event accumulation*. +- **Contract:** [`counted-signal-contract.md`](counted-signal-contract.md) — + short abstract contract with stable clause IDs and an evidence map. - **Related:** [`event-flags.md`](event-flags.md) — same niche (bounded ISR notification), different contract (multiplicity matters here); the `SignalSink`/`SignalSource` trait vocabulary lives in that document and @@ -140,9 +142,22 @@ are small and, importantly, expose the architecture split for review: | RV32IMAC | 18 B | 8 B | The M0/M23 rows use the existing portable-atomic single-core probe backend. -Instruction counts still require the pinned QEMU reference environment; the -probe regions are committed, but no cycle number is claimed from a machine -without QEMU. + +The cycle probe has also been run in the pinned reference environment via +`./scripts/verify.sh cycles`: + +| Cortex-M3 hot path | Retired guest instructions | +|---|---:| +| `increment` | 8 | +| `take_count` | 7 | + +Environment stamp: rustc 1.92.0 (`ded5c06cf`), LLVM 21.1.3, QEMU 10.0.11 +(Debian trixie), release `opt-level = "z"` with LTO. The runner subtracts its +marker overhead and uses one guest instruction per translation block with +`-icount shift=0`; these are deterministic instruction/tick counts for that +environment, not a universal microarchitectural cycle claim. Together with +the eight-target code-size rows, this closes the lane's missing pinned-cost +evidence. This result refines rather than silently closes the handle decision: choose SPSC handles and the central bounded-saturation problem has a small exact @@ -150,12 +165,18 @@ solution; choose multiple raisers and this implementation must be rejected. ## 4. Promotion bar to PROPOSED -1. Solve the bounded-saturating-increment question — it decides whether the - primitive can honour the crate's boundedness rules at all. -2. Settle the shared handle-model question with - [`event-flags.md`](event-flags.md) (one answer for both). -3. Write the short contract with citable clause IDs. -4. Standard evidence bar, same emphasis as EventFlags: codesize/cycles on - the ISR-side `increment` carry the admission case; Loom/Miri close the - atomic-take and no-lost-increment clauses; the saturation test mirrors - `dropped_accum_saturates_instead_of_overflowing`. +1. **Complete for the SPSC candidate:** the bounded-saturating-increment + question has the load-plus-conditional-RMW answer proved in §3.1. +2. **Open maintainer decision:** settle the shared handle model with + [`event-flags.md`](event-flags.md) (one answer for both). The proof + dependency is recorded in the contract's H-decision section; choosing + multiple raisers rejects the current algorithm and its B1 evidence. +3. **Complete:** the short + [`counted-signal-contract.md`](counted-signal-contract.md) gives stable + I/T/A/B/H/X clause IDs and maps each guarantee to evidence. +4. **Complete for the SPSC candidate:** eight-target code-size rows and pinned + Cortex-M3 instruction counts carry the cost case; the unit boundary test, + threaded stress, Miri, and two Loom models cover atomic take, + no-lost-increment accounting, and saturation without wrapping. + +The shared handle decision is therefore the only remaining promotion gate. diff --git a/docs/proposals/exploratory-primitives.md b/docs/proposals/exploratory-primitives.md index a7899a8..e813d8b 100644 --- a/docs/proposals/exploratory-primitives.md +++ b/docs/proposals/exploratory-primitives.md @@ -111,8 +111,9 @@ abstraction. ## 5. CountedSignal: multiplicity without payloads -**Design document: [`counted-signal.md`](counted-signal.md)** (the -substantive design text has moved there; this entry is the summary). +**Design document: [`counted-signal.md`](counted-signal.md); semantic contract: +[`counted-signal-contract.md`](counted-signal-contract.md).** The substantive +design text has moved there; this entry is the summary. For events whose duplicates matter but whose individual payloads do not — encoder pulses, timer expirations, drop counts. A saturating atomic diff --git a/src/counted_signal.rs b/src/counted_signal.rs index d0f819b..bbad984 100644 --- a/src/counted_signal.rs +++ b/src/counted_signal.rs @@ -113,6 +113,15 @@ impl core::fmt::Debug for CountedSignal { /// /// This handle is `Send + !Sync`. Its exclusivity is what makes exact, /// bounded saturation possible without a compare-exchange loop. +/// +/// The load-bearing `!Sync` property is pinned at compile time: +/// +/// ```compile_fail +/// use ph_eventing::counted_signal::Producer; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` pub struct Producer<'a> { signal: &'a CountedSignal, _not_sync: PhantomData>, @@ -152,6 +161,13 @@ impl core::fmt::Debug for Producer<'_> { /// /// This handle is `Send + !Sync` and may atomically take counts while its /// paired producer increments from another context. +/// +/// ```compile_fail +/// use ph_eventing::counted_signal::Consumer; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` pub struct Consumer<'a> { signal: &'a CountedSignal, _not_sync: PhantomData>, @@ -215,6 +231,7 @@ mod tests { #[test] fn increments_accumulate_and_take_clears() { + // Contract I1, T1, T4, and A1. let signal = CountedSignal::new(); let producer = signal.try_producer().unwrap(); let consumer = signal.try_consumer().unwrap(); @@ -228,6 +245,7 @@ mod tests { #[test] fn saturates_instead_of_wrapping() { + // Contract I2-I3, T2, and A2-A3. let signal = CountedSignal::new(); signal.count.store(u32::MAX - 1, Ordering::Relaxed); let producer = signal.try_producer().unwrap(); @@ -244,20 +262,27 @@ mod tests { #[test] fn handles_are_exclusive_and_reusable_after_drop() { + // Contract H1 and H3: reacquisition continues existing state. let signal = CountedSignal::new(); let producer = signal.try_producer().unwrap(); let consumer = signal.try_consumer().unwrap(); assert!(signal.try_producer().is_none()); assert!(signal.try_consumer().is_none()); + producer.increment(); + drop(producer); drop(consumer); - assert!(signal.try_producer().is_some()); - assert!(signal.try_consumer().is_some()); + let producer = signal.try_producer().expect("producer role released"); + let consumer = signal.try_consumer().expect("consumer role released"); + assert_eq!(consumer.take_count().count(), 1); + producer.increment(); + assert_eq!(consumer.take_count().count(), 1); } #[test] fn handles_are_send() { + // Contract H2. The compile-fail examples above pin `!Sync`. fn assert_send() {} assert_send::>(); assert_send::>(); @@ -266,6 +291,7 @@ mod tests { #[cfg(not(loom))] #[test] fn const_new_works_in_static_context() { + // Contract H4. static SIGNAL: CountedSignal = CountedSignal::new(); let producer = SIGNAL.try_producer().unwrap(); let consumer = SIGNAL.try_consumer().unwrap(); @@ -276,6 +302,7 @@ mod tests { #[cfg(not(loom))] #[test] fn concurrent_takes_do_not_lose_increments() { + // Contract T3 and A1 at stress-test scale. use core::sync::atomic::{AtomicBool, Ordering as CoreOrdering}; let signal = CountedSignal::new(); diff --git a/src/loom_tests.rs b/src/loom_tests.rs index e243322..8bf04b9 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -24,7 +24,7 @@ use loom::sync::Arc; use loom::thread; /// Concurrent takes partition increments between snapshots without losing or -/// duplicating them. +/// duplicating them (CountedSignal contract T1-T3 and A1). #[test] fn counted_signal_take_partitions_increments() { loom::model(|| { @@ -52,7 +52,7 @@ fn counted_signal_take_partitions_increments() { /// At the saturation boundary, a take between the producer's load and /// `fetch_add` moves the increment into the new epoch; it cannot make the RMW -/// wrap or lose the increment. +/// wrap or lose the increment (contract I2-I3, T2-T3, and A2-A3). #[test] fn counted_signal_saturation_boundary_is_linearizable() { loom::model(|| { From 8593b4afc41d58ccdda5797c118ba8322b56bcf3 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 13:09:41 -0400 Subject: [PATCH 11/87] Measure LatestBuf block composition --- AGENTS.md | 8 + CHANGELOG.md | 5 + docs/0.3.0-candidates.md | 15 +- .../latest-block-composition-measurements.md | 200 +++++++++++ docs/proposals/latest-buf-evaluation.md | 16 +- docs/proposals/latest-buf-measurements.md | 12 +- scripts/codesize.sh | 77 ++++- scripts/codesize/Cargo.toml | 2 + scripts/codesize/src/lib.rs | 146 +++++++- scripts/cycles.sh | 3 + scripts/cycles/Cargo.toml | 2 + scripts/cycles/src/main.rs | 321 +++++++++++++++++- scripts/probes/block_shape.rs | 114 +++++++ scripts/verify.sh | 1 + 14 files changed, 888 insertions(+), 34 deletions(-) create mode 100644 docs/proposals/latest-block-composition-measurements.md create mode 100644 scripts/probes/block_shape.rs diff --git a/AGENTS.md b/AGENTS.md index 98913f0..5c47e19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -653,6 +653,7 @@ Re-bless deliberately, never reflexively — the diff is the review: ./scripts/codesize.sh # baseline, 8 upstream targets ./scripts/codesize.sh split # include try_split, on branches that have it ./scripts/codesize.sh latest-matrix # LatestBuf operations and payload layouts +./scripts/codesize.sh latest-block-matrix # LatestBuf/BlockBuf D3 composition XTENSA=1 ./scripts/codesize.sh # add the 3 ESP32 rows ``` @@ -712,6 +713,13 @@ Role drop/reacquisition costs 5/15 instructions for the producer and 5/14 for the consumer. The target/code-size/RAM and A.1 comparison record is `docs/proposals/latest-buf-measurements.md`. +The joint D3 composition probe uses a separate `latest-block-matrix` feature +and a structural twin of `Block` / `BlockBuilder` pinned to the +BlockBuf candidate revision, rather than stacking candidate branches. Run it +with `./scripts/verify.sh cycles latest-block-matrix`; its 2/8/16-byte by +8/32/128 cycle, code-size, and RAM record is +`docs/proposals/latest-block-composition-measurements.md`. + Two results carry the argument: 1. **Every `push` is constant.** Empty vs loaded differs by at most one diff --git a/CHANGELOG.md b/CHANGELOG.md index 60f582f..38a429c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ All notable changes to this project will be documented in this file. flash, including +8 bytes on ESP32-S2. Private role indices now have an all-zero encoding, moving const-initialized channels from `.data` to `.bss` and removing 48-420 bytes of flash/startup copy in the measured payloads. +- Joint LatestBuf/BlockBuf D3 measurement mode over 2/8/16-byte samples and + `N = 8/32/128`, covering all 11 targets plus pinned QEMU regions. It records + sample scheduling, final block completion/publication, consumer cost, and + 136-8,280 bytes of combined channel/builder RAM without stacking candidate + branches. ### Documentation - `RingBuf::new`'s docs no longer mention a `pop` method the type does not have — `pop` was diff --git a/docs/0.3.0-candidates.md b/docs/0.3.0-candidates.md index 9a6e8d4..1b105a0 100644 --- a/docs/0.3.0-candidates.md +++ b/docs/0.3.0-candidates.md @@ -144,11 +144,16 @@ compile, and target/payload measurement stages. The live comparison record is the 11-target code-size, pinned-cycle, A.1, and RAM results are in [`proposals/latest-buf-measurements.md`](proposals/latest-buf-measurements.md). A.1 closes in favour of an Acquire-load empty fast path; A.2 is pinned by the -wrap tests. D1-D3 and A.3 remain prototype defaults pending maintainer closure. -The next development item is the joint complete-block composition measurement -with the BlockBuf lane, not more soundness design. Traits `ObservedSource` and -the payload metadata traits remain gated on a second implementation proving -the vocabulary honest; they are not part of the initial acceptance question. +wrap tests. The joint complete-block matrix with the BlockBuf lane is also +complete in +[`proposals/latest-block-composition-measurements.md`](proposals/latest-block-composition-measurements.md): +final completion plus LatestBuf publication is 171-6,958 reference +instructions across the 2/8/16-byte by 8/32/128 grid, with 136-8,280 bytes of +combined channel/builder RAM. The evidence supports payload-agnostic D3; D1-D3 +and A.3 remain prototype defaults pending maintainer closure. Traits +`ObservedSource` and the payload metadata traits remain gated on a second +implementation proving the vocabulary honest; they are not part of the +initial acceptance question. **Composition evidence is deliberately deferred.** Hardware-in-the-loop evaluation of the full producer→transport→consumer composition is the last diff --git a/docs/proposals/latest-block-composition-measurements.md b/docs/proposals/latest-block-composition-measurements.md new file mode 100644 index 0000000..c6d3537 --- /dev/null +++ b/docs/proposals/latest-block-composition-measurements.md @@ -0,0 +1,200 @@ +# LatestBuf sample/block composition matrix + +- **Measured:** 2026-08-11. +- **LatestBuf source:** `candidate/latest-buf` at `6f46da3`, plus the isolated + joint probe recorded with this document. +- **Block source:** `candidate/block-buf` at `bc54a9a`. +- **Compiler:** `rustc 1.92.0 (ded5c06cf 2025-12-08)`. +- **Cycle reference:** `QEMU emulator version 10.0.11`, from the repository's + `ph-eventing-verify` image. +- **Shapes:** sample widths 2, 8, and 16 bytes; `N = 8, 32, 128`. + +This is the joint composition evidence requested by issues #27 and #28 for +decision D3. It compares individual-sample publication with complete-block +publication through the same generic `LatestBuf`, and uses the same final +`BlockBuilder::push` plus transport-publication boundary as #28's BlockBuf +matrix. + +The candidate branches deliberately do not stack. The probe therefore carries +a private structural twin of #28's `Block` and `BlockBuilder` at +`bc54a9a`: the same field types, field order, completion path, sample widths, +and target layouts. Only the probe uses these twins; the public LatestBuf API +remains generic and the lane has no dependency on the BlockBuf branch. + +Reproduce with: + +```sh +XTENSA=1 ./scripts/codesize.sh latest-block-matrix +./scripts/verify.sh cycles latest-block-matrix +``` + +## Scheduling comparison + +The table below compares continuous replacement publication. `N x sample` is +the cost of releasing every acquired sample separately. `Complete block` is +the final builder push plus one `LatestBuf>::publish`; the first +`N - 1` private builder pushes are acquisition work outside the publication +region, matching #28's boundary. + +| Width | N | One sample | `N x sample` | Complete block | Change at release boundary | +|---:|---:|---:|---:|---:|---:| +| 2 | 8 | 41 | 328 | 173 | -47% | +| 2 | 32 | 41 | 1,312 | 525 | -60% | +| 2 | 128 | 41 | 5,248 | 1,136 | -78% | +| 8 | 8 | 44 | 352 | 541 | +54% | +| 8 | 32 | 44 | 1,408 | 1,152 | -18% | +| 8 | 128 | 44 | 5,632 | 3,640 | -35% | +| 16 | 8 | 69 | 552 | 723 | +31% | +| 16 | 32 | 69 | 2,208 | 1,970 | -11% | +| 16 | 128 | 69 | 8,832 | 6,958 | -21% | + +Batching is not uniformly cheaper: at `N = 8`, the 8- and 16-byte shapes pay +more at the measured release boundary. It becomes cheaper for those widths at +`N >= 32`, while the 2-byte shapes benefit throughout this grid. This is a +scheduling trade-off over one generic transport, not evidence for a separate +latest-block synchronization primitive. + +## Joint comparison with EventBuf block publication + +#28 measures final builder completion plus accepted or rejected +`EventBuf, 1>::push`. The matching LatestBuf first/replacement rows +are shown beside it. LatestBuf publication always succeeds; replacement is +observable through `PublishReport` rather than returning the old block. + +| Width | N | Block bytes | Latest first | Latest replacement | EventBuf accepted | EventBuf rejected | +|---:|---:|---:|---:|---:|---:|---:| +| 2 | 8 | 24 | 171 | 173 | 159 | 151 | +| 2 | 32 | 72 | 524 | 525 | 606 | 595 | +| 2 | 128 | 264 | 1,134 | 1,136 | 1,373 | 1,362 | +| 8 | 8 | 72 | 542 | 541 | 618 | 611 | +| 8 | 32 | 264 | 1,151 | 1,152 | 1,385 | 1,378 | +| 8 | 128 | 1,032 | 3,644 | 3,640 | 4,502 | 4,497 | +| 16 | 8 | 136 | 719 | 723 | 875 | 844 | +| 16 | 32 | 520 | 1,967 | 1,970 | 2,415 | 2,406 | +| 16 | 128 | 2,056 | 6,958 | 6,958 | 8,658 | 8,644 | + +Except for the smallest 24-byte block, LatestBuf's completion-plus-publication +path is 12-20% cheaper than EventBuf's accepted queued publication in this +reference build. That difference reflects distinct overload contracts, not +interchangeability: LatestBuf replaces unread state, while EventBuf preserves +queue order and rejects when full. + +## Raw LatestBuf block transport + +These regions start with an already-complete block. They isolate the generic +transport from builder completion and include the consumer side needed to +state the full composition cost. + +| Width | N | Publish first | Publish replacement | Take pending | Take empty | +|---:|---:|---:|---:|---:|---:| +| 2 | 8 | 102 | 100 | 133 | 96 | +| 2 | 32 | 432 | 430 | 272 | 135 | +| 2 | 128 | 1,206 | 1,204 | 584 | 291 | +| 8 | 8 | 347 | 343 | 282 | 143 | +| 8 | 32 | 857 | 853 | 594 | 299 | +| 8 | 128 | 2,969 | 2,965 | 1,843 | 923 | +| 16 | 8 | 423 | 422 | 387 | 192 | +| 16 | 32 | 1,238 | 1,237 | 1,011 | 504 | +| 16 | 128 | 4,505 | 4,503 | 3,499 | 1,752 | + +The probe black-boxes the returned `Option>>`, so the +empty rows include the optimized caller-visible aggregate return path as well +as the channel's Acquire-load fast path. Their payload-size scaling is codegen +at that return boundary, not an atomic or payload read from a channel slot. As +in the standalone matrix, no path scales with lag, occupancy, or history. + +## RAM + +All channel images remain in `.bss`. `Combined` is one LatestBuf channel plus +one private BlockBuilder; applications may place the builder on a task stack +or in static RAM, but the bytes exist either way. + +| Width | N | Block bytes | LatestBuf channel | Builder | Combined | +|---:|---:|---:|---:|---:|---:| +| 2 | 8 | 24 | 108 | 28 | 136 | +| 2 | 32 | 72 | 252 | 76 | 328 | +| 2 | 128 | 264 | 828 | 268 | 1,096 | +| 8 | 8 | 72 | 264 | 80 | 344 | +| 8 | 32 | 264 | 840 | 272 | 1,112 | +| 8 | 128 | 1,032 | 3,144 | 1,040 | 4,184 | +| 16 | 8 | 136 | 456 | 144 | 600 | +| 16 | 32 | 520 | 1,608 | 528 | 2,136 | +| 16 | 128 | 2,056 | 6,216 | 2,064 | 8,280 | + +For comparison, individual-sample LatestBuf channels are 48, 72, and 96 bytes +for the 2-, 8-, and 16-byte sample types. + +## Code size across all targets + +Each `P/T` sample entry is one `publish` / `take_latest` monomorph. Each `C/T` +block entry is final builder completion plus publication / `take_latest`. +Xtensa totals include `.text` and `.literal`. + +### 2-byte samples + +| Target | Sample P/T | N=8 C/T | N=32 C/T | N=128 C/T | +|---|---:|---:|---:|---:| +| thumbv6m | 78 / 98 | 192 / 110 | 210 / 110 | 250 / 124 | +| thumbv8m.base | 80 / 102 | 192 / 114 | 200 / 114 | 228 / 132 | +| thumbv7m | 76 / 90 | 188 / 124 | 182 / 96 | 188 / 102 | +| thumbv7em | 76 / 90 | 188 / 124 | 182 / 96 | 188 / 102 | +| thumbv8m.main | 76 / 88 | 180 / 120 | 178 / 86 | 184 / 92 | +| armv7r | 116 / 160 | 284 / 172 | 304 / 176 | 304 / 180 | +| armv7a | 116 / 160 | 284 / 172 | 304 / 176 | 304 / 180 | +| riscv32imac | 76 / 112 | 200 / 116 | 200 / 102 | 218 / 106 | +| ESP32 | 102 / 141 | 233 / 156 | 276 / 159 | 280 / 161 | +| ESP32-S2 | 80 / 116 | 215 / 131 | 254 / 134 | 258 / 136 | +| ESP32-S3 | 102 / 141 | 233 / 156 | 276 / 159 | 280 / 161 | + +### 8-byte samples + +| Target | Sample P/T | N=8 C/T | N=32 C/T | N=128 C/T | +|---|---:|---:|---:|---:| +| thumbv6m | 80 / 118 | 242 / 130 | 274 / 146 | 276 / 148 | +| thumbv8m.base | 82 / 118 | 238 / 134 | 274 / 154 | 280 / 156 | +| thumbv7m | 78 / 102 | 202 / 108 | 202 / 112 | 230 / 116 | +| thumbv7em | 78 / 102 | 202 / 108 | 202 / 112 | 230 / 116 | +| thumbv8m.main | 78 / 100 | 202 / 104 | 202 / 108 | 230 / 112 | +| armv7r | 128 / 176 | 336 / 196 | 328 / 196 | 312 / 196 | +| armv7a | 128 / 176 | 336 / 196 | 328 / 196 | 312 / 196 | +| riscv32imac | 80 / 120 | 246 / 132 | 264 / 136 | 258 / 142 | +| ESP32 | 106 / 154 | 287 / 171 | 291 / 175 | 346 / 214 | +| ESP32-S2 | 85 / 133 | 266 / 150 | 268 / 152 | 315 / 185 | +| ESP32-S3 | 106 / 154 | 287 / 171 | 291 / 175 | 346 / 214 | + +### 16-byte samples + +| Target | Sample P/T | N=8 C/T | N=32 C/T | N=128 C/T | +|---|---:|---:|---:|---:| +| thumbv6m | 98 / 134 | 262 / 142 | 272 / 146 | 276 / 148 | +| thumbv8m.base | 100 / 130 | 270 / 148 | 278 / 154 | 284 / 156 | +| thumbv7m | 114 / 120 | 214 / 110 | 216 / 112 | 260 / 144 | +| thumbv7em | 114 / 120 | 214 / 110 | 216 / 112 | 260 / 144 | +| thumbv8m.main | 102 / 118 | 210 / 106 | 212 / 108 | 252 / 134 | +| armv7r | 128 / 192 | 312 / 196 | 312 / 196 | 332 / 204 | +| armv7a | 128 / 192 | 312 / 196 | 312 / 196 | 332 / 204 | +| riscv32imac | 102 / 140 | 248 / 136 | 250 / 136 | 300 / 164 | +| ESP32 | 124 / 168 | 311 / 175 | 320 / 179 | 383 / 226 | +| ESP32-S2 | 103 / 147 | 286 / 152 | 299 / 160 | 354 / 197 | +| ESP32-S3 | 124 / 168 | 311 / 175 | 320 / 179 | 383 / 226 | + +The constrained kill rows do not show a code-size outlier: thumbv6m complete +publication is 192-276 bytes and ESP32-S2 is 215-354 bytes across the grid. +As in the earlier matrices, non-monotonic rows come from shared copy routines +and inlining choices, which is why retired instructions and RAM are reported +separately. + +## Result for D3 and the remaining decisions + +The evidence supports the coupled lanes' existing D3 recommendation: +`LatestBuf` remains payload-agnostic, and sample versus complete block is an +application release-scheduling and RAM choice. Nothing in the grid establishes +a distinct latest-block synchronization or accounting contract, so the +measurement does not justify a `LatestBlockBuf` primitive. + +That is a recommendation for maintainer closure, not an API decision taken by +the probe. D1, D2, D3, and A.3 still require the recorded maintainer closure. +The BlockBuf P/S decision also remains budget-gated: this matrix supplies the +LatestBuf-side rows and explicit 136-8,280-byte combined RAM range, but it does +not invent the named target instruction/time budget or RAM envelope that #28 +requires before choosing Copy composition or SlotPool. diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index 8d358c0..1a22072 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -3,9 +3,12 @@ - **Purpose:** implementation-independent comparison record for issue #27. - **Inputs:** [`latest-buf.md`](latest-buf.md), [`latest-buf-contract.md`](latest-buf-contract.md), and the BlockBuf candidate. -- **Status:** live evaluation record. Soundness, target/payload cost, A.1, and - channel-state layout evidence now exist; D3 and maintainer closure remain. -- **Measurements:** [`latest-buf-measurements.md`](latest-buf-measurements.md). +- **Status:** decision ready. Soundness, target/payload cost, A.1, + channel-state layout, and joint D3 composition evidence now exist; + maintainer closure remains. +- **Measurements:** [`latest-buf-measurements.md`](latest-buf-measurements.md) + and + [`latest-block-composition-measurements.md`](latest-block-composition-measurements.md). ## 1. Decisions sufficient for an evaluable prototype @@ -25,6 +28,13 @@ returned by `EventBuf::push`, not a third synchronization primitive. A named type earns its place only through a distinct synchronization or accounting contract. +The completed D3 matrix supports that default across 2/8/16-byte samples and +`N = 8/32/128`: batching crosses from more expensive to cheaper depending on +shape, but every row is the same generic LatestBuf transport over a different +payload. No row establishes a separate latest-block contract. Final builder +completion plus LatestBuf publication costs 171-6,958 reference instructions, +with combined channel/builder RAM of 136-8,280 bytes. + ## 2. State-persistence comparison Both candidates use the same three exclusive roles and a single encoded diff --git a/docs/proposals/latest-buf-measurements.md b/docs/proposals/latest-buf-measurements.md index c3987ea..5a05d4f 100644 --- a/docs/proposals/latest-buf-measurements.md +++ b/docs/proposals/latest-buf-measurements.md @@ -12,8 +12,8 @@ This is the cost evidence requested by issue #27 and [latest-buf-evaluation.md](latest-buf-evaluation.md) section 6. The 128-byte array measures the transport's complete-payload scaling without importing the -BlockBuf candidate. It is not the joint `Block` composition campaign, -which remains separately coupled to issue #28. +BlockBuf candidate. The subsequent joint `Block` campaign is recorded in +[`latest-block-composition-measurements.md`](latest-block-composition-measurements.md). Reproduce the final implementation with: @@ -121,13 +121,13 @@ pending take, and zero to an empty take. That bounded hot-path cost removes a payload-proportional flash and startup-copy cost, so the encoded `.bss` layout is retained. -## Result and remaining gate +## Result and follow-on The soundness-stage implementation now has the requested target/payload code size, pinned instruction regions, A.1 comparison, and explicit handle/channel RAM record. Neither constrained kill row shows a code-size outlier, and idle polling no longer enters a portable-atomic critical section. -This does not close D3. Joint sample-versus-`Block` composition numbers -against the BlockBuf lane remain the next development item, followed by -maintainer closure of D1-D3 and A.3. +The joint sample-versus-`Block` composition follow-on is now complete in +the linked record. Development gates 1-3 are therefore complete; maintainer +closure of D1-D3 and A.3 remains. diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 2b06e74..53469d6 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -29,6 +29,7 @@ # ./scripts/codesize.sh # baseline API, upstream targets # ./scripts/codesize.sh split # also measure try_split, where present # ./scripts/codesize.sh latest-matrix # LatestBuf target/payload matrix +# ./scripts/codesize.sh latest-block-matrix # LatestBuf sample/block D3 matrix # XTENSA=1 ./scripts/codesize.sh # add ESP32 rows (needs esp-rs fork) # # Reading the output: each number is the byte size of one function, so it @@ -48,6 +49,7 @@ export CARGO_INCREMENTAL PROBE_FEATURES="" BLESS=0 LATEST_MATRIX=0 +LATEST_BLOCK_MATRIX=0 for arg in "$@"; do case "$arg" in split) PROBE_FEATURES="split" ;; @@ -55,6 +57,10 @@ for arg in "$@"; do PROBE_FEATURES="latest-matrix" LATEST_MATRIX=1 ;; + latest-block-matrix) + PROBE_FEATURES="latest-block-matrix" + LATEST_BLOCK_MATRIX=1 + ;; --bless) BLESS=1 ;; # Not 2: that is reserved for "could not run", which ci.sh maps to SKIP. *) printf 'unknown argument: %s @@ -126,11 +132,17 @@ section_size() { RESULTS="$(mktemp)" trap 'rm -f "$RESULTS"' EXIT -if [ "$LATEST_MATRIX" = "1" ]; then - printf '%-30s %-10s %10s %10s %10s %6s\n' \ +if [ "$LATEST_BLOCK_MATRIX" = "1" ]; then + printf '%-30s %-16s %10s %10s %10s %10s %10s %6s\n' \ + TARGET PAYLOAD publish_B take_B complete_B channel_B builder_B init + printf '%-30s %-16s %10s %10s %10s %10s %10s %6s\n' \ + '------------------------------' '----------------' '---------' '------' \ + '----------' '---------' '---------' '----' +elif [ "$LATEST_MATRIX" = "1" ]; then + printf '%-30s %-16s %10s %10s %10s %6s\n' \ TARGET PAYLOAD publish_B take_B channel_B init - printf '%-30s %-10s %10s %10s %10s %6s\n' \ - '------------------------------' '----------' '---------' '------' '---------' '----' + printf '%-30s %-16s %10s %10s %10s %6s\n' \ + '------------------------------' '----------------' '---------' '------' '---------' '----' else printf '%-30s %10s %8s %8s %6s\n' TARGET two_calls split bss data printf '%-30s %10s %8s %8s %6s\n' '------------------------------' '---------' '-----' '---' '----' @@ -228,6 +240,46 @@ for entry in $TARGETS; do continue fi + if [ "$LATEST_BLOCK_MATRIX" = "1" ]; then + for shape in sample_w2 sample_w8 sample_w16 block_w2_n8 block_w2_n32 block_w2_n128 block_w8_n8 block_w8_n32 block_w8_n128 block_w16_n8 block_w16_n32 block_w16_n128; do + publish_fn="latest_${shape}_publish" + take_fn="latest_${shape}_take" + static_name="$(printf 'LATEST_%s_BUF' "$shape" | tr '[:lower:]' '[:upper:]')" + publish="$(fn_size "$ar" "$publish_fn")" + take="$(fn_size "$ar" "$take_fn")" + complete='-' + builder='-' + case "$shape" in + block_*) + block_shape="${shape#block_}" + publish='-' + complete="$(fn_size "$ar" "latest_complete_${block_shape}_publish")" + builder_name="$(printf 'LATEST_BUILDER_%s' "$block_shape" | tr '[:lower:]' '[:upper:]')" + builder="$(section_size "$ar" ".bss.$builder_name")" + [ -z "$builder" ] && builder="$(section_size "$ar" ".rodata.$builder_name")" + [ -z "$complete" ] && matrix_missing=$((matrix_missing + 1)) + [ -z "$builder" ] && matrix_missing=$((matrix_missing + 1)) + ;; + esac + channel_data="$(section_size "$ar" ".data.$static_name")" + channel_bss="$(section_size "$ar" ".bss.$static_name")" + if [ -n "$channel_data" ]; then + channel="$channel_data" + init=data + else + channel="$channel_bss" + init=bss + fi + [ -z "$publish" ] && matrix_missing=$((matrix_missing + 1)) + [ -z "$take" ] && matrix_missing=$((matrix_missing + 1)) + [ -z "$channel" ] && matrix_missing=$((matrix_missing + 1)) + printf '%-30s %-16s %10s %10s %10s %10s %10s %6s\n' \ + "$target" "$shape" "${publish:--}" "${take:--}" \ + "${complete:--}" "${channel:--}" "${builder:--}" "${init:--}" + done + continue + fi + two="$(fn_size "$ar" bringup_two_calls)" spl="$(fn_size "$ar" bringup_split)" bss="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.bss\..*3BUF/ { print $2; exit }')" @@ -251,20 +303,29 @@ if [ "$skipped" -gt 0 ]; then fi [ "$failed" -gt 0 ] && printf '%s target(s) failed to build.\n\n' "$failed" -if [ "$LATEST_MATRIX" = "1" ]; then +if [ "$LATEST_MATRIX" = "1" ] || [ "$LATEST_BLOCK_MATRIX" = "1" ]; then if [ "$matrix_missing" -gt 0 ]; then - printf '%s latest-matrix section(s) had no code-size measurement.\n' \ + printf '%s LatestBuf matrix section(s) had no code-size measurement.\n' \ "$matrix_missing" >&2 exit 1 fi [ "$failed" -gt 0 ] && exit 1 [ "$skipped" -gt 0 ] && exit 2 printf 'publish_B and take_B are emitted flash for one operation monomorph.\n' - printf 'The roles row reports producer and consumer claim+release code size.\n' + if [ "$LATEST_MATRIX" = "1" ]; then + printf 'The roles row reports producer and consumer claim+release code size.\n' + else + printf 'complete_B adds the final BlockBuilder push to block publication.\n' + printf 'Block and builder rows use the exact shapes from #28 at bc54a9a.\n' + fi printf 'channel_B is target-object RAM for three slots plus channel state.\n' printf 'init reports whether that const-initialized image is .data or .bss;\n' printf '.data also occupies flash and is copied during startup.\n' - printf 'Run scripts/cycles.sh latest-matrix for state-dependent paths.\n' + if [ "$LATEST_MATRIX" = "1" ]; then + printf 'Run scripts/cycles.sh latest-matrix for state-dependent paths.\n' + else + printf 'Run scripts/cycles.sh latest-block-matrix for state-dependent paths.\n' + fi exit 0 fi # --------------------------------------------------------------------------- diff --git a/scripts/codesize/Cargo.toml b/scripts/codesize/Cargo.toml index f699b81..433ca3e 100644 --- a/scripts/codesize/Cargo.toml +++ b/scripts/codesize/Cargo.toml @@ -29,6 +29,8 @@ cm0 = ["ph-eventing/portable-atomic-unsafe-assume-single-core"] split = [] # Enable the LatestBuf operation and payload-size matrix. latest-matrix = [] +# Enable the cross-lane LatestBuf sample/block composition matrix. +latest-block-matrix = [] [profile.release] # Match what an embedded consumer would realistically ship. diff --git a/scripts/codesize/src/lib.rs b/scripts/codesize/src/lib.rs index 343be79..a6beecf 100644 --- a/scripts/codesize/src/lib.rs +++ b/scripts/codesize/src/lib.rs @@ -5,14 +5,25 @@ //! Run via `scripts/codesize.sh`, not directly. #![no_std] +#[cfg(feature = "latest-block-matrix")] +use core::mem::MaybeUninit; use core::panic::PanicInfo; use ph_eventing::EventBuf; -#[cfg(feature = "latest-matrix")] +#[cfg(any(feature = "latest-matrix", feature = "latest-block-matrix"))] use ph_eventing::{ LatestBuf, LatestItem, PublishReport, latest_buf::{Consumer as LatestConsumer, Producer as LatestProducer}, }; +#[cfg(feature = "latest-block-matrix")] +#[path = "../../probes/block_shape.rs"] +pub mod block_probe; +#[cfg(feature = "latest-block-matrix")] +use block_probe::{BlockBuilderShape, BlockShape}; + +#[cfg(all(feature = "latest-matrix", feature = "latest-block-matrix"))] +compile_error!("latest-matrix and latest-block-matrix are mutually exclusive probes"); + #[panic_handler] fn panic(_: &PanicInfo) -> ! { loop {} @@ -78,10 +89,69 @@ pub static LATEST_W16_BUF: LatestBuf<[u32; 4]> = LatestBuf::new(); #[unsafe(no_mangle)] pub static LATEST_BLOCK_BUF: LatestBuf<[u32; 32]> = LatestBuf::new(); +#[cfg(feature = "latest-block-matrix")] +macro_rules! latest_composition_static { + ($name:ident, $payload:ty) => { + #[unsafe(no_mangle)] + pub static $name: LatestBuf<$payload> = LatestBuf::new(); + }; +} + +#[cfg(feature = "latest-block-matrix")] +macro_rules! latest_builder_static { + ($name:ident, $payload:ty) => { + #[unsafe(no_mangle)] + pub static $name: MaybeUninit<$payload> = MaybeUninit::uninit(); + }; +} + +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_SAMPLE_W2_BUF, u16); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_SAMPLE_W8_BUF, u64); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_SAMPLE_W16_BUF, [u64; 2]); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W2_N8_BUF, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W2_N32_BUF, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W2_N128_BUF, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W8_N8_BUF, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W8_N32_BUF, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W8_N128_BUF, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W16_N8_BUF, BlockShape<[u64; 2], 8>); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W16_N32_BUF, BlockShape<[u64; 2], 32>); +#[cfg(feature = "latest-block-matrix")] +latest_composition_static!(LATEST_BLOCK_W16_N128_BUF, BlockShape<[u64; 2], 128>); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W2_N8, BlockBuilderShape); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W2_N32, BlockBuilderShape); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W2_N128, BlockBuilderShape); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W8_N8, BlockBuilderShape); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W8_N32, BlockBuilderShape); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W8_N128, BlockBuilderShape); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W16_N8, BlockBuilderShape<[u64; 2], 8>); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W16_N32, BlockBuilderShape<[u64; 2], 32>); +#[cfg(feature = "latest-block-matrix")] +latest_builder_static!(LATEST_BUILDER_W16_N128, BlockBuilderShape<[u64; 2], 128>); + // Code size does not depend on whether the runtime path is first/replacement // publication or empty/pending take, so each payload needs one publish and one // take monomorph. The cycle probe separates those runtime paths. -#[cfg(feature = "latest-matrix")] +#[cfg(any(feature = "latest-matrix", feature = "latest-block-matrix"))] macro_rules! latest_operation_probe { ($publish:ident, $take:ident, $payload:ty) => { #[unsafe(no_mangle)] @@ -103,6 +173,78 @@ latest_operation_probe!(latest_w16_publish, latest_w16_take, [u32; 4]); #[cfg(feature = "latest-matrix")] latest_operation_probe!(latest_block_publish, latest_block_take, [u32; 32]); +#[cfg(feature = "latest-block-matrix")] +latest_operation_probe!(latest_sample_w2_publish, latest_sample_w2_take, u16); +#[cfg(feature = "latest-block-matrix")] +latest_operation_probe!(latest_sample_w8_publish, latest_sample_w8_take, u64); +#[cfg(feature = "latest-block-matrix")] +latest_operation_probe!(latest_sample_w16_publish, latest_sample_w16_take, [u64; 2]); +#[cfg(feature = "latest-block-matrix")] +macro_rules! latest_take_probe { + ($take:ident, $payload:ty) => { + #[unsafe(no_mangle)] + pub fn $take(consumer: &LatestConsumer<'_, $payload>) -> Option> { + consumer.take_latest() + } + }; +} + +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w2_n8_take, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w2_n32_take, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w2_n128_take, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w8_n8_take, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w8_n32_take, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w8_n128_take, BlockShape); +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w16_n8_take, BlockShape<[u64; 2], 8>); +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w16_n32_take, BlockShape<[u64; 2], 32>); +#[cfg(feature = "latest-block-matrix")] +latest_take_probe!(latest_block_w16_n128_take, BlockShape<[u64; 2], 128>); + +#[cfg(feature = "latest-block-matrix")] +macro_rules! latest_complete_probe { + ($name:ident, $sample:ty, $n:literal) => { + #[unsafe(no_mangle)] + pub fn $name( + fill: &mut BlockBuilderShape<$sample, $n>, + producer: &LatestProducer<'_, BlockShape<$sample, $n>>, + sequence: u32, + sample: $sample, + ) -> bool { + match fill.push(sequence, sample) { + Ok(Some(block)) => producer.publish(block).replaced_unread, + Ok(None) | Err(_) => false, + } + } + }; +} + +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w2_n8_publish, u16, 8); +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w2_n32_publish, u16, 32); +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w2_n128_publish, u16, 128); +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w8_n8_publish, u64, 8); +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w8_n32_publish, u64, 32); +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w8_n128_publish, u64, 128); +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w16_n8_publish, [u64; 2], 8); +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w16_n32_publish, [u64; 2], 32); +#[cfg(feature = "latest-block-matrix")] +latest_complete_probe!(latest_complete_w16_n128_publish, [u64; 2], 128); + // Role acquisition and release are payload-independent. Keep producer and // consumer paths separate so a future state-persistence candidate can be // compared without changing the runner. diff --git a/scripts/cycles.sh b/scripts/cycles.sh index a63ca00..82cb8ed 100755 --- a/scripts/cycles.sh +++ b/scripts/cycles.sh @@ -39,6 +39,7 @@ # Usage: # ./scripts/cycles.sh # local qemu-system-arm # ./scripts/cycles.sh latest-matrix +# ./scripts/cycles.sh latest-block-matrix # ./scripts/verify.sh cycles # same, inside the reference image set -u @@ -52,6 +53,7 @@ PROBE_FEATURES="" for arg in "$@"; do case "$arg" in latest-matrix) PROBE_FEATURES="latest-matrix" ;; + latest-block-matrix) PROBE_FEATURES="latest-block-matrix" ;; *) printf 'unknown argument: %s\n' "$arg" >&2; exit 64 ;; esac done @@ -211,6 +213,7 @@ report="$(awk ' else if (group == "sr") printf "\nSeqRing (overwrite SPSC)\n" else if (group == "rb") printf "\nRingBuf (single owner)\n" else if (group == "lb") printf "\nLatestBuf (freshness-first SPSC)\n" + else if (group == "lc") printf "\nLatestBuf sample/block composition\n" } } next diff --git a/scripts/cycles/Cargo.toml b/scripts/cycles/Cargo.toml index 7cc5066..4dadb87 100644 --- a/scripts/cycles/Cargo.toml +++ b/scripts/cycles/Cargo.toml @@ -19,6 +19,8 @@ cortex-m-semihosting = "0.5" # Keep the larger LatestBuf payload matrix out of the standing probe so its # stack use and marker set remain independent. latest-matrix = [] +# Keep the larger cross-lane sample/block matrix isolated as well. +latest-block-matrix = [] [profile.release] opt-level = "z" diff --git a/scripts/cycles/src/main.rs b/scripts/cycles/src/main.rs index c773923..e4df7a5 100644 --- a/scripts/cycles/src/main.rs +++ b/scripts/cycles/src/main.rs @@ -26,11 +26,20 @@ use core::hint::black_box; use cortex_m_rt::entry; use cortex_m_semihosting::debug; -#[cfg(feature = "latest-matrix")] +#[cfg(any(feature = "latest-matrix", feature = "latest-block-matrix"))] use ph_eventing::LatestBuf; -#[cfg(not(feature = "latest-matrix"))] +#[cfg(not(any(feature = "latest-matrix", feature = "latest-block-matrix")))] use ph_eventing::{EventBuf, RingBuf, SeqRing}; +#[cfg(feature = "latest-block-matrix")] +#[path = "../../probes/block_shape.rs"] +mod block_probe; +#[cfg(feature = "latest-block-matrix")] +use block_probe::{BlockBuilderShape, BlockShape}; + +#[cfg(all(feature = "latest-matrix", feature = "latest-block-matrix"))] +compile_error!("latest-matrix and latest-block-matrix are mutually exclusive probes"); + #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { debug::exit(debug::EXIT_FAILURE); @@ -43,7 +52,9 @@ fn panic(_: &core::panic::PanicInfo) -> ! { /// identical `nop`-only bodies the linker folded all nineteen markers onto a /// single address, the runner saw one label, and the output was silently empty. /// `r12` is call-clobbered under AAPCS, so writing it in a `-> ()` function is -/// free and harmless. +/// free and harmless. The assembly deliberately does not claim `nomem`: each +/// marker is also a compiler memory barrier, preventing setup for an adjacent +/// payload from being hoisted into the measured region. macro_rules! markers { ($($idx:expr => $name:ident),* $(,)?) => { $( @@ -54,7 +65,7 @@ macro_rules! markers { core::arch::asm!( "mov r12, {tag}", tag = const $idx, - options(nomem, nostack, preserves_flags) + options(nostack, preserves_flags) ) }; } @@ -62,7 +73,7 @@ macro_rules! markers { }; } -#[cfg(not(feature = "latest-matrix"))] +#[cfg(not(any(feature = "latest-matrix", feature = "latest-block-matrix")))] markers! { 0 => m_end, 1 => m_overhead, @@ -89,6 +100,80 @@ markers! { 19 => m_sr_poll_lagged_far, } +#[cfg(feature = "latest-block-matrix")] +markers! { + 0 => m_end, + 1 => m_overhead, + // D3 composition rows: sample width in bytes, then complete Block shape. + 50 => m_lc_sample_w2_publish_first, + 51 => m_lc_sample_w2_publish_replace, + 52 => m_lc_sample_w2_take_pending, + 53 => m_lc_sample_w2_take_empty, + 54 => m_lc_sample_w8_publish_first, + 55 => m_lc_sample_w8_publish_replace, + 56 => m_lc_sample_w8_take_pending, + 57 => m_lc_sample_w8_take_empty, + 58 => m_lc_sample_w16_publish_first, + 59 => m_lc_sample_w16_publish_replace, + 60 => m_lc_sample_w16_take_pending, + 61 => m_lc_sample_w16_take_empty, + 62 => m_lc_block_w2_n8_publish_first, + 63 => m_lc_block_w2_n8_publish_replace, + 64 => m_lc_block_w2_n8_take_pending, + 65 => m_lc_block_w2_n8_take_empty, + 66 => m_lc_block_w2_n32_publish_first, + 67 => m_lc_block_w2_n32_publish_replace, + 68 => m_lc_block_w2_n32_take_pending, + 69 => m_lc_block_w2_n32_take_empty, + 70 => m_lc_block_w2_n128_publish_first, + 71 => m_lc_block_w2_n128_publish_replace, + 72 => m_lc_block_w2_n128_take_pending, + 73 => m_lc_block_w2_n128_take_empty, + 74 => m_lc_block_w8_n8_publish_first, + 75 => m_lc_block_w8_n8_publish_replace, + 76 => m_lc_block_w8_n8_take_pending, + 77 => m_lc_block_w8_n8_take_empty, + 78 => m_lc_block_w8_n32_publish_first, + 79 => m_lc_block_w8_n32_publish_replace, + 80 => m_lc_block_w8_n32_take_pending, + 81 => m_lc_block_w8_n32_take_empty, + 82 => m_lc_block_w8_n128_publish_first, + 83 => m_lc_block_w8_n128_publish_replace, + 84 => m_lc_block_w8_n128_take_pending, + 85 => m_lc_block_w8_n128_take_empty, + 86 => m_lc_block_w16_n8_publish_first, + 87 => m_lc_block_w16_n8_publish_replace, + 88 => m_lc_block_w16_n8_take_pending, + 89 => m_lc_block_w16_n8_take_empty, + 90 => m_lc_block_w16_n32_publish_first, + 91 => m_lc_block_w16_n32_publish_replace, + 92 => m_lc_block_w16_n32_take_pending, + 93 => m_lc_block_w16_n32_take_empty, + 94 => m_lc_block_w16_n128_publish_first, + 95 => m_lc_block_w16_n128_publish_replace, + 96 => m_lc_block_w16_n128_take_pending, + 97 => m_lc_block_w16_n128_take_empty, + // Final BlockBuilder push + LatestBuf publication, matching #28's boundary. + 100 => m_lc_complete_w2_n8_publish_first, + 101 => m_lc_complete_w2_n8_publish_replace, + 102 => m_lc_complete_w2_n32_publish_first, + 103 => m_lc_complete_w2_n32_publish_replace, + 104 => m_lc_complete_w2_n128_publish_first, + 105 => m_lc_complete_w2_n128_publish_replace, + 106 => m_lc_complete_w8_n8_publish_first, + 107 => m_lc_complete_w8_n8_publish_replace, + 108 => m_lc_complete_w8_n32_publish_first, + 109 => m_lc_complete_w8_n32_publish_replace, + 110 => m_lc_complete_w8_n128_publish_first, + 111 => m_lc_complete_w8_n128_publish_replace, + 112 => m_lc_complete_w16_n8_publish_first, + 113 => m_lc_complete_w16_n8_publish_replace, + 114 => m_lc_complete_w16_n32_publish_first, + 115 => m_lc_complete_w16_n32_publish_replace, + 116 => m_lc_complete_w16_n128_publish_first, + 117 => m_lc_complete_w16_n128_publish_replace, +} + #[cfg(feature = "latest-matrix")] markers! { 0 => m_end, @@ -113,7 +198,7 @@ markers! { 35 => m_lb_consumer_reacquire, } -#[cfg(not(feature = "latest-matrix"))] +#[cfg(not(any(feature = "latest-matrix", feature = "latest-block-matrix")))] fn event_buf_costs() { let buf = EventBuf::::new(); let tx = buf.try_producer().expect("producer"); @@ -154,7 +239,7 @@ fn event_buf_costs() { m_end(); } -#[cfg(not(feature = "latest-matrix"))] +#[cfg(not(any(feature = "latest-matrix", feature = "latest-block-matrix")))] fn seq_ring_costs() { let ring = SeqRing::::new(); let tx = ring.try_producer().expect("producer"); @@ -203,7 +288,7 @@ fn seq_ring_costs() { m_end(); } -#[cfg(not(feature = "latest-matrix"))] +#[cfg(not(any(feature = "latest-matrix", feature = "latest-block-matrix")))] fn ring_buf_costs() { let mut ring = RingBuf::::new(); @@ -228,7 +313,7 @@ fn ring_buf_costs() { m_end(); } -#[cfg(feature = "latest-matrix")] +#[cfg(any(feature = "latest-matrix", feature = "latest-block-matrix"))] macro_rules! measure_latest_payload { ( $payload:ty, @@ -261,6 +346,220 @@ macro_rules! measure_latest_payload { }}; } +#[cfg(feature = "latest-block-matrix")] +macro_rules! measure_complete_publication { + ( + $sample:ty, + $n:literal, + $value:expr, + $publish_first:ident, + $publish_replace:ident + ) => {{ + let channel = LatestBuf::>::new(); + let producer = channel.try_producer().expect("producer"); + + let mut first_fill = BlockBuilderShape::<$sample, $n>::new(); + for sequence in 1..$n { + let _ = first_fill.push(sequence as u32, black_box($value)); + } + $publish_first(); + let block = first_fill + .push($n as u32, black_box($value)) + .expect("contiguous") + .expect("complete"); + let _ = black_box(producer.publish(block)); + m_end(); + + let mut replacement_fill = BlockBuilderShape::<$sample, $n>::new(); + for sequence in 1..$n { + let _ = replacement_fill.push(sequence as u32, black_box($value)); + } + $publish_replace(); + let block = replacement_fill + .push($n as u32, black_box($value)) + .expect("contiguous") + .expect("complete"); + let _ = black_box(producer.publish(block)); + m_end(); + }}; +} + +#[cfg(feature = "latest-block-matrix")] +fn latest_block_composition_costs() { + measure_latest_payload!( + u16, + 1_u16, + 2_u16, + m_lc_sample_w2_publish_first, + m_lc_sample_w2_publish_replace, + m_lc_sample_w2_take_pending, + m_lc_sample_w2_take_empty + ); + measure_latest_payload!( + u64, + 1_u64, + 2_u64, + m_lc_sample_w8_publish_first, + m_lc_sample_w8_publish_replace, + m_lc_sample_w8_take_pending, + m_lc_sample_w8_take_empty + ); + measure_latest_payload!( + [u64; 2], + [1_u64; 2], + [2_u64; 2], + m_lc_sample_w16_publish_first, + m_lc_sample_w16_publish_replace, + m_lc_sample_w16_take_pending, + m_lc_sample_w16_take_empty + ); + measure_latest_payload!( + BlockShape, + BlockShape::filled(1_u16), + BlockShape::filled(2_u16), + m_lc_block_w2_n8_publish_first, + m_lc_block_w2_n8_publish_replace, + m_lc_block_w2_n8_take_pending, + m_lc_block_w2_n8_take_empty + ); + measure_latest_payload!( + BlockShape, + BlockShape::filled(1_u16), + BlockShape::filled(2_u16), + m_lc_block_w2_n32_publish_first, + m_lc_block_w2_n32_publish_replace, + m_lc_block_w2_n32_take_pending, + m_lc_block_w2_n32_take_empty + ); + measure_latest_payload!( + BlockShape, + BlockShape::filled(1_u16), + BlockShape::filled(2_u16), + m_lc_block_w2_n128_publish_first, + m_lc_block_w2_n128_publish_replace, + m_lc_block_w2_n128_take_pending, + m_lc_block_w2_n128_take_empty + ); + measure_latest_payload!( + BlockShape, + BlockShape::filled(1_u64), + BlockShape::filled(2_u64), + m_lc_block_w8_n8_publish_first, + m_lc_block_w8_n8_publish_replace, + m_lc_block_w8_n8_take_pending, + m_lc_block_w8_n8_take_empty + ); + measure_latest_payload!( + BlockShape, + BlockShape::filled(1_u64), + BlockShape::filled(2_u64), + m_lc_block_w8_n32_publish_first, + m_lc_block_w8_n32_publish_replace, + m_lc_block_w8_n32_take_pending, + m_lc_block_w8_n32_take_empty + ); + measure_latest_payload!( + BlockShape, + BlockShape::filled(1_u64), + BlockShape::filled(2_u64), + m_lc_block_w8_n128_publish_first, + m_lc_block_w8_n128_publish_replace, + m_lc_block_w8_n128_take_pending, + m_lc_block_w8_n128_take_empty + ); + measure_latest_payload!( + BlockShape<[u64; 2], 8>, + BlockShape::filled([1_u64; 2]), + BlockShape::filled([2_u64; 2]), + m_lc_block_w16_n8_publish_first, + m_lc_block_w16_n8_publish_replace, + m_lc_block_w16_n8_take_pending, + m_lc_block_w16_n8_take_empty + ); + measure_latest_payload!( + BlockShape<[u64; 2], 32>, + BlockShape::filled([1_u64; 2]), + BlockShape::filled([2_u64; 2]), + m_lc_block_w16_n32_publish_first, + m_lc_block_w16_n32_publish_replace, + m_lc_block_w16_n32_take_pending, + m_lc_block_w16_n32_take_empty + ); + measure_latest_payload!( + BlockShape<[u64; 2], 128>, + BlockShape::filled([1_u64; 2]), + BlockShape::filled([2_u64; 2]), + m_lc_block_w16_n128_publish_first, + m_lc_block_w16_n128_publish_replace, + m_lc_block_w16_n128_take_pending, + m_lc_block_w16_n128_take_empty + ); + + measure_complete_publication!( + u16, + 8, + 1_u16, + m_lc_complete_w2_n8_publish_first, + m_lc_complete_w2_n8_publish_replace + ); + measure_complete_publication!( + u16, + 32, + 1_u16, + m_lc_complete_w2_n32_publish_first, + m_lc_complete_w2_n32_publish_replace + ); + measure_complete_publication!( + u16, + 128, + 1_u16, + m_lc_complete_w2_n128_publish_first, + m_lc_complete_w2_n128_publish_replace + ); + measure_complete_publication!( + u64, + 8, + 1_u64, + m_lc_complete_w8_n8_publish_first, + m_lc_complete_w8_n8_publish_replace + ); + measure_complete_publication!( + u64, + 32, + 1_u64, + m_lc_complete_w8_n32_publish_first, + m_lc_complete_w8_n32_publish_replace + ); + measure_complete_publication!( + u64, + 128, + 1_u64, + m_lc_complete_w8_n128_publish_first, + m_lc_complete_w8_n128_publish_replace + ); + measure_complete_publication!( + [u64; 2], + 8, + [1_u64; 2], + m_lc_complete_w16_n8_publish_first, + m_lc_complete_w16_n8_publish_replace + ); + measure_complete_publication!( + [u64; 2], + 32, + [1_u64; 2], + m_lc_complete_w16_n32_publish_first, + m_lc_complete_w16_n32_publish_replace + ); + measure_complete_publication!( + [u64; 2], + 128, + [1_u64; 2], + m_lc_complete_w16_n128_publish_first, + m_lc_complete_w16_n128_publish_replace + ); +} + #[cfg(feature = "latest-matrix")] fn latest_buf_costs() { measure_latest_payload!( @@ -321,7 +620,7 @@ fn main() -> ! { m_overhead(); m_end(); - #[cfg(not(feature = "latest-matrix"))] + #[cfg(not(any(feature = "latest-matrix", feature = "latest-block-matrix")))] { event_buf_costs(); seq_ring_costs(); @@ -329,6 +628,8 @@ fn main() -> ! { } #[cfg(feature = "latest-matrix")] latest_buf_costs(); + #[cfg(feature = "latest-block-matrix")] + latest_block_composition_costs(); debug::exit(debug::EXIT_SUCCESS); loop {} diff --git a/scripts/probes/block_shape.rs b/scripts/probes/block_shape.rs new file mode 100644 index 0000000..17200a3 --- /dev/null +++ b/scripts/probes/block_shape.rs @@ -0,0 +1,114 @@ +//! Probe-only structural twins of BlockBuf at `bc54a9a`. +//! +//! Candidate branches must not stack. Sharing this source between the code-size +//! and cycle probes keeps the measured `Block` layout and final +//! `BlockBuilder::push` path identical without importing the BlockBuf branch. + +#![allow(dead_code)] + +use core::mem::MaybeUninit; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BlockShape { + first_sequence: u32, + last_sequence: u32, + samples: [T; N], +} + +impl BlockShape { + pub const fn filled(sample: T) -> Self { + Self { + first_sequence: 1, + last_sequence: N as u32, + samples: [sample; N], + } + } +} + +pub struct BlockBuilderShape { + samples: [MaybeUninit; N], + len: usize, + first_sequence: u32, + last_sequence: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FillErrorShape { + ReservedSequence { + sample: T, + }, + Discontinuous { + expected: u32, + received: u32, + sample: T, + }, +} + +impl BlockBuilderShape { + pub const fn new() -> Self { + const { assert!(N > 0, "BlockBuilder capacity must be greater than zero") }; + Self { + samples: [const { MaybeUninit::uninit() }; N], + len: 0, + first_sequence: 0, + last_sequence: 0, + } + } + + pub fn push( + &mut self, + sequence: u32, + sample: T, + ) -> Result>, FillErrorShape> { + if sequence == 0 { + return Err(FillErrorShape::ReservedSequence { sample }); + } + + if self.len != 0 { + let expected = next_sequence(self.last_sequence); + if sequence != expected { + return Err(FillErrorShape::Discontinuous { + expected, + received: sequence, + sample, + }); + } + } else { + self.first_sequence = sequence; + } + + self.samples[self.len].write(sample); + self.len += 1; + self.last_sequence = sequence; + + if self.len != N { + return Ok(None); + } + + // SAFETY: `len == N`, and `len` advances only after the corresponding + // slot is written. `T: Copy`, so copying the initialized array out does + // not invalidate the backing `MaybeUninit` storage. + let samples = unsafe { self.samples.as_ptr().cast::<[T; N]>().read() }; + let block = BlockShape { + first_sequence: self.first_sequence, + last_sequence: self.last_sequence, + samples, + }; + self.clear(); + Ok(Some(block)) + } + + pub fn clear(&mut self) { + self.len = 0; + self.first_sequence = 0; + self.last_sequence = 0; + } +} + +const fn next_sequence(sequence: u32) -> u32 { + if sequence == u32::MAX { + 1 + } else { + sequence + 1 + } +} diff --git a/scripts/verify.sh b/scripts/verify.sh index 35a034e..b57071b 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -19,6 +19,7 @@ # ./scripts/verify.sh loom # ./scripts/verify.sh cycles # ./scripts/verify.sh cycles latest-matrix +# ./scripts/verify.sh cycles latest-block-matrix # ./scripts/verify.sh shell # interactive shell in the image # # Requires Docker. Everything else is inside the image. From f26d4c3ca528f8ede4cf4f5a6121f2940d06142d Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 13:10:44 -0400 Subject: [PATCH 12/87] Finalize CountedSignal promotion decision --- AGENTS.md | 8 +-- CHANGELOG.md | 10 ++-- README.md | 13 +++-- docs/0.3.0-candidates.md | 7 ++- docs/proposals/counted-signal-contract.md | 27 ++++++---- docs/proposals/counted-signal.md | 64 +++++++++++------------ src/counted_signal.rs | 5 ++ 7 files changed, 79 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b604f70..a270d37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ It ships four primitives: - **`RingBuf`** — a single-owner ring buffer (no atomics, `&mut` access). Ideal for local event logs, sample windows, and single-context collection. - **`SeqRing`** — a lock-free SPSC ring that **overwrites** old entries. Designed for high-rate telemetry where a fast producer and a potentially slower consumer run on different contexts. - **`EventBuf`** — a lock-free SPSC ring with **backpressure**. `push` returns `Err(val)` when full, so the producer always knows when delivery fails. -- **`CountedSignal`** — an exploratory saturating SPSC count for identical, +- **`CountedSignal`** — a saturating SPSC count for identical, payload-free events. The sole producer handle makes exact bounded saturation possible without a CAS retry loop. @@ -184,6 +184,8 @@ The `SeqRing` implementation uses careful atomic ordering for thread safety: ### Memory Ordering Strategy (CountedSignal) +- The semantic contract and stable clause IDs live in + `docs/proposals/counted-signal-contract.md`. - The sole producer performs a Relaxed load and, below `u32::MAX`, one Relaxed `fetch_add`. The consumer performs a Relaxed `swap(0)`. - Relaxed is sufficient because there is no payload publication; the atomic's @@ -674,8 +676,8 @@ measures the other half of the determinism claim — that the hot paths cost a Measured in the reference environment — `scripts/verify/Dockerfile` (rustc 1.92.0, Debian trixie qemu-system-arm 10.0.x, thumbv7m / Cortex-M3), reproduce with `./scripts/verify.sh cycles`. The counts are deterministic *per -environment*, not universal: a 10.2 QEMU build shifted two measured regions -regions by exactly one instruction (trace boundary attribution, not codegen). +environment*, not universal: a 10.2 QEMU build shifted two measured regions by +exactly one instruction (trace boundary attribution, not codegen). Cross-environment diffs of ±1 are noise; compare inside the image. | | empty | loaded | rejected/empty | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6310403..6bfddf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,14 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Added -- Exploratory `CountedSignal`: a payload-free SPSC counter with a bounded +- `CountedSignal`: a payload-free SPSC counter with a bounded `increment`, atomic `take_count`, exact `u32` saturation, and observable saturation. Loom models pin both ordinary take partitioning and the - saturation-boundary interleaving; cycle and code-size probes expose the - remaining cost decision. The proposal records why exact bounded saturation - depends on retaining a sole `Send + !Sync` producer handle. + saturation-boundary interleaving; its frozen contract maps citable clauses + to unit, threaded, Loom, Miri, code-size, and QEMU evidence. The reference + Cortex-M3 probe measures 8 retired instructions for `increment` and 7 for + `take_count`. Exact bounded saturation depends on retaining a sole + `Send + !Sync` producer handle. ### Documentation - `RingBuf::new`'s docs no longer mention a `pop` method the type does not have — `pop` was diff --git a/README.md b/README.md index df62055..1d5ed0e 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,8 @@ assert!(producer.push(3).is_ok()); // space freed ### CountedSignal -An exploratory saturating count for repeated events whose payload and ordering -do not matter. The sole producer is load-bearing: it permits exact saturation +A saturating count for repeated events whose payload and ordering do not +matter. The sole producer is load-bearing: it permits exact saturation with a bounded load plus conditional `fetch_add`, without a CAS retry loop. ```rust @@ -238,6 +238,10 @@ them is a runtime step and always will be. - `take_count` atomically clears the counter and reports whether it saturated. - A concurrent increment belongs wholly to the current take or the next one. - The sole `Send + !Sync` producer handle is part of the correctness contract. +- Count operations do not publish unrelated application memory; payload data + needs a separate synchronization mechanism. +- The reference Cortex-M3 probe measures 8 retired instructions for + `increment` and 7 for `take_count` (rustc 1.92.0, QEMU 10.0.11). ## Safety and Concurrency - `RingBuf` has no atomics and no interior mutability — standard Rust borrow rules apply. It stores slots as `MaybeUninit` and reads only live entries, so it does contain `unsafe`. @@ -245,8 +249,9 @@ them is a runtime step and always will be. active. Use `try_producer()`/`try_consumer()`, which return `None` rather than panicking — on a microcontroller a panic is a reset, and the panic machinery costs flash you may not have. The panicking `producer()`/`consumer()` are **deprecated since 0.2.0** and will be removed in - 0.3.0. Using unsafe to bypass the SPSC constraint (or sharing handles concurrently) is - undefined behavior. + 0.3.0. Using unsafe to bypass `SeqRing`/`EventBuf` ownership can be undefined + behavior. Forging or concurrently sharing a `CountedSignal` producer breaks + its bounded no-wrap contract; the handle is `!Sync` to prevent that in safe Rust. - `T: Copy` is required by all payload-carrying types to avoid allocation and return values by copy. - `EventBuf` is race-free by construction: its producer and consumer never touch the same slot, and it passes Miri with the data-race detector enabled. diff --git a/docs/0.3.0-candidates.md b/docs/0.3.0-candidates.md index ec2f1b6..9b2be6e 100644 --- a/docs/0.3.0-candidates.md +++ b/docs/0.3.0-candidates.md @@ -233,7 +233,12 @@ position for maintainer triage, not a decision. duplicated); the saturate-don't-wrap rule matches the `dropped_accum` convention already in the codebase. Separate design documents by maintainer direction, with the handle-model question explicitly shared — - one answer for both. + one answer for both. **Shared decision H (2026-08-11):** both lanes use + sole-role handles that are `Send + !Sync`, with `&self` operations; any + future MPSC signal is a separate type with separate evidence. + **CountedSignal lane update:** promoted to PROPOSED after applying H, + freezing its clause-numbered contract, and completing the + unit/Loom/Miri/code-size/reference-QEMU evidence map. **Tier 2 — high value, hard problems named, needs design work:** diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index a51bd0e..a1b6c2b 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -1,17 +1,18 @@ -# CountedSignal semantic contract (draft) +# CountedSignal semantic contract -- **Status:** Draft for review — promotion-bar item 3 in - [`counted-signal.md`](counted-signal.md) §4. +- **Status:** Frozen for evaluation on the `candidate/counted-signal` lane. + Promotion-bar item 3 in [`counted-signal.md`](counted-signal.md) §4 is + complete. - **Rule of this document:** the clauses describe an **abstract counted signal**, independently of atomics, memory orderings, or the candidate algorithm. The design and its evidence must satisfy the clauses; they do not define them. - **Clause IDs are load-bearing.** Tests, models, and documentation cite these IDs. Do not renumber an existing clause; append a new one to its group. -- **Handle-decision status:** the H clauses state the lane's recommended answer - to the still-open shared decision with EventFlags. Accepting this contract - accepts that answer for CountedSignal; choosing shareable raisers requires a - revised contract, algorithm, and evidence case. See §8. +- **Handle-decision status:** accepted for CountedSignal and EventFlags. The H + clauses freeze sole-role `Send + !Sync` handles with `&self` hot-path + operations. A future shareable/multi-producer signal requires a separate + contract, type, algorithm, and evidence case. See §8. ## 1. Abstract model @@ -111,7 +112,7 @@ to a target, toolchain, and reference environment. ## 8. Shared handle decision (H-decision) -The candidate answer shared with EventFlags is H1–H4: sole-role +The accepted answer shared with EventFlags is H1–H4: sole-role `Send + !Sync` handles with `&self` hot-path operations and state resident in the signal. For CountedSignal, this is a correctness choice rather than an API style preference. The bounded candidate implementation reads the state and @@ -120,9 +121,9 @@ is the only possible intervening writer and can only reset the state; a second raiser would invalidate the no-wrap proof and the current evidence for B1. The independent LatestBuf A.3 decision currently recommends the same handle -shape. Whether H and A.3 close as one crate-wide doctrine or as separate -decisions remains a maintainer decision on issue #26; this contract does not -silently take that broader decision. +shape. This closes H for EventFlags and CountedSignal only. Whether the +independent LatestBuf A.3 choice later becomes one crate-wide doctrine remains +a separate maintainer decision on issue #26. ## 9. Evidence map @@ -141,3 +142,7 @@ The candidate's relaxed atomic orderings and its load-plus-conditional-RMW proof are implementation evidence for this abstract contract. They remain in [`counted-signal.md`](counted-signal.md) §3.1 and the module documentation, not in the semantic clauses above. + +The candidate tree passed the complete pinned reference matrix with zero skips +on 2026-08-11: CI/features/docs/deny/coverage, Miri host and proxy targets, all +seven Loom models, eight-target code size, embedded checks, and QEMU cycles. diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index efa9344..42cd96c 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -1,6 +1,7 @@ -# CountedSignal: multiplicity without payloads (exploratory design document) +# CountedSignal: multiplicity without payloads -- **Status:** EXPLORATORY — design exploration vehicle, not yet PROPOSED. +- **Status:** PROPOSED — shared handle decision, contract, and admission + evidence complete; ready for evaluation. - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §5 (received 2026-08-11); triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. @@ -60,28 +61,22 @@ should remain statically sized and avoid a general dynamic registry. [`event-flags.md`](event-flags.md) §3 — the notes apply verbatim and are not duplicated here. -## 3. Open questions - -- Saturating increment on a plain atomic needs care: a - `compare_exchange` loop is unbounded under contention, which violates the - producer bound — is the answer `fetch_add` with a saturation check that - tolerates a bounded overshoot window, a claimed-bit scheme, or accepting - `fetch_add` wrapping on a width wide enough that saturation is - unreachable in practice (and documenting that instead)? This is the - design's central question — the naive implementations are either - unbounded or not actually saturating. -- How is saturation observed — a sticky flag in `take_count`'s return, a - reserved sentinel value, or a separate query? -- `take_count` semantics: `swap(0)` is the obvious atomic take — confirm - the contract is "count since last take," not a running total. -- Multi-counter form: fixed `[counter; K]` per event class — is that a - distinct type, or `CountedSignal` composed by the caller? (The taxonomy - pre-rejects a dynamic registry; the question is only whether the static - array earns a type.) -- Counter width: `u32` everywhere (matching the crate's sequence width and - the 32-bit `usize` of every shipped target), or parameterised? - -## 3.1 Exploratory result: exact bounded saturation requires one producer +## 3. Resolved candidate decisions + +- Saturation uses the sole-producer algorithm in §3.1: one Relaxed load and, + below `u32::MAX`, one Relaxed `fetch_add`. There is no retry loop. +- `u32::MAX` is the observable saturation sentinel, wrapped in + `CountSnapshot`; exact excess beyond the sentinel is intentionally absent. +- `take_count` is a `swap(0)` and reports one take interval, not a running + total. +- The initial type is one `u32` counter. Applications compose a fixed array of + signals when they need multiple classes; a dedicated multi-counter type or + dynamic registry needs a concrete caller and separate evidence. +- The width is `u32`, matching the crate's atomic shim and every shipped + 32-bit target. Width genericity would create target-dependent contracts and + needs its own evidence before it can earn a separate type. + +## 3.1 Candidate result: exact bounded saturation requires one producer The candidate branch now carries an executable SPSC design. It found a fourth answer to the central question that is stronger than the three initially @@ -159,18 +154,23 @@ environment, not a universal microarchitectural cycle claim. Together with the eight-target code-size rows, this closes the lane's missing pinned-cost evidence. -This result refines rather than silently closes the handle decision: choose -SPSC handles and the central bounded-saturation problem has a small exact -solution; choose multiple raisers and this implementation must be rejected. +## 3.2 Shared handle decision + +The accepted EventFlags/CountedSignal answer is sole-producer/sole-consumer +`Send + !Sync` handles with `&self` hot-path operations. For CountedSignal the +choice is load-bearing: it makes §3.1 exact and bounded. EventFlags does not +need exclusivity for `fetch_or` correctness, but loses no guarantee under the +same conservative ownership model. A future MPSC signal can be added as a +separate type after it has its own contract and evidence; it cannot weaken this +type's proof. ## 4. Promotion bar to PROPOSED 1. **Complete for the SPSC candidate:** the bounded-saturating-increment question has the load-plus-conditional-RMW answer proved in §3.1. -2. **Open maintainer decision:** settle the shared handle model with - [`event-flags.md`](event-flags.md) (one answer for both). The proof - dependency is recorded in the contract's H-decision section; choosing - multiple raisers rejects the current algorithm and its B1 evidence. +2. **Complete:** the shared handle model is sole-role `Send + !Sync` handles + with `&self` operations for both this lane and EventFlags. The proof + dependency is recorded in the contract's H-decision section. 3. **Complete:** the short [`counted-signal-contract.md`](counted-signal-contract.md) gives stable I/T/A/B/H/X clause IDs and maps each guarantee to evidence. @@ -179,4 +179,4 @@ solution; choose multiple raisers and this implementation must be rejected. threaded stress, Miri, and two Loom models cover atomic take, no-lost-increment accounting, and saturation without wrapping. -The shared handle decision is therefore the only remaining promotion gate. +The promotion bar is complete; the candidate is ready for evaluation. diff --git a/src/counted_signal.rs b/src/counted_signal.rs index bbad984..006a298 100644 --- a/src/counted_signal.rs +++ b/src/counted_signal.rs @@ -11,6 +11,11 @@ //! but no other operation can increase it. Consequently `fetch_add` cannot //! wrap: it either advances the value observed by the producer or advances a //! newly reset epoch. Multiple producers would invalidate that proof. +//! +//! The counter carries no payload-publication semantics. These Relaxed +//! operations order the count itself, but do not publish unrelated application +//! memory. Use a separate synchronization mechanism when an occurrence makes +//! payload data available. use core::cell::Cell; use core::marker::PhantomData; From e9859d4e9b3213d2ec61e905764151b9e4de861a Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 13:57:07 -0400 Subject: [PATCH 13/87] Implement EventFlags candidate --- AGENTS.md | 76 +++- CHANGELOG.md | 19 + README.md | 63 +++- docs/0.3.0-candidates.md | 36 +- docs/proposals/event-flags-contract.md | 132 +++++++ docs/proposals/event-flags.md | 447 +++++++++-------------- docs/proposals/exploratory-primitives.md | 12 +- scripts/codesize.sh | 22 +- scripts/codesize/baseline.tsv | 28 +- scripts/codesize/src/lib.rs | 50 ++- scripts/cycles.sh | 1 + scripts/cycles/src/main.rs | 31 +- scripts/event-flags-atomic-window.sh | 158 ++++++++ scripts/loom.sh | 9 +- src/event_flags.rs | 445 ++++++++++++++++++++++ src/lib.rs | 23 +- src/loom_tests.rs | 122 ++++++- 17 files changed, 1334 insertions(+), 340 deletions(-) create mode 100644 docs/proposals/event-flags-contract.md create mode 100755 scripts/event-flags-atomic-window.sh create mode 100644 src/event_flags.rs diff --git a/AGENTS.md b/AGENTS.md index 4aca151..52170e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,10 +26,11 @@ prose when it disagrees. **ph-eventing** provides stack-allocated ring buffers for no-std embedded targets. -It ships three primitives: +It ships four primitives: - **`RingBuf`** — a single-owner ring buffer (no atomics, `&mut` access). Ideal for local event logs, sample windows, and single-context collection. - **`SeqRing`** — a lock-free SPSC ring that **overwrites** old entries. Designed for high-rate telemetry where a fast producer and a potentially slower consumer run on different contexts. - **`EventBuf`** — a lock-free SPSC ring with **backpressure**. `push` returns `Err(val)` when full, so the producer always knows when delivery fails. +- **`EventFlags`** — a coalescing SPSC condition set. One producer raises a 32-bit mask and one consumer atomically takes it; duplicates may coalesce and no condition ordering is retained. **Key characteristics:** - Zero runtime dependencies (`portable-atomic` is optional; `loom` is a dev-dependency gated on `--cfg loom`). Verify with `cargo tree` — it must print the crate alone. @@ -38,6 +39,7 @@ It ships three primitives: - `SeqRing`: producer never blocks; consumer drops old events when lagging - `SeqRing`: sequence-based tracking; a raced read is detected and discarded rather than returned - `EventBuf`: producer gets explicit backpressure; no data silently lost +- `EventFlags`: one `fetch_or` to raise, one `swap(0)` to take; Release/Acquire publishes application state - Common `Sink`/`Source`/`Link` traits unify producers and consumers across buffer types - `forward()` utility bridges any `Source` into any `Sink` @@ -129,6 +131,7 @@ ph-eventing/ │ ├── miri.sh # Miri UB/concurrency checks │ ├── loom.sh # Loom model checking │ ├── codesize.sh # Per-target flash cost across 11 embedded targets +│ ├── event-flags-atomic-window.sh # EventFlags interrupt-mask disassembly gate │ └── codesize/ # no_std probe crate it measures (own workspace) ├── build.rs # guards the mutually exclusive portable-atomic features ├── .github/ # CI workflow (push + PR), issue/PR templates, CODEOWNERS, dependabot @@ -138,6 +141,7 @@ ph-eventing/ ├── lib.rs # Crate root, public exports, doctests ├── macros.rs # static_spsc! -- declarative static bring-up ├── event_buf.rs # Bounded SPSC event buffer with backpressure + ├── event_flags.rs # Coalesced SPSC condition notification ├── ring.rs # Single-owner stack-allocated ring buffer ├── seq_ring.rs # Lock-free SPSC overwrite ring with sequence tracking ├── sync.rs # Atomic/cell shim — swaps in Loom's primitives under --cfg loom @@ -159,6 +163,10 @@ ph-eventing/ | `EventBuf` | Bounded SPSC ring with backpressure (push returns `Result`) | | `event_buf::Producer<'a, T, N>` | EventBuf write handle; `push(T) -> Result<(), T>` | | `event_buf::Consumer<'a, T, N>` | EventBuf read handle; `pop() -> Option`, `peek()`, `drain()` | +| `EventFlags` | Coalesced SPSC set of exactly 32 payload-free conditions | +| `EventMask` | Transparent `u32` condition set used by EventFlags | +| `event_flags::Producer<'a>` | Sole raising handle; `raise(EventMask)` | +| `event_flags::Consumer<'a>` | Sole taking handle; `take_all() -> EventMask` | | `Sink` | Trait — accept events via `try_push(&mut self, T) -> Result<(), Error>` | | `Source` | Trait — yield events via `try_pop(&mut self) -> Option` | | `Link` | Trait — blanket impl for any `Sink + Source` | @@ -211,6 +219,22 @@ in range after an ordering regression and could hide the broken snapshot from tests; retain the unclamped branch so its bound continues to follow from the memory-ordering proof. +### Memory Ordering Strategy (EventFlags) + +`EventFlags` has one shared `AtomicU32` pending set and no payload slots: + +- The producer raises bits with one `fetch_or(mask, Release)`. +- The consumer destructively takes all pending bits with one `swap(0, Acquire)`. +- The atomic modification order partitions every raise between consecutive + takes: a concurrent bit is returned by either the racing take or the next one, + never lost. Do not replace the RMWs with load/modify/store sequences. +- Release/Acquire is load-bearing publication. If a take observes a bit, it also + observes application-owned state sequenced before that raise. The Loom + publication litmus fails when either side is weakened to Relaxed. +- Producer and consumer role claims use AcqRel compare-exchange and Release on + handle drop. Handles are `Send + !Sync`; their `&self` operations do not grant + multiple logical producers or consumers. + ### Internal model (SeqRing) The public API is on docs.rs; this is the part you cannot infer from it. Three @@ -585,7 +609,7 @@ one MCU can lose badly on another**, and measuring one target hides that: | Cortex-M0+ / M23 (`thumbv6m`, `thumbv8m.base`) | No native 32-bit atomics. Under portable-atomic every RMW is an interrupt-disable critical section, so an extra RMW costs flash *and* interrupt latency | | RISC-V (`riscv32imac`) | `fetch_or`/`swap` are single AMO instructions but `compare_exchange` is an LR/SC retry loop — the opposite cost ordering from ARM, where both are ldrex/strex | | Xtensa (ESP32) | Splits code between `.text.` and `.literal.`. Counting only `.text` undercounts it — 212 vs 220 bytes for the same function | -| ESP32-S2 / S3 | Single-core Xtensa **without** the `S32C1I` compare-and-swap, so they need portable-atomic exactly like Cortex-M0+ | +| ESP32-S2 / S3 | S2 lacks native 32-bit RMW and uses the interrupt-masked portable-atomic path. The measured esp-rs S3 target advertises 32-bit atomics and emits native `S32C1I`; verify compiler cfg/disassembly rather than grouping the two by family name. | This is not hypothetical. A `try_split` prototype measured 32 bytes *cheaper* than two separate calls on Cortex-M3/M4/M7, 28 cheaper on M33, 8 bytes *more @@ -673,8 +697,10 @@ Cross-environment diffs of ±1 are noise; compare inside the image. | `SeqRing::latest_value` | — | 30 | | | `RingBuf::push` | 20 | **20** (overwriting) | | | `RingBuf::get` / `latest` | 22 / 16 | | | +| `EventFlags::raise` | 12 (clear) | **12** (already set) | | +| `EventFlags::take_all` | — | 8 (non-empty) | 8 (empty) | -Two results carry the argument: +Three results carry the argument: 1. **Every `push` is constant.** Empty vs loaded differs by at most one instruction on all three types. Cost does not scale with occupancy or `N`. @@ -682,6 +708,12 @@ Two results carry the argument: ~2000 behind both cost **115 instructions**. If recovery walked the backlog the second would be two orders of magnitude larger. It is a jump, and now that is measured rather than asserted. +3. **EventFlags is constant across condition state.** Raise is 12 instructions + whether the bit is clear or already set; take is 8 whether non-empty or + empty. On the portable paths, `event-flags-atomic-window.sh` additionally + pins straight-line masked windows of 4 instructions on thumbv6m and 5 on + ESP32-S2; ESP32-S3 masks interrupts for zero instructions under its native + `S32C1I` path. The rejected push is *cheaper* than an accepted one — backpressure is an early return, not extra work. @@ -783,7 +815,9 @@ Individual checks are also available as cargo aliases (see ## Testing -Tests are in `src/ring.rs`, `src/seq_ring.rs`, `src/event_buf.rs`, and `src/traits.rs` in their respective `tests` modules. They require std and use the standard Rust test framework. +Tests are in `src/ring.rs`, `src/seq_ring.rs`, `src/event_buf.rs`, +`src/event_flags.rs`, and `src/traits.rs` in their respective `tests` modules. +They require std and use the standard Rust test framework. **Run tests:** ```bash @@ -824,6 +858,17 @@ cargo test - `const_new_works_in_const_context` — `static` / const `new()` (`#[cfg(not(loom))]`) - `static_buf_yields_static_sendable_handles` — `'static`, `Send` handles off a `static` buffer +**`event_flags::tests`:** +- `event_mask_is_an_explicit_panic_free_32_bit_set` — exact-width mask and checked bit construction +- `duplicate_raises_coalesce_and_take_clears` — duplicate coalescing and destructive take +- `multi_bit_and_all_bit_masks_round_trip` — multi-condition and all-condition masks +- `empty_raise_and_empty_take_are_no_ops` — empty-mask semantics +- `handles_are_exclusive_and_reusable_after_drop` — fallible role claims and release +- `handles_are_send_and_container_is_sync` — handle/container auto-trait contract +- `const_new_works_in_static_context` — static construction +- `concurrent_raise_and_take_never_loses_the_condition` — threaded no-loss stress +- `observed_raise_publishes_preceding_memory` — native/Miri publication litmus + **`seq_ring::tests`:** - `poll_one_empty_returns_false` — Empty ring behavior - `polls_in_order` — Sequential consumption @@ -865,8 +910,9 @@ cargo test **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -and `src/traits.rs`. Total: 69 unit tests + 11 doctests, plus 3 `compile_fail` -doctests pinning the `N == 0` rejection (`E0080`) on all three types. +and `src/traits.rs`. Total: 78 unit tests + 11 doctests, plus 5 +`compile_fail` doctests: three pin the `N == 0` rejection (`E0080`) on the +buffer types and two pin the EventFlags handle `!Sync` contract. ## Code Conventions @@ -883,6 +929,9 @@ doctests pinning the `N == 0` rejection (`E0080`) on all three types. - `T: Copy` required by `RingBuf`, `SeqRing`, and `EventBuf` for value-copy returns - `T: Send` required for `SeqRing` and `EventBuf` to be `Sync` - Unsafe code is confined to `MaybeUninit` / `UnsafeCell` slot access in all three buffers +- `EventFlags` carries no `T`, contains no unsafe code, and exposes only the + transparent `EventMask(u32)` value type +- All concurrent producer/consumer handles are `Send + !Sync` - No panics in hot paths. All three `new()` reject `N == 0` with a **const** assertion, so a zero-capacity buffer fails the build rather than panicking — which also means no test can cover the rejection, and the const assertion is @@ -934,6 +983,15 @@ The project supports these targets (defined in `rust-toolchain.toml`): - `EventBuf`: `len()` never exceeds `N` and never blocks, even while both handles are active - `SeqRing`: `dropped_accum` saturates — it must never overflow, and `usize` is 32-bit on every shipped target - `EventBuf`: Producer and Consumer handles are `Send + !Sync` +- `EventFlags`: pending bits are an unordered union; duplicates may coalesce, + and `take_all` atomically clears exactly the set it returns +- `EventFlags`: Release `raise` and Acquire `take_all` publish + application-owned state; keep both RMWs atomic and do not weaken them +- `EventFlags`: `EventMask` remains exactly one transparent `u32`; checked + index construction rejects indices outside `0..32` without panicking +- `EventFlags`: Producer and Consumer handles are `Send + !Sync`; do not add + peek operations or stream-trait implementations that blur destructive-take + semantics - `SeqRing` / `EventBuf`: the panicking `producer()` / `consumer()` are deprecated since 0.2.0 and removed in 0.3.0. Library code must use `try_producer()` / `try_consumer()`; a panic is a reset on the targets this crate exists for. Test modules carry `#![allow(deprecated)]` because @@ -956,12 +1014,16 @@ The project supports these targets (defined in `rust-toolchain.toml`): 6. `./scripts/ci.sh`, then `./scripts/miri.sh` and `./scripts/loom.sh` — a passing `cargo test` is not evidence for a change in this file -**Adding a new event type feature:** +**Adding a new payload-buffer feature:** 1. Preserve `T: Copy`; adding a bound is a breaking change 2. If it needs a new atomic or cell, take it from `crate::sync`, never `core` 3. Document the guarantee on *both* user surfaces — `README.md` and the `//!` docs — since crates.io and docs.rs show different things +For payload-free signals such as `EventFlags`, freeze the coalescing/counting +contract and mask width before implementation; do not force them into the +stream traits merely to reuse vocabulary. + ### What to Avoid - Adding external dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 83507f5..97219ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added +- `EventFlags` — a coalescing SPSC condition set for ISR-to-task notification. Exactly 32 + payload-free conditions are represented by a transparent `EventMask(u32)`; the producer raises + with one Release `fetch_or`, and the consumer atomically returns and clears the set with one + Acquire `swap(0)`. Duplicate raises may coalesce, but a raise racing a take is never lost between + windows. Handles follow the accepted signal-lane doctrine: sole-role `Send + !Sync` values with + `&self` hot-path operations and fallible, non-panicking acquisition. +- EventFlags admission evidence: three Loom models (including a publication litmus whose Release + and Acquire mutation checks both fail as intended), detector-on Miri coverage, eight gated and + three opt-in Xtensa code-size rows, four Cortex-M3 instruction regions, and a reproducible + portable-atomic disassembly check. The masked window is 4 instructions on thumbv6m and 5 on + ESP32-S2; ESP32-S3 uses native `s32c1i` and masks interrupts for 0 instructions under the + measured esp-rs toolchain. + +### Fixed +- `scripts/loom.sh ` now scopes the filter to `loom_tests::` instead of passing a + second positional test filter to Cargo. The documented `./scripts/loom.sh event_buf` form was + rejected by Cargo before any model ran. + ### Documentation - `RingBuf::new`'s docs no longer mention a `pop` method the type does not have — `pop` was deliberately rejected (its data loss would be unreportable under overwrite; see the worked diff --git a/README.md b/README.md index 348ed85..8d26a74 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![MSRV](https://img.shields.io/badge/MSRV-1.92.0-blue)](rust-toolchain.toml) [![no_std](https://img.shields.io/badge/no__std-yes-green)](src/lib.rs) -Stack-allocated ring buffers for no-std embedded targets. +Deterministic zero-allocation handoff primitives for no-std embedded targets. ## What's in the box @@ -16,8 +16,10 @@ Stack-allocated ring buffers for no-std embedded targets. | [`RingBuf`](#ringbuf) | Single-owner ring buffer — simple, no atomics, `&mut` access. | | [`SeqRing`](#seqring) | Lock-free SPSC ring that **overwrites** old entries (lossy, high-throughput). | | [`EventBuf`](#eventbuf) | Lock-free SPSC ring with **backpressure** — rejects pushes when full. | +| [`EventFlags`](#eventflags) | Coalesced SPSC condition set — 32 payload-free conditions, one atomic hot-path operation. | -All three are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Copy`. +All four are fixed-size, `#![no_std]`, and zero-allocation. The three buffers +are generic over `T: Copy`; `EventFlags` carries an `EventMask(u32)`. ## What this optimises for @@ -25,9 +27,9 @@ All three are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Co **behaviour you can predict and cost you can measure**: - **Predictability first.** No unbounded loops, no hidden allocation, and no - panic reachable from a hot path. For the two SPSC types, no data loss that + panic reachable from a hot path. For the concurrent types, no data loss that cannot be observed either — every drop is reported (`SeqRing`) or prevented - (`EventBuf`). `RingBuf` is the deliberate exception: it is a single-owner + (`EventBuf`), or explicitly coalesced by contract (`EventFlags`). `RingBuf` is the deliberate exception: it is a single-owner window that overwrites silently, with no drop counter and no backpressure. Reach for it when losing the oldest entry is the point, not when delivery matters. @@ -48,6 +50,7 @@ panics, or hides a cost. ## Features - Three ring buffer flavours: single-owner, lossy SPSC, and backpressure SPSC. +- `EventFlags` for coalesced ISR-to-task condition notification. - Common `Sink`/`Source`/`Link` traits for writing generic event-processing code. - `forward(src, snk, max)` utility to bridge any `Source` → `Sink`. - No heap, no dynamic dispatch, no required dependencies. @@ -57,7 +60,7 @@ panics, or hides a cost. ## Compatibility - MSRV: Rust 1.92.0. - `SeqRing::new()` and `EventBuf::new()` assert `N > 0`. -- `SeqRing` and `EventBuf` require 32-bit atomics by default. +- `SeqRing`, `EventBuf`, and `EventFlags` require 32-bit atomics by default. - For `thumbv6m-none-eabi` (and other no-atomic targets), enable one of: - `portable-atomic-unsafe-assume-single-core` - `portable-atomic-critical-section` (requires a critical-section implementation in the binary) @@ -131,6 +134,34 @@ assert_eq!(consumer.pop(), Some(1)); assert!(producer.push(3).is_ok()); // space freed ``` +### EventFlags + +A coalesced condition set for ISR-to-task notification. Repeated raises of one +condition may merge; a take returns and clears every condition that occurred at +least once since the preceding take. + +```rust +use ph_eventing::{EventFlags, EventMask}; + +const DATA_READY: EventMask = EventMask::from_bits(1 << 0); +const OVERFLOW: EventMask = EventMask::from_bits(1 << 1); + +let flags = EventFlags::new(); +let producer = flags.try_producer().expect("no producer taken yet"); +let consumer = flags.try_consumer().expect("no consumer taken yet"); + +producer.raise(DATA_READY); +producer.raise(DATA_READY); // coalesces +producer.raise(OVERFLOW); + +assert_eq!(consumer.take_all(), DATA_READY | OVERFLOW); +assert!(consumer.take_all().is_empty()); +``` + +`EventFlags` deliberately does not implement the stream traits below: a +coalesced condition set is not a sequence of items, and destructive take plus +a rejecting downstream sink could silently lose the mask. + ### Common Traits All producers implement `Sink` and all consumers implement `Source`, @@ -211,15 +242,24 @@ them is a runtime step and always will be. - `drain(max, hook)` consumes up to `max` items through a callback and returns the count. - No data is silently lost — the producer always knows when the buffer cannot accept more. +### EventFlags +- `raise(mask)` unions conditions into the pending set; duplicate bits may coalesce. +- `take_all()` atomically returns and clears every pending condition. +- Conditions are unordered and carry no payload or multiplicity. +- `EventMask` is exactly 32 bits; `from_index` rejects out-of-range indices without panicking. +- A take that observes a raise also observes memory writes sequenced before it. +- There is no non-clearing peek and no stream/signal trait implementation in the initial surface. + ## Safety and Concurrency - `RingBuf` has no atomics and no interior mutability — standard Rust borrow rules apply. It stores slots as `MaybeUninit` and reads only live entries, so it does contain `unsafe`. -- `SeqRing` and `EventBuf` are SPSC by design: exactly one producer and one consumer may be +- `SeqRing`, `EventBuf`, and `EventFlags` are SPSC by design: exactly one producer and one consumer may be active. Use `try_producer()`/`try_consumer()`, which return `None` rather than panicking — on a microcontroller a panic is a reset, and the panic machinery costs flash you may not have. The panicking `producer()`/`consumer()` are **deprecated since 0.2.0** and will be removed in 0.3.0. Using unsafe to bypass the SPSC constraint (or sharing handles concurrently) is undefined behavior. -- `T: Copy` is required by all types to avoid allocation and return values by copy. +- `T: Copy` is required by the three buffer types to avoid allocation and return values by copy. +- `EventFlags` has no unsafe slot access and passes Miri with the race detector enabled. - `EventBuf` is race-free by construction: its producer and consumer never touch the same slot, and it passes Miri with the data-race detector enabled. - `SeqRing` is a seqlock and carries a **known formal data race** — the consumer may copy a slot @@ -242,8 +282,9 @@ them is a runtime step and always will be. The typical embedded shape is a producer in an interrupt handler and a consumer in a task loop. That works, with three things to know: -- **The buffer is shared; the handles are owned.** `SeqRing` and - `EventBuf` are `Sync` when `T: Send`, so `&buf` can be handed to both +- **The container is shared; the handles are owned.** `SeqRing` and + `EventBuf` are `Sync` when `T: Send`, and `EventFlags` is `Sync`, so a + shared reference can be handed to both contexts. `Producer` and `Consumer` are `Send + !Sync` — move each one into the context that owns it, and never share a single handle between contexts. There is no way to get a second `Producer` while one is live: `producer()` @@ -285,7 +326,7 @@ in a task loop. That works, with three things to know: ## Quality and verification -`SeqRing` and `EventBuf` are lock-free, so a green test run on x86 is weak +The concurrent primitives are atomic, so a green test run on x86 is weak evidence — a strongly-ordered host cannot exhibit the ordering bugs that appear on ARM and RISC-V. What backs this crate, in descending order of strength: @@ -293,7 +334,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 69 unit + 11 doctests + 3 compile-fail | Behaviour, including threaded stress tests for both SPSC types; `N == 0` rejected at compile time | +| 78 unit + 11 doctests + 5 compile-fail | Behaviour, including threaded stress for all concurrent types; `N == 0` and EventFlags handle `!Sync` contracts rejected at compile time | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/docs/0.3.0-candidates.md b/docs/0.3.0-candidates.md index f029cb6..7f68be2 100644 --- a/docs/0.3.0-candidates.md +++ b/docs/0.3.0-candidates.md @@ -185,17 +185,18 @@ happens under overload, which context owns each piece of memory, and what bounds can be measured. Its admission rule and out-of-scope list are adopted as the standing filter for everything below. -These entries are **EXPLORATORY**: captured for triage, none carrying a -design detailed enough for the evidence bar yet. `LatestBuf` is the -exception — already PROPOSED with a contract (§3); the taxonomy's entry for -it is a cross-reference to that document. +These entries begin **EXPLORATORY**: captured for triage until their contract +and evidence bar are complete. `LatestBuf` is already PROPOSED with a contract +(§3), and `EventFlags` has now reached PROPOSED on its candidate branch; their +taxonomy entries cross-reference the completed design packages. **Update (2026-08-11):** each Tier 1/2 primitive now has its own exploratory design document under `docs/proposals/`, seeded with the substantive text moved out of the taxonomy plus its triage notes, open -questions, and promotion bar. Status remains EXPLORATORY — a document -existing is not a design being ready; promotion to PROPOSED happens when a -design matures enough to face the evidence bar. +questions, and promotion bar. A document existing does not itself make a +design ready; promotion to PROPOSED happens only after its contract and +evidence bar are complete. EventFlags is the first primitive promoted from +this set. ### Initial triage (2026-08-11) @@ -215,16 +216,17 @@ position for maintainer triage, not a decision. types matches the taxonomy's no-runtime-policy-branches rule and this repo's predictability priority; the LatestBuf contract clauses look nearly wholesale-reusable for the `Latest` variant. -- **`EventFlags`** (coalesced condition bitset) — design document: - [`proposals/event-flags.md`](proposals/event-flags.md). Small surface, - obvious contract, high embedded value. Triage notes: `fetch_or`/`swap(0)` are both - interrupt-disable critical sections under portable-atomic on - thumbv6m/ESP32-S2 — cheap, but the ISR-side cost must be measured, not - assumed, since ISR latency is exactly what this primitive is for. The - "clearing and raising must not lose a concurrent event" clause is - Loom-model material. Note this would be the crate's first - `&self`-operation primitive (the sketched `SignalSink::raise` takes - `&self`) — a handle-model question to settle at design time. +- **`EventFlags`** (coalesced condition bitset) — **PROPOSED** on + `candidate/event-flags`. The frozen semantic clauses are in + [`proposals/event-flags-contract.md`](proposals/event-flags-contract.md) and + the implementation, ordering proof, code-size/cycle tables, and exact + interrupt-masked windows are in + [`proposals/event-flags.md`](proposals/event-flags.md). The candidate keeps + SPSC `Send + !Sync` role handles, uses one Release `fetch_or` and one Acquire + `swap(0)`, and deliberately does not implement the stream traits. Measured + portable paths mask interrupts for 4 instructions on thumbv6m and 5 on + ESP32-S2; the measured ESP32-S3 target instead emits native `s32c1i` and + masks for 0 instructions. - **`CountedSignal`** (saturating per-condition counter) — design document: [`proposals/counted-signal.md`](proposals/counted-signal.md). Same niche and same triage notes as `EventFlags` (cross-referenced, not diff --git a/docs/proposals/event-flags-contract.md b/docs/proposals/event-flags-contract.md new file mode 100644 index 0000000..2725969 --- /dev/null +++ b/docs/proposals/event-flags-contract.md @@ -0,0 +1,132 @@ +# EventFlags semantic contract + +- **Status:** Frozen for evaluation on the `candidate/event-flags` lane. +- **Rule of this document:** these clauses describe an abstract coalescing + condition set independently of atomics, memory orderings, or the candidate + algorithm. The design and evidence must satisfy the clauses; they do not + define them. +- **Clause IDs are load-bearing.** Tests, models, and documentation cite these + IDs. Do not renumber an existing clause; append a new one to its group. +- **Decision status:** the shared handle decision and EventFlags-specific + width, mask, observation, and trait decisions are closed for this candidate. + See §9. + +## 1. Abstract model (M) + +An EventFlags value records an unordered set of payload-free conditions from +one producer handle for one consumer handle. The condition namespace contains +exactly 32 members, represented by bits 0 through 31 of an `EventMask`. + +- **M1.** A new EventFlags value has an empty pending set. +- **M2.** `raise(mask)` and `take_all()` are linearizable. Every completed call + takes effect exactly once at an instant between invocation and return. + +## 2. Raise clauses (R) + +- **R1.** At `raise(mask)`'s linearization point, pending becomes the set union + of its prior value and `mask`. Raising the empty set changes no condition. +- **R2.** Pending records only whether each condition occurred. Duplicate + raises may coalesce; multiplicity is not observable. + +## 3. Take clauses (T) + +- **T1.** `take_all()` returns exactly the pending set immediately before its + linearization point and makes pending empty at that point. +- **T2.** A take linearized while pending is empty returns the empty set and + leaves it empty. + +## 4. Window and conservation clauses (C) + +- **C1.** For each condition, a take contains it if and only if at least one + matching raise linearized after the preceding take and before this take, or + after initialization for the first take. +- **C2.** A raise racing a take linearizes wholly before or wholly after it. If + the raise is first, the condition is in that take; otherwise it remains + pending for a later take. Clearing never erases the later raise. +- **C3.** A take fabricates no condition and returns no individual raise in two + take windows. Multiple matching raises may legitimately make the same bit + appear in separate windows; within one window they may coalesce under R2. + +## 5. Publication clause (S) + +- **S1.** Memory actions sequenced before a raise happen-before memory actions + sequenced after a take that observes any condition from that raise. + EventFlags publishes readiness for application-owned state; the state itself + is not stored in the mask. + +## 6. Boundedness clauses (B) + +The clauses below count one target atomic RMW as one signal-word operation. +Target-specific instruction and interrupt-masked windows remain measured claims +tied to a compiler and architecture. + +- **B1.** `raise` performs one signal-word `fetch_or`, with no source-level + retry loop, allocation, callback, panic path, or work proportional to + history, occupancy, or the number of set bits. +- **B2.** `take_all` performs one signal-word `swap(0)`, with the same bounds + independent of producer activity and the number of pending conditions. +- **B3.** Producer work never waits for or invokes consumer work, and consumer + work never waits for or invokes producer work. + +## 7. Handle clauses (H) + +- **H1.** At most one producer handle and at most one consumer handle are + active per EventFlags value. Acquisition is fallible and non-panicking; a + second acquisition of an active role fails. +- **H2.** Handles are `Send + !Sync`. They may move between execution contexts + but may not be shared between them. An `&self` hot-path receiver does not + weaken this ownership rule. +- **H3.** Dropping a handle makes only its role re-acquirable without resetting + pending state. After reacquisition, all M/R/T/C/S clauses continue from the + existing state. +- **H4.** EventFlags is constructible in static storage on the normal build. + +## 8. Width and representation clauses (W) + +- **W1.** `EventMask` is a transparent, dependency-free `u32` set containing + exactly 32 conditions. Raw conversion is explicit and preserves every bit. +- **W2.** Constructing a one-condition mask from an external index is + panic-free: indices `0..32` succeed and all other values return `None`. + +## 9. Accepted candidate decisions + +- **H:** sole-role `Send + !Sync` producer/consumer handles with `&self` + operations, accepted once for EventFlags and CountedSignal on issue #30. +- **D2:** transparent `EventMask(u32)`. The exploratory enum mapping retained + runtime range checks and could not prove that two variants did not alias; + the transparent mask erased to the raw operation within two bytes. +- **D3:** exactly 32 conditions. A generic word width would expose + target-specific atomic availability and fallback cost as public API. +- **D4:** no non-clearing peek. An advisory snapshot invites check-then-act + reasoning that cannot be upheld across a concurrent take or raise. +- **D5:** no stream `Sink`/`Source`/`Link` implementation. Coalesced state is + not an item stream. Signal traits remain deferred: CountedSignal's + increment/count-snapshot vocabulary disproves the original single generic + `SignalSink`/`SignalSource` sketch as an honest shared surface. + +## 10. What the contract does not promise (X) + +- **X1.** FIFO order, timestamps, identity, or multiplicity for conditions. +- **X2.** Payload storage. S1 is publication for separately-owned memory. +- **X3.** More than 32 conditions or a target-dependent word width. +- **X4.** Multiple producers, multiple consumers, or shareable handles. +- **X5.** A non-clearing observation method or stream/signal trait vocabulary. +- **X6.** A universal wall-clock bound. B1-B3 are algorithmic guarantees; + instruction and interrupt-latency claims are environment-specific evidence. + +## 11. Evidence map + +| Clauses | Evidence on `candidate/event-flags` | +|---|---| +| M1, R1-R2, T1-T2, C1, C3, W1-W2 | `event_mask_is_an_explicit_panic_free_32_bit_set`, `duplicate_raises_coalesce_and_take_clears`, `multi_bit_and_all_bit_masks_round_trip`, and `empty_raise_and_empty_take_are_no_ops` | +| C1-C3 | Loom's `event_flags_raise_racing_take_is_partitioned_exactly` and `event_flags_distinct_raises_partition_across_takes`; native/Miri threaded stress | +| S1 | Loom's `event_flags_observed_raise_publishes_payload`; the model fails with either Release or Acquire independently weakened to Relaxed | +| B1-B3 | Source review; eight-target gated code-size rows; Cortex-M3 QEMU rows; `event-flags-atomic-window.sh` disassembly checks for thumbv6m and ESP32-S2/S3 | +| H1, H3 | `handles_are_exclusive_and_reusable_after_drop`, including pending-state continuation | +| H2 | `handles_are_send_and_container_is_sync`; producer and consumer compile-fail doctests pin `!Sync` | +| H4 | `const_new_works_in_static_context` | + +The candidate uses Release `fetch_or` and Acquire `swap(0)`. Those orderings +are implementation evidence for S1, not abstract operations added to the +contract. The mutation checks are recorded because bit conservation alone +cannot distinguish Relaxed from the accepted publication pair. diff --git a/docs/proposals/event-flags.md b/docs/proposals/event-flags.md index 169b107..e6dbe8d 100644 --- a/docs/proposals/event-flags.md +++ b/docs/proposals/event-flags.md @@ -1,305 +1,184 @@ -# EventFlags: coalesced condition notification (exploratory design document) +# EventFlags: coalesced condition notification -- **Status:** EXPLORATORY — design exploration vehicle, not yet PROPOSED. +- **Status:** PROPOSED — decisions, frozen contract, implementation, and + admission evidence complete; ready for evaluation. - **Origin:** substantive design text moved from the - [bounded-handoff taxonomy](exploratory-primitives.md) §4 (received - 2026-08-11); triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. -- **Taxonomy row:** retention *one bit per condition* · overload *repeated - signals coalesce* · representative use *ISR-to-task notification*. -- **Related:** [`counted-signal.md`](counted-signal.md) — same niche - (bounded ISR notification), different contract (multiplicity); the signal - trait vocabulary below is shared between the two. + [bounded-handoff taxonomy](exploratory-primitives.md) §4; triaged Tier 1 in + [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. +- **Contract:** [`event-flags-contract.md`](event-flags-contract.md) — stable + M/R/T/C/S/B/H/W/X clause IDs and the evidence map. +- **Related:** [`counted-signal.md`](counted-signal.md) — same bounded + ISR-notification niche, but retains multiplicity instead of coalescing it. -## 1. Design sketch (from the taxonomy) +## 1. Accepted design -Some producer-consumer communication is not a stream at all. +EventFlags is a fixed SPSC condition set for communication that is not an item +stream. An interrupt can raise conditions such as FIFO watermark, DMA complete, +peripheral error, configuration changed, or shutdown requested. Repeating one +condition before the task observes it adds no information. -An interrupt may need to communicate conditions such as: +```rust +const DATA_READY: EventMask = EventMask::from_bits(1 << 0); +const OVERFLOW: EventMask = EventMask::from_bits(1 << 1); -- FIFO watermark reached; -- DMA block complete; -- peripheral error; -- configuration changed; -- shutdown requested; -- watchdog warning; -- data available. +producer.raise(DATA_READY); +producer.raise(OVERFLOW); -Repeated notification of the same condition may have no additional value. An -atomic bitset is a natural primitive: +let pending = consumer.take_all(); +``` -```rust -producer.raise(Event::DataReady); -producer.raise(Event::Overflow); +The representation and operations are deliberately small: -let events = consumer.take_all(); -``` +- one transparent `EventMask(u32)`, exactly 32 conditions; +- one `AtomicU32` pending word; +- Release `fetch_or` to raise; +- Acquire `swap(0)` to take; +- one sole-role `Send + !Sync` producer and consumer, with `&self` hot paths; +- no allocation, queue capacity, non-clearing peek, stream traits, or signal + traits. -A possible representation is an `AtomicU32`: +Duplicate conditions coalesce. A raise racing a take is observed in that take +or remains pending for the next one; the atomic modification order cannot lose +it between the OR and clear. Release/Acquire additionally publishes +application memory written before the raise. -- producer uses `fetch_or`; -- consumer uses `swap(0)`; -- duplicate events coalesce; -- producer work is constant; -- no allocation or queue capacity is required. +## 2. Decisions closed -This is an excellent ISR-to-task primitive when ordering and multiplicity do -not matter. +### 2.1 Handles -Its contract must say explicitly: +Shared decision H was accepted for both signal lanes on issue #30: sole-role +`Send + !Sync` handles with `&self` operations. EventFlags' atomic OR could +support multiple raisers, but promising that here would force a different and +weaker CountedSignal algorithm. The conservative shared contract loses no SPSC +use case and leaves an independently-evidenced MPSC type possible later. -- conditions are unordered; -- duplicate raises may coalesce; -- each bit means "occurred at least once since the last take"; -- clearing and raising must not lose a concurrent event. +### 2.2 Condition representation and width -This is much narrower and more predictable than adding a general semaphore -abstraction. +The transparent mask wins over raw `u32`, an enum trait, and a macro-generated +namespace. It keeps domains distinct at the API boundary while compiling to +the same word operation. `EventMask::from_index` checks external indices +without a shift panic; applications can define named `const` masks without a +runtime mapping. -## 2. Trait sketches (moved from the taxonomy's trait directions) +The candidate stays exactly `u32`. A generic word trait would make atomic +availability, fallback behaviour, monomorphization, and code size part of the +public contract. `u64` in particular would not mean one native operation on +the 32-bit MCUs this crate supports. -### SignalSink and SignalSource +### 2.3 Observation and traits -```rust -pub trait SignalSink { - fn raise(&self, signal: S); -} +A non-clearing peek is omitted. Its result would be immediately advisory: a +take may clear it and a raise may follow it. That surface invites +check-then-act code with no guarantee behind it. + +EventFlags does not implement stream `Sink`/`Source`/`Link`; destructive take +plus a rejecting downstream sink could lose the mask, and coalescing cannot be +reported through the stream vocabulary. The initial `SignalSink` / +`SignalSource` sketch is also deferred. CountedSignal's `increment` and +count-plus-saturation snapshot prove that one shared generic `S` is not honest +for both primitives. + +## 3. Implementation and ordering + +The only condition-state operations are: -pub trait SignalSource { - fn take_pending(&self) -> S; -} +```text +raise(mask): pending.fetch_or(mask, Release) +take_all(): EventMask(pending.swap(0, Acquire)) ``` -These describe coalesced conditions rather than event streams. - -Per the crate's trait rule, they ship only with a primitive that -demonstrates their need — this one and [`CountedSignal`](counted-signal.md) -are the candidates, and the vocabulary should be proven against both before -it is frozen. - -## 3. Triage notes (0.3.0 cycle) - -- **The ISR-side cost must be measured, not assumed.** `fetch_or` and - `swap` are both interrupt-disable critical sections under portable-atomic - on thumbv6m / ESP32-S2 — cheap, but ISR latency is exactly what this - primitive exists to protect, so the cost lands in `cycles.sh` and - codesize rows before any claim is made. -- **`&self` operations are new ground for the crate.** The sketched - `raise(&self)` would be the first `&self`-operation primitive here; every - existing handle mutates through `&mut self`. This is a handle-model - question to settle at design time (see open questions), not a detail. -- **The lost-event clause is Loom material.** "Clearing and raising must - not lose a concurrent event" is satisfied by `fetch_or`/`swap(0)` on one - word — but the model, not the prose, is the evidence, and the model also - pins it against future "optimisation." - -## 4. Exploratory decision package (2026-08-11) - -The questions below remain decisions, not accidental consequences of the first -implementation that happens to compile. Three isolated prototypes were built -from the same `master` commit so the choices can be evaluated independently: - -| Prototype | Conditions | Handles | Consumer | Deliberate omissions | -|-----------|------------|---------|----------|----------------------| -| A — raw shared word | `u32` | `EventFlags` itself; unlimited `&self` callers | any caller may `take_all(&self)` | claims, peek, traits, width genericity | -| B — enum-driven MPSC | user `EventFlag::bit() -> u8`; checked at runtime | unlimited `Copy + Sync` `Raiser`s | one claimed `Send + !Sync` `Taker` | width genericity, stream traits | -| C — SPSC typed mask | transparent `EventMask(u32)` | claimed `Producer`, `Send + !Sync` | claimed `Consumer`, `Send + !Sync` | peek, traits, width genericity | - -All three use one `AtomicU32`, `fetch_or(Release)` to raise, and `swap(0, -Acquire)` to take. All are `no_std`, allocation-free, panic-free on their hot -paths, const/static constructible outside Loom, and add no normal dependency. -They are exploration vehicles, not three APIs proposed for shipment. - -### 4.1 Candidate contract - -These clauses are implementation-independent. Their IDs are candidates for -the permanent contract; once tests and user documentation cite them, they must -not be renumbered. - -- **M1 — Initial state.** The pending condition set is initially empty. -- **M2 — Linearization.** Every raise and take has one instant between its - invocation and return at which it takes effect. -- **R1 — Raise.** At `raise(m)`'s linearization point, pending becomes the set - union of its prior value and `m`; raising the empty set changes nothing. -- **R2 — Coalescing.** Pending records only whether each condition occurred. - Duplicate raises may coalesce; multiplicity is not observable. -- **T1 — Take.** A take returns exactly the pending set immediately before its - linearization point and makes pending empty at that point. -- **T2 — Empty take.** A take linearized while pending is empty returns the - empty set and changes no observable state. -- **C1 — Window exactness.** For each condition, a take contains it if and only - if at least one matching raise linearized after the preceding take and before - this take (or after initialization for the first take). -- **C2 — Concurrent boundary.** A raise racing a take is ordered by their - linearization points. If the raise is first, the condition is in that take; - otherwise it remains pending for a later take. Clearing never erases the - later raise. -- **C3 — No fabrication.** A take returns no condition absent a matching raise - in its take window. -- **O1 — Unordered.** There is no FIFO, timestamp, multiplicity, or - cross-condition ordering guarantee. -- **S1 — Publication.** Memory actions sequenced before a raise happen-before - memory actions sequenced after a take that observes that raise. This is why - the prototypes use Release/Acquire rather than Relaxed operations. -- **B1 — Bounded hot paths.** Raise and take each perform one signal-word - operation. Neither contains a retry loop, allocation, callback, panic path, - or work proportional to history, occupancy, or the number of set bits. -- **B2 — Independence.** Producer work never waits for or invokes consumer - work, and consumer work never waits for or invokes producer work. - -Handle clauses depend on D1 and therefore cannot yet be frozen. The -conservative candidate is: at most one active handle per role; acquisition is -fallible and non-panicking; handles are `Send + !Sync`; dropping a handle makes -only that role re-acquirable; pending state survives drop/reacquisition; the -container is const/static constructible. - -### 4.2 D1 — handle and concurrency model (deferred) - -`raise(&self)` does **not** by itself imply a shareable handle. Existing crate -handles already use `&self` hot-path methods while `PhantomData>` -makes the handle `!Sync`. The auto traits, not receiver mutability, define the -concurrency contract. - -| Option | Predictability | Efficiency | Shared answer with `CountedSignal` | Cost / risk | -|--------|----------------|------------|------------------------------------|-------------| -| A: direct shared container | Atomic operations are sound with many raisers and takers, but multiple takers distribute observations nondeterministically | smallest state and API; no claim acquisition | poor — it would commit the vocabulary to contention before bounded exact saturation is solved | no exclusive observer; expands Loom and publication obligations | -| B: many raisers, one taker | one observation owner; natural fit for atomic OR | raiser is zero-claim and copyable | uncertain — exact saturating multi-raiser increment is the unresolved core of `CountedSignal` | asymmetric first non-SPSC contract | -| C: one handle per role | matches existing ownership and makes the smallest shared promise | two claim bits and one extra handle indirection; hot word operation remains one RMW | strongest — does not force `CountedSignal` to promise a bounded contended increment | leaves hardware-supported EventFlags concurrency unused | - -The current recommendation is **C**, deliberately conservative. A future MPSC -type can be added after independent evidence; an MPSC promise cannot be taken -back compatibly. This is a recommendation for the shared planning decision, -not a decision silently made on this branch. - -### 4.3 D2 — condition representation (deferred) - -| Option | Strength | Runtime / flash expectation | API and regression burden | -|--------|----------|-----------------------------|---------------------------| -| raw `u32` | exact mechanism, but unrelated domains and arbitrary bits mix freely | baseline minimum | smallest surface, weakest semantic boundary | -| transparent `EventMask(u32)` | makes set operations explicit; checked constructors can prevent shift panics; named application constants remain possible | should erase to the raw mask; preliminary results are within 2 bytes | small hand-rolled surface, no dependency | -| `EventFlag` enum trait | strong call-site vocabulary and namespace | checked shift/validation is visible in the hot path | user owns uniqueness/range correctness; largest docs and semver surface | -| const-generic/enum macro | can recover named ergonomics at compile time | must be proved per expansion/monomorphization | macro diagnostics, generated docs, and a much larger evidence matrix | - -The current recommendation is the transparent mask, with raw conversion kept -explicit and panic-free. Enum ergonomics can be layered on later by a macro if -real callers demonstrate that it earns the surface. The prototype exposed an -important limit: `EventFlag::bit()` can validate range, but cannot prove that -two enum variants do not alias the same bit. - -### 4.4 D3 — width (deferred, recommendation: `u32` only) - -`u32` matches the crate's existing atomic shim and sequence width and provides -one contract on every shipped 32-bit target. A generic atomic-word trait would -expose target-dependent atomic availability, fallback behaviour, code size, -and monomorphization as public API. `u64` is particularly misleading on these -targets: “one word” need not mean one native or one critical-section operation. - -The admission candidate should therefore provide exactly 32 conditions. -Additional named widths remain possible later, but only with a concrete user -and a separate Loom/Miri/codesize/cycles matrix. - -### 4.5 D4 — non-clearing observation (deferred, recommendation: omit) - -An atomic load is race-free and linearizable as a snapshot, but the result is -intrinsically advisory: another take may clear it immediately and a concurrent -raise may arrive immediately after it. A `peek` name invites check-then-act -reasoning that the primitive cannot uphold. Omit it from the initial surface. -If demonstrated demand later earns it, call it `snapshot_pending` or -`load_pending` and state explicitly that it predicts no later take. - -### 4.6 D5 — traits (deferred, recommendation: keep disjoint) - -Do not implement stream `Sink`/`Source`/`Link`. Coalesced state is not an item -stream, and `forward()` could destructively take a mask and then lose it when a -destination rejects the value. It also cannot report per-condition -coalescing. - -Do not freeze `SignalSink`/`SignalSource` yet. `CountedSignal` has not shown -that `raise(S) -> take_pending() -> S` is honest vocabulary: its producer -operation is an increment and its take likely returns a count-plus-saturation -report. If both primitives survive, associated `Signal` and `Pending` types on -traits implemented by handles are a better direction than one shared generic -`S`, but the second implementation must prove it first. - -## 5. Preliminary implementation evidence - -### 5.1 Behaviour and build checks - -The isolated prototypes were checked without modifying the candidate branch: - -| Prototype | Native tests | Additional checks | -|-----------|--------------|-------------------| -| A — raw shared word | 74 unit + 11 doctests + 3 compile-fail | fmt, clippy, threaded multi-raiser and raise-vs-take stress | -| B — enum-driven MPSC | 73 unit + 15 doctests | fmt, clippy, `thumbv7em`, zero normal dependencies, focused Loom raise-vs-take model | -| C — SPSC typed mask | 75 unit + 11 doctests + 3 compile-fail | fmt, clippy, `thumbv7em`, focused Loom raise-vs-take model | - -These checks establish that each API shape is viable. The focused model checks -only C2 for the prototype; it is not the complete evidence map below. - -### 5.2 Exploratory hot-path code size - -Minimal `opt-level = "z"`, LTO static-library probes were built with the pinned -rustc for three representative installed targets. Each row is the isolated -`.text.` size in bytes; handle acquisition and application call-site -code are excluded. - -| Target | raw raise / take | enum MPSC raise / take | SPSC mask raise / take | -|--------|-----------------:|-----------------------:|-----------------------:| -| `thumbv6m-none-eabi` (portable-atomic single-core) | 22 / 24 | 38 / 24 | 24 / 24 | -| `thumbv7m-none-eabi` | 24 / 26 | 36 / 26 | 26 / 26 | -| `riscv32imac-unknown-none-elf` | 6 / 6 | 20 / 8 | 8 / 8 | - -This is decision evidence, not an admission measurement: it covers three of -the eleven target rows, uses standalone probes rather than the committed gate, -and does not measure interrupt-disabled duration. It nevertheless answers two -questions usefully: - -1. The transparent mask erases as intended; the SPSC handle costs only one - extra pointer load in these non-inlined boundary probes (2 bytes per hot - function). -2. The enum trait's range check and mapping remain visible: raise is 12–16 - bytes larger than the SPSC mask and 14 bytes larger than raw on the measured - targets. Ergonomics is therefore not free on the ISR path. - -No cycle count is recorded here. Local QEMU was unavailable, and the existing -reference probe measures Cortex-M3 rather than the portable-atomic critical -section that carries this primitive's admission case. Treating a host or one -native-atomic number as closure would violate the reason this issue exists. - -## 6. Evidence map required before promotion - -- **Contract/unit:** M1–T2, empty and all-bit masks, bit 31, duplicate and - multi-bit raises, no fabrication, take clears, claim failure, static handles, - and drop/reacquisition preserving pending state. -- **Loom C1–C3:** raise racing take is observed now or later but never neither; - distinct raises partition exactly across takes plus final pending; duplicates - coalesce; no condition is fabricated or returned twice without re-raise. -- **Loom S1:** a separate payload-publication litmus. Mutating either Release or - Acquire to Relaxed must make this model fail; the bit-conservation model alone - cannot distinguish the orderings. -- **Miri:** detector-on unit/stress and handle transfer/reacquisition, including - a 32-bit std target. There is no unsafe slot access here, so EventFlags must - not inherit the `SeqRing` detector exception. -- **Code size:** committed rows for acquisition, raise, and empty/non-empty take - across all eight gated upstream targets plus the three opt-in Xtensa rows; - compare the chosen wrapper with a raw atomic baseline and confirm no panic - strings or startup data. -- **Cycles/latency:** reference-image Cortex-M3 counts for raise with a clear and - already-set bit and take when empty/non-empty; plus target-credible - instruction and maximum interrupt-disabled-window measurements for - thumbv6m and ESP32-S2/S3 portable-atomic paths. Verify exactly one RMW and no - hidden compare-exchange loop. -- **Integration:** full `verify.sh` with zero skips; README, crate docs, - CHANGELOG, AGENTS memory-ordering notes and test counts; `Cargo.toml` include - allowlist; normal `cargo tree` still the crate alone. - -## 7. Promotion bar to PROPOSED - -1. Record D1 and D2 as shared planning decisions; D1 is one answer for this - primitive and `CountedSignal`. -2. Confirm or change the recommendations for D3–D5 explicitly. -3. Freeze the accepted subset of the §4.1 clauses and handle clauses. -4. Implement the §6 evidence map. The portable-atomic ISR measurement is the - admission case; Loom/Miri close the semantic and publication clauses. - -Until steps 1–3 are explicit, status remains **EXPLORATORY** and the three -prototypes remain options rather than a de facto public API. +Each atomic RMW is a linearization point. For a racing pair, atomic +modification order is either OR-before-swap or swap-before-OR, giving the +current or following take window respectively. No read/compute/write split +exists that could clear a later raise. + +Release/Acquire is load-bearing for publication, not conservation. Loom's +publication litmus passes with the accepted pair and fails with the expected +stale payload when either operation is independently weakened to Relaxed. The +two partition models continue to pass under Relaxed, which is why they cannot +stand in for the publication test. + +## 4. Admission evidence + +### 4.1 Behaviour, Loom, and Miri + +- 78 unit tests exercise empty/all masks, bit 31, checked index construction, + duplicate and multi-bit raises, clear-on-take, role acquisition, static + construction, threaded take races, and publication. +- Three EventFlags Loom models exhaustively cover one-raise and two-distinct- + raise take partitions plus the Release/Acquire publication litmus. +- Producer and consumer compile-fail doctests pin `!Sync`; ordinary type tests + pin `Send`, container `Sync`, and the transparent four-byte mask. +- EventFlags contains no unsafe slot access and passes the normal detector-on + Miri path, including the repository's 32-bit proxy target. + +### 4.2 Code size + +The committed gate measures acquisition plus the two hot functions under +rustc 1.92.0 (`ded5c06cf`). Take is branch-free, so one row is both its empty +and non-empty code size. + +| Target | acquire roles | raise | take | +|---|---:|---:|---:| +| thumbv6m | 68 | 24 | 24 | +| thumbv8m.base | 64 | 22 | 22 | +| thumbv7m | 72 | 26 | 26 | +| thumbv7em | 72 | 26 | 26 | +| thumbv8m.main | 64 | 22 | 22 | +| armv7r | 116 | 32 | 32 | +| armv7a | 116 | 32 | 32 | +| riscv32imac | 66 | 8 | 8 | +| ESP32 (opt-in) | 145 | 37 | 36 | +| ESP32-S2 (opt-in) | 68 | 23 | 22 | +| ESP32-S3 (opt-in) | 145 | 37 | 36 | + +`EventFlags` itself is 12 bytes: pending plus two role-claim atomic words. The +existing static buffer remains in `.bss`, and the candidate adds no `.data`, +runtime dependency, allocation, or panic string to either hot path. + +### 4.3 Cortex-M3 instruction counts + +Measured by `./scripts/verify.sh cycles` in the pinned reference image: rustc +1.92.0, QEMU 10.0.11, Cortex-M3. + +| Operation | State | Retired instructions | +|---|---|---:| +| `raise` | condition clear | 12 | +| `raise` | condition already set | 12 | +| `take_all` | non-empty | 8 | +| `take_all` | empty | 8 | + +The equal state pairs pin the intended constant hot path: neither occupancy +nor number of set bits changes the executed work. + +### 4.4 Portable-atomic interrupt window + +`scripts/event-flags-atomic-window.sh` compiles and disassembles the committed +probe. Counts are instructions after the architectural disable instruction +through restore/synchronization. + +| Target | raise | take | Generated path | +|---|---:|---:|---| +| thumbv6m | 4 | 4 | PRIMASK: load, update/store, restore; straight-line | +| ESP32-S2 | 5 | 5 | PS.INTLEVEL=15: load, update/store, `wsr.ps`, `rsync`; straight-line | +| ESP32-S3 | 0 | 0 | Native `s32c1i`; the esp-rs target advertises 32-bit atomics and does not mask interrupts | + +The thumbv6m and S2 paths each contain exactly one interrupt-masked RMW and no +branch or hidden compare-exchange loop. The S3 result corrects the exploratory +assumption that S2 and S3 share a fallback: under esp-rs 1.95.0-nightly, S3 is +a native-atomic target, so its maximum interrupt-disabled window is zero. + +## 5. Promotion result + +All four promotion-bar items are closed: + +1. shared H and EventFlags' transparent-mask decision recorded; +2. 32 conditions, no peek, disjoint stream traits, and deferred signal traits + explicitly confirmed; +3. accepted clauses frozen in `event-flags-contract.md`; +4. unit/threaded, Loom mutation, Miri, code-size, Cortex-M3, and portable- + atomic window evidence implemented. + +The lane is therefore **PROPOSED** and ready for candidate evaluation. This is +not acceptance into the release: evaluation may still reject the primitive on +API fit or measured cost, with the evidence retained either way. diff --git a/docs/proposals/exploratory-primitives.md b/docs/proposals/exploratory-primitives.md index a7899a8..85ad04b 100644 --- a/docs/proposals/exploratory-primitives.md +++ b/docs/proposals/exploratory-primitives.md @@ -96,8 +96,9 @@ the design document; DMA cache maintenance stays outside the crate. ## 4. EventFlags: coalesced condition notification -**Design document: [`event-flags.md`](event-flags.md)** (the substantive -design text has moved there; this entry is the summary). +**Status: PROPOSED.** The frozen semantic clauses are in +[`event-flags-contract.md`](event-flags-contract.md), and the implementation +and admission evidence are in [`event-flags.md`](event-flags.md). Some producer-consumer communication is not a stream at all: an ISR signalling conditions (watermark reached, DMA complete, error, shutdown) @@ -109,6 +110,11 @@ least once since the last take," and clearing must never lose a concurrent raise. Much narrower and more predictable than a general semaphore abstraction. +The implemented candidate uses fallible SPSC role acquisition with +`Send + !Sync` handles. `raise` is one Release `fetch_or`; `take_all` is one +Acquire `swap(0)`. It remains outside the stream traits because its value is a +destructively taken, unordered condition set rather than an event item. + ## 5. CountedSignal: multiplicity without payloads **Design document: [`counted-signal.md`](counted-signal.md)** (the @@ -312,7 +318,7 @@ sketches live in the owning documents; this table is the index: |-------|-----------------|------| | `LatestSink`, `LatestSource` | [`latest-buf.md`](latest-buf.md) §12 | Producers that always publish but may replace an unread value; freshness sources that report skips | | `ObservedSource` | [`latest-buf.md`](latest-buf.md) §12.3 | Cross-primitive vocabulary, gated on two honest implementations. **Known divergence:** this taxonomy originally sketched a wider `DeliveryObservation` (`ordinal`, `skipped_before`, `filtered_before`) than the proposal's (`sequence`, `skipped_before`); the `filtered_before` field earns its place only if the filtering adapters (§8) ship. Reconcile at the two-implementations gate, not before | -| `SignalSink`, `SignalSource` | [`event-flags.md`](event-flags.md) §2 | Coalesced conditions, not streams; to be proven against both `EventFlags` and `CountedSignal` before the vocabulary freezes | +| `SignalSink`, `SignalSource` | [`event-flags.md`](event-flags.md) §2 | Rejected for EventFlags: the mask is a destructive unordered take, not an event item. Revisit only if a second primitive demonstrates honest shared semantics. | | `ReservableSink`, `ClaimSource` | [`slot-pool.md`](slot-pool.md) §2 | Grant/claim lifecycles for zero-copy transfer | | `Sequenced`, `Timestamped` | [`latest-buf.md`](latest-buf.md) §12.4 | Optional payload-metadata conveniences: generic consumers inspect producer-owned identity without the crate prescribing sequence width, time units, or a clock | diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 4665d38..8bd7f74 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -13,8 +13,9 @@ # from ARM, where both are ldrex/strex. # - Xtensa splits code between `.text.` and `.literal.`. Counting only # `.text` undercounts it (212 vs 220 bytes for the same function on esp32). -# - ESP32-S2/S3 are single-core Xtensa without the S32C1I compare-and-swap -# instruction, so they need portable-atomic exactly like Cortex-M0+. +# - ESP32-S2 lacks native 32-bit RMWs and uses portable-atomic's masked path. +# The measured esp-rs ESP32-S3 target advertises 32-bit atomics and emits +# native S32C1I. Keep the two rows separate and verify their disassembly. # # Tooling is whatever rust-toolchain.toml pins. `llvm-size` ships with the # `llvm-tools` component, which that file already declares, so there is nothing @@ -114,8 +115,10 @@ fn_size() { RESULTS="$(mktemp)" trap 'rm -f "$RESULTS"' EXIT -printf '%-30s %10s %8s %8s %6s\n' TARGET two_calls split bss data -printf '%-30s %10s %8s %8s %6s\n' '------------------------------' '---------' '-----' '---' '----' +printf '%-30s %10s %8s %8s %8s %8s %8s %6s\n' \ + TARGET two_calls split flags_acq flags_raise flags_take bss data +printf '%-30s %10s %8s %8s %8s %8s %8s %6s\n' \ + '------------------------------' '---------' '-----' '---------' '-----------' '----------' '---' '----' skipped=0 failed=0 @@ -162,16 +165,23 @@ for entry in $TARGETS; do ar="scripts/codesize/target/$target/release/libph_eventing_codesize.a" two="$(fn_size "$ar" bringup_two_calls)" spl="$(fn_size "$ar" bringup_split)" + flags_acq="$(fn_size "$ar" event_flags_acquire_roles)" + flags_raise="$(fn_size "$ar" event_flags_raise)" + flags_take="$(fn_size "$ar" event_flags_take)" bss="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.bss\..*3BUF/ { print $2; exit }')" dat="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.data\./ { s += $2 } END { print s + 0 }')" - printf '%-30s %10s %8s %8s %6s\n' \ - "$target" "${two:--}" "${spl:--}" "${bss:--}" "${dat:-0}" + printf '%-30s %10s %8s %8s %8s %8s %8s %6s\n' \ + "$target" "${two:--}" "${spl:--}" "${flags_acq:--}" \ + "${flags_raise:--}" "${flags_take:--}" "${bss:--}" "${dat:-0}" # Xtensa is never gated: it needs a toolchain fork, so making it a hard gate # would make that fork mandatory for every contributor. case "$target" in xtensa-*) continue ;; esac [ -n "$two" ] && printf '%s\ttwo_calls\t%s\n' "$target" "$two" >> "$RESULTS" + [ -n "$flags_acq" ] && printf '%s\tevent_flags_acquire\t%s\n' "$target" "$flags_acq" >> "$RESULTS" + [ -n "$flags_raise" ] && printf '%s\tevent_flags_raise\t%s\n' "$target" "$flags_raise" >> "$RESULTS" + [ -n "$flags_take" ] && printf '%s\tevent_flags_take\t%s\n' "$target" "$flags_take" >> "$RESULTS" [ -n "$bss" ] && printf '%s\tbss\t%s\n' "$target" "$bss" >> "$RESULTS" printf '%s\tdata\t%s\n' "$target" "${dat:-0}" >> "$RESULTS" done diff --git a/scripts/codesize/baseline.tsv b/scripts/codesize/baseline.tsv index 5c1c565..ca05136 100644 --- a/scripts/codesize/baseline.tsv +++ b/scripts/codesize/baseline.tsv @@ -9,25 +9,49 @@ # would make that fork mandatory for every contributor. armv7a-none-eabi bss 268 armv7a-none-eabi data 0 +armv7a-none-eabi event_flags_acquire 116 +armv7a-none-eabi event_flags_raise 32 +armv7a-none-eabi event_flags_take 32 armv7a-none-eabi two_calls 220 armv7r-none-eabi bss 268 armv7r-none-eabi data 0 +armv7r-none-eabi event_flags_acquire 116 +armv7r-none-eabi event_flags_raise 32 +armv7r-none-eabi event_flags_take 32 armv7r-none-eabi two_calls 220 riscv32imac-unknown-none-elf bss 268 riscv32imac-unknown-none-elf data 0 +riscv32imac-unknown-none-elf event_flags_acquire 66 +riscv32imac-unknown-none-elf event_flags_raise 8 +riscv32imac-unknown-none-elf event_flags_take 8 riscv32imac-unknown-none-elf two_calls 152 thumbv6m-none-eabi bss 268 thumbv6m-none-eabi data 0 +thumbv6m-none-eabi event_flags_acquire 68 +thumbv6m-none-eabi event_flags_raise 24 +thumbv6m-none-eabi event_flags_take 24 thumbv6m-none-eabi two_calls 156 thumbv7em-none-eabi bss 268 thumbv7em-none-eabi data 0 -thumbv7em-none-eabi two_calls 180 +thumbv7em-none-eabi event_flags_acquire 72 +thumbv7em-none-eabi event_flags_raise 26 +thumbv7em-none-eabi event_flags_take 26 +thumbv7em-none-eabi two_calls 172 thumbv7m-none-eabi bss 268 thumbv7m-none-eabi data 0 -thumbv7m-none-eabi two_calls 180 +thumbv7m-none-eabi event_flags_acquire 72 +thumbv7m-none-eabi event_flags_raise 26 +thumbv7m-none-eabi event_flags_take 26 +thumbv7m-none-eabi two_calls 172 thumbv8m.base-none-eabi bss 268 thumbv8m.base-none-eabi data 0 +thumbv8m.base-none-eabi event_flags_acquire 64 +thumbv8m.base-none-eabi event_flags_raise 22 +thumbv8m.base-none-eabi event_flags_take 22 thumbv8m.base-none-eabi two_calls 156 thumbv8m.main-none-eabi bss 268 thumbv8m.main-none-eabi data 0 +thumbv8m.main-none-eabi event_flags_acquire 64 +thumbv8m.main-none-eabi event_flags_raise 22 +thumbv8m.main-none-eabi event_flags_take 22 thumbv8m.main-none-eabi two_calls 152 diff --git a/scripts/codesize/src/lib.rs b/scripts/codesize/src/lib.rs index 36f38d3..87f3491 100644 --- a/scripts/codesize/src/lib.rs +++ b/scripts/codesize/src/lib.rs @@ -6,7 +6,8 @@ #![no_std] use core::panic::PanicInfo; -use ph_eventing::EventBuf; +use ph_eventing::event_flags::{Consumer as FlagsConsumer, Producer as FlagsProducer}; +use ph_eventing::{EventBuf, EventFlags, EventMask}; #[panic_handler] fn panic(_: &PanicInfo) -> ! { @@ -17,6 +18,9 @@ fn panic(_: &PanicInfo) -> ! { /// no flash and no startup code. Measured as `.bss.*BUF`. static BUF: EventBuf = EventBuf::new(); +/// EventFlags is three atomic words: pending plus the two role claims. +static FLAGS: EventFlags = EventFlags::new(); + #[cfg(feature = "split")] static SPLIT_BUF: EventBuf = EventBuf::new(); @@ -40,6 +44,50 @@ pub extern "C" fn bringup_two_calls() -> i32 { } } +/// Acquire both EventFlags roles without including either hot-path operation. +#[unsafe(no_mangle)] +pub extern "C" fn event_flags_acquire_roles() -> i32 { + let producer = match FLAGS.try_producer() { + Some(producer) => producer, + None => return -1, + }; + let consumer = match FLAGS.try_consumer() { + Some(consumer) => consumer, + None => return -2, + }; + + // The probe is never executed; forgetting keeps Drop's role-release stores + // out of the acquisition section so the row attributes only acquisition. + core::mem::forget(producer); + core::mem::forget(consumer); + 0 +} + +/// Raise through an already-acquired EventFlags producer. +/// +/// # Safety +/// `producer` must point to a live producer handle for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn event_flags_raise( + producer: *const FlagsProducer<'static>, + bits: u32, +) { + // SAFETY: required by this function's contract. + unsafe { &*producer }.raise(EventMask::from_bits(bits)); +} + +/// Take through an already-acquired EventFlags consumer. +/// +/// This one branch-free function is the empty and non-empty take code-size row. +/// +/// # Safety +/// `consumer` must point to a live consumer handle for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn event_flags_take(consumer: *const FlagsConsumer<'static>) -> u32 { + // SAFETY: required by this function's contract. + unsafe { &*consumer }.take_all().bits() +} + /// Bring-up via a single `try_split`. Only present on branches that have it. #[cfg(feature = "split")] #[unsafe(no_mangle)] diff --git a/scripts/cycles.sh b/scripts/cycles.sh index d46c08c..ca04f40 100755 --- a/scripts/cycles.sh +++ b/scripts/cycles.sh @@ -196,6 +196,7 @@ report="$(awk ' if (group == "eb") printf "\nEventBuf (backpressure SPSC)\n" else if (group == "sr") printf "\nSeqRing (overwrite SPSC)\n" else if (group == "rb") printf "\nRingBuf (single owner)\n" + else if (group == "ef") printf "\nEventFlags (coalesced SPSC conditions)\n" } } next diff --git a/scripts/cycles/src/main.rs b/scripts/cycles/src/main.rs index 72c6b31..83c498a 100644 --- a/scripts/cycles/src/main.rs +++ b/scripts/cycles/src/main.rs @@ -26,7 +26,7 @@ use core::hint::black_box; use cortex_m_rt::entry; use cortex_m_semihosting::debug; -use ph_eventing::{EventBuf, RingBuf, SeqRing}; +use ph_eventing::{EventBuf, EventFlags, EventMask, RingBuf, SeqRing}; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { @@ -83,6 +83,11 @@ markers! { 17 => m_rb_get, 18 => m_rb_latest, 19 => m_sr_poll_lagged_far, + // EventFlags -- coalesced SPSC conditions + 20 => m_ef_raise_clear, + 21 => m_ef_raise_already_set, + 22 => m_ef_take_nonempty, + 23 => m_ef_take_empty, } fn event_buf_costs() { @@ -197,6 +202,29 @@ fn ring_buf_costs() { m_end(); } +fn event_flags_costs() { + let flags = EventFlags::new(); + let tx = flags.try_producer().expect("producer"); + let rx = flags.try_consumer().expect("consumer"); + let condition = EventMask::from_bits(1 << 7); + + m_ef_raise_clear(); + tx.raise(black_box(condition)); + m_end(); + + m_ef_raise_already_set(); + tx.raise(black_box(condition)); + m_end(); + + m_ef_take_nonempty(); + black_box(rx.take_all()); + m_end(); + + m_ef_take_empty(); + black_box(rx.take_all()); + m_end(); +} + #[entry] fn main() -> ! { // Two adjacent markers: the cost of the markers themselves, subtracted @@ -207,6 +235,7 @@ fn main() -> ! { event_buf_costs(); seq_ring_costs(); ring_buf_costs(); + event_flags_costs(); debug::exit(debug::EXIT_SUCCESS); loop {} diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh new file mode 100755 index 0000000..7eab795 --- /dev/null +++ b/scripts/event-flags-atomic-window.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env sh +# Measure EventFlags' interrupt-disabled window on the targets that carry its +# ISR-latency admission case. +# +# This complements cycles.sh. QEMU's Cortex-M3 is useful for whole-operation +# instruction counts, but it cannot exercise the portable-atomic fallback used +# by Cortex-M0 and ESP32-S2. This script builds the committed code-size probe, +# extracts its two EventFlags hot-path functions, and checks the exact masked +# instruction sequences in their disassembly. +# +# ESP32-S3 is deliberately included because the exploratory proposal grouped +# it with S2. The esp-rs target currently advertises native 32-bit atomics and +# emits S32C1I, so its measured interrupt-disabled window is zero. The check +# pins that compiler fact rather than repeating the old assumption. + +set -u + +cd "$(dirname "$0")/.." || exit 1 + +CARGO_INCREMENTAL=0 +export CARGO_INCREMENTAL + +host="$(rustc -vV | sed -n 's/^host: //p')" +sysroot="$(rustc --print sysroot)" || exit 1 +llvm_ar="$sysroot/lib/rustlib/$host/bin/llvm-ar" +llvm_objdump="$sysroot/lib/rustlib/$host/bin/llvm-objdump" +[ -x "$llvm_ar" ] || llvm_ar="${llvm_ar}.exe" +[ -x "$llvm_objdump" ] || llvm_objdump="${llvm_objdump}.exe" + +if [ ! -x "$llvm_ar" ] || [ ! -x "$llvm_objdump" ]; then + printf 'error: llvm-tools missing (rustup component add llvm-tools)\n' >&2 + exit 1 +fi + +if ! rustc +esp --print target-list 2>/dev/null | grep -qx xtensa-esp32s2-none-elf; then + printf 'SKIP: the esp-rs toolchain is not installed (install with espup).\n' + printf 'A SKIP is not a pass -- this script carries the S2/S3 admission rows.\n' + exit 2 +fi + +for tool in xtensa-esp32s2-elf-objdump xtensa-esp32s3-elf-objdump; do + if ! command -v "$tool" >/dev/null 2>&1; then + printf 'SKIP: %s is not on PATH (run espup install/export).\n' "$tool" + printf 'A SKIP is not a pass -- this script carries the S2/S3 admission rows.\n' + exit 2 + fi +done + +printf '==> rustc %s\n' "$(rustc -vV | sed -n 's/^release: //p')" +printf '==> esp-rs %s\n' "$(rustc +esp -vV | sed -n 's/^release: //p')" +printf '==> building thumbv6m and ESP32-S2/S3 probes\n' + +cargo build --release --target thumbv6m-none-eabi \ + --manifest-path scripts/codesize/Cargo.toml --features cm0 >/dev/null +cargo +esp build --release --target xtensa-esp32s2-none-elf \ + --manifest-path scripts/codesize/Cargo.toml --features cm0 \ + -Zbuild-std=core >/dev/null +cargo +esp build --release --target xtensa-esp32s3-none-elf \ + --manifest-path scripts/codesize/Cargo.toml --features cm0 \ + -Zbuild-std=core >/dev/null + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +extract_probe_object() { + archive="$1" + output="$2" + member="$($llvm_ar t "$archive" | sed -n '/ph_eventing_codesize.*cgu\.0\.rcgu\.o/{p;q;}')" + if [ -z "$member" ]; then + printf 'error: probe object not found in %s\n' "$archive" >&2 + exit 1 + fi + "$llvm_ar" p "$archive" "$member" > "$output" +} + +arm_archive="scripts/codesize/target/thumbv6m-none-eabi/release/libph_eventing_codesize.a" +s2_archive="scripts/codesize/target/xtensa-esp32s2-none-elf/release/libph_eventing_codesize.a" +s3_archive="scripts/codesize/target/xtensa-esp32s3-none-elf/release/libph_eventing_codesize.a" + +extract_probe_object "$arm_archive" "$tmp_dir/thumbv6m.o" +extract_probe_object "$s2_archive" "$tmp_dir/esp32s2.o" +extract_probe_object "$s3_archive" "$tmp_dir/esp32s3.o" + +"$llvm_objdump" -d "$tmp_dir/thumbv6m.o" > "$tmp_dir/thumbv6m.txt" +xtensa-esp32s2-elf-objdump -d "$tmp_dir/esp32s2.o" > "$tmp_dir/esp32s2.txt" +xtensa-esp32s3-elf-objdump -d "$tmp_dir/esp32s3.o" > "$tmp_dir/esp32s3.txt" + +# Count instructions after the disable instruction through the architectural +# restore/synchronization instruction. This is the maximum interval for which +# a previously-enabled interrupt remains masked by the operation. +masked_count() { + file="$1" + symbol="$2" + start="$3" + finish="$4" + awk -v symbol="$symbol" -v start="$start" -v finish="$finish" ' + $0 ~ "<" symbol ">:" { in_fn = 1; next } + in_fn && /^$/ { in_fn = 0 } + in_fn && $0 ~ /^[[:space:]]*[0-9a-f]+:/ { + if ($0 ~ start) { masked = 1; next } + if (masked) { + count++ + if ($0 ~ finish) { print count; exit } + } + } + ' "$file" +} + +arm_raise="$(masked_count "$tmp_dir/thumbv6m.txt" event_flags_raise 'cpsid[[:space:]]+i' 'msr[[:space:]]+primask')" +arm_take="$(masked_count "$tmp_dir/thumbv6m.txt" event_flags_take 'cpsid[[:space:]]+i' 'msr[[:space:]]+primask')" +s2_raise="$(masked_count "$tmp_dir/esp32s2.txt" event_flags_raise 'rsil.*15' 'rsync')" +s2_take="$(masked_count "$tmp_dir/esp32s2.txt" event_flags_take 'rsil.*15' 'rsync')" + +if [ "$arm_raise" != 4 ] || [ "$arm_take" != 4 ]; then + printf 'error: thumbv6m masked window changed (raise=%s take=%s, expected 4/4).\n' \ + "${arm_raise:--}" "${arm_take:--}" >&2 + exit 1 +fi +if [ "$s2_raise" != 5 ] || [ "$s2_take" != 5 ]; then + printf 'error: ESP32-S2 masked window changed (raise=%s take=%s, expected 5/5).\n' \ + "${s2_raise:--}" "${s2_take:--}" >&2 + exit 1 +fi + +# Both portable paths must remain straight-line. Any branch in a hot function +# means the claimed single critical section has acquired a retry/dispatch path. +if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/thumbv6m.txt" \ + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]]b[a-z]*[[:space:]]'; then + printf 'error: thumbv6m EventFlags hot path contains a branch.\n' >&2 + exit 1 +fi +if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/esp32s2.txt" \ + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]]b[a-z]*[[:space:]]'; then + printf 'error: ESP32-S2 EventFlags hot path contains a branch.\n' >&2 + exit 1 +fi + +if grep -Eq 'rsil|wsr\.ps' "$tmp_dir/esp32s3.txt"; then + printf 'error: ESP32-S3 unexpectedly masks interrupts in EventFlags.\n' >&2 + exit 1 +fi +if [ "$(grep -c 's32c1i' "$tmp_dir/esp32s3.txt")" -lt 2 ]; then + printf 'error: ESP32-S3 no longer emits native S32C1I for both hot paths.\n' >&2 + exit 1 +fi + +printf '\n%-18s %8s %8s %s\n' TARGET raise take implementation +printf '%-18s %8s %8s %s\n' '------------------' '-----' '----' '--------------' +printf '%-18s %8s %8s %s\n' thumbv6m "$arm_raise" "$arm_take" \ + 'PRIMASK critical section' +printf '%-18s %8s %8s %s\n' esp32-s2 "$s2_raise" "$s2_take" \ + 'PS.INTLEVEL=15 critical section' +printf '%-18s %8s %8s %s\n' esp32-s3 0 0 \ + 'native S32C1I; no interrupt masking' + +printf '\nCounts are instructions after interrupt disable through restore/sync.\n' +printf 'Both portable paths are straight-line and contain exactly one read,\n' +printf 'one update/store sequence, and no branch or compare-exchange loop.\n' diff --git a/scripts/loom.sh b/scripts/loom.sh index 2d5c325..95ecc32 100755 --- a/scripts/loom.sh +++ b/scripts/loom.sh @@ -35,7 +35,14 @@ export LOOM_MAX_PREEMPTIONS printf '==> loom (max_preemptions=%s)\n' "$LOOM_MAX_PREEMPTIONS" -if RUSTFLAGS='--cfg loom' cargo test --lib loom_tests "$@"; then +if [ "$#" -gt 0 ]; then + filter="loom_tests::$1" + shift +else + filter="loom_tests" +fi + +if RUSTFLAGS='--cfg loom' cargo test --lib "$filter" "$@"; then printf '\nAll Loom models verified.\n' else printf '\nLoom found a failing execution. The output above replays the\n' diff --git a/src/event_flags.rs b/src/event_flags.rs new file mode 100644 index 0000000..0945119 --- /dev/null +++ b/src/event_flags.rs @@ -0,0 +1,445 @@ +//! Coalesced condition notification for an ISR-to-task handoff. +//! +//! [`EventFlags`] records whether each of exactly 32 payload-free conditions +//! occurred since the preceding take. The producer raises an [`EventMask`] +//! with one atomic `fetch_or`; the consumer takes every pending condition with +//! one atomic `swap(0)`. Repeated raises of the same condition may coalesce, +//! and conditions carry neither multiplicity nor ordering. +//! +//! A raise uses Release ordering and a take uses Acquire ordering. Therefore, +//! memory actions sequenced before a raise happen-before memory actions +//! sequenced after a take that observes that raise. The flags publish the fact +//! that application state is ready; they do not carry that state themselves. +//! +//! The handles are deliberately sole-role `Send + !Sync` values. An `&self` +//! hot-path receiver does not make a handle shareable: move each handle into +//! the one execution context that owns its role. + +use core::cell::Cell; +use core::marker::PhantomData; +use core::ops::{BitAnd, BitOr, BitOrAssign}; + +use crate::sync::{AtomicBool, AtomicU32, Ordering}; + +/// A set of pending EventFlags conditions. +/// +/// The representation is exactly one `u32`: bit indices 0 through 31 are the +/// complete condition namespace. Applications can define named `const` masks +/// with [`EventMask::from_bits`] without introducing a runtime mapping layer. +#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)] +#[repr(transparent)] +#[must_use] +pub struct EventMask(u32); + +impl EventMask { + /// The empty condition set. + pub const EMPTY: Self = Self(0); + + /// The set containing all 32 conditions. + pub const ALL: Self = Self(u32::MAX); + + /// Construct a mask from its exact 32-bit representation. + #[inline(always)] + pub const fn from_bits(bits: u32) -> Self { + Self(bits) + } + + /// Construct the one-condition mask at `index`. + /// + /// Returns `None` for an index outside `0..32`; no shift panic is + /// reachable, including on a hot path that validates external input. + #[inline(always)] + #[must_use] + pub const fn from_index(index: u32) -> Option { + if index < u32::BITS { + Some(Self(1u32 << index)) + } else { + None + } + } + + /// Return the exact 32-bit representation. + #[inline(always)] + #[must_use] + pub const fn bits(self) -> u32 { + self.0 + } + + /// Whether the set contains no conditions. + #[inline(always)] + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// Whether every condition in `other` is present in this set. + #[inline(always)] + #[must_use] + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + /// Whether this set and `other` share at least one condition. + #[inline(always)] + #[must_use] + pub const fn intersects(self, other: Self) -> bool { + self.0 & other.0 != 0 + } +} + +impl BitOr for EventMask { + type Output = Self; + + #[inline(always)] + fn bitor(self, rhs: Self) -> Self::Output { + Self(self.0 | rhs.0) + } +} + +impl BitOrAssign for EventMask { + #[inline(always)] + fn bitor_assign(&mut self, rhs: Self) { + self.0 |= rhs.0; + } +} + +impl BitAnd for EventMask { + type Output = Self; + + #[inline(always)] + fn bitand(self, rhs: Self) -> Self::Output { + Self(self.0 & rhs.0) + } +} + +impl core::fmt::Debug for EventMask { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "EventMask({:#010x})", self.0) + } +} + +/// A coalescing SPSC set of 32 payload-free conditions. +/// +/// Exactly one [`Producer`] and one [`Consumer`] may be active at a time. +/// Acquisition is fallible and non-panicking; dropping a handle releases only +/// its role and leaves the pending set unchanged. +pub struct EventFlags { + pending: AtomicU32, + producer_taken: AtomicBool, + consumer_taken: AtomicBool, +} + +impl EventFlags { + /// Create an empty EventFlags value. + #[cfg(not(loom))] + #[must_use] + pub const fn new() -> Self { + Self { + pending: AtomicU32::new(0), + producer_taken: AtomicBool::new(false), + consumer_taken: AtomicBool::new(false), + } + } + + /// Create an empty EventFlags value under Loom. + #[cfg(loom)] + #[must_use] + pub fn new() -> Self { + Self { + pending: AtomicU32::new(0), + producer_taken: AtomicBool::new(false), + consumer_taken: AtomicBool::new(false), + } + } + + /// Try to acquire the sole producer handle. + /// + /// Returns `None` while another producer handle is active. + #[inline] + pub fn try_producer(&self) -> Option> { + if self.producer_taken.swap(true, Ordering::AcqRel) { + None + } else { + Some(Producer { + flags: self, + _not_sync: PhantomData, + }) + } + } + + /// Try to acquire the sole consumer handle. + /// + /// Returns `None` while another consumer handle is active. + #[inline] + pub fn try_consumer(&self) -> Option> { + if self.consumer_taken.swap(true, Ordering::AcqRel) { + None + } else { + Some(Consumer { + flags: self, + _not_sync: PhantomData, + }) + } + } +} + +impl Default for EventFlags { + fn default() -> Self { + Self::new() + } +} + +impl core::fmt::Debug for EventFlags { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("EventFlags") + .field( + "pending", + &EventMask::from_bits(self.pending.load(Ordering::Relaxed)), + ) + .finish_non_exhaustive() + } +} + +/// The sole raising handle for an [`EventFlags`] value. +/// +/// This handle is `Send + !Sync`: it may move into an ISR or another execution +/// context, but it may not be shared between contexts. +/// +/// ```compile_fail +/// use ph_eventing::event_flags::Producer; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` +pub struct Producer<'a> { + flags: &'a EventFlags, + _not_sync: PhantomData>, +} + +impl Producer<'_> { + /// Raise every condition in `mask`. + /// + /// The operation is exactly one atomic `fetch_or`: it never loops, waits, + /// allocates, calls user code, or panics. A concurrent take observes this + /// raise in its own snapshot or leaves it pending for the following take. + #[inline] + pub fn raise(&self, mask: EventMask) { + self.flags.pending.fetch_or(mask.bits(), Ordering::Release); + } +} + +impl Drop for Producer<'_> { + fn drop(&mut self) { + self.flags.producer_taken.store(false, Ordering::Release); + } +} + +impl core::fmt::Debug for Producer<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("event_flags::Producer").finish() + } +} + +/// The sole taking handle for an [`EventFlags`] value. +/// +/// This handle is `Send + !Sync` and may take pending conditions while its +/// paired producer raises them from another context. +/// +/// ```compile_fail +/// use ph_eventing::event_flags::Consumer; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` +pub struct Consumer<'a> { + flags: &'a EventFlags, + _not_sync: PhantomData>, +} + +impl Consumer<'_> { + /// Atomically take every pending condition and clear the set. + /// + /// Each returned bit was raised at least once after the preceding take. + /// Duplicate raises may coalesce and cross-condition order is not retained. + #[inline] + pub fn take_all(&self) -> EventMask { + EventMask::from_bits(self.flags.pending.swap(0, Ordering::Acquire)) + } +} + +impl Drop for Consumer<'_> { + fn drop(&mut self) { + self.flags.consumer_taken.store(false, Ordering::Release); + } +} + +impl core::fmt::Debug for Consumer<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("event_flags::Consumer").finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DATA_READY: EventMask = EventMask::from_bits(1 << 0); + const OVERFLOW: EventMask = EventMask::from_bits(1 << 1); + + #[test] + fn event_mask_is_an_explicit_panic_free_32_bit_set() { + // Contract M1, R1, and W1-W2. + assert_eq!(core::mem::size_of::(), 4); + assert!(EventMask::EMPTY.is_empty()); + assert_eq!(EventMask::ALL.bits(), u32::MAX); + assert_eq!(EventMask::from_index(0), Some(DATA_READY)); + assert_eq!(EventMask::from_index(31).unwrap().bits(), 1 << 31); + assert_eq!(EventMask::from_index(32), None); + assert_eq!(EventMask::from_index(u32::MAX), None); + + let both = DATA_READY | OVERFLOW; + assert!(both.contains(DATA_READY)); + assert!(both.intersects(OVERFLOW)); + assert_eq!((both & OVERFLOW).bits(), OVERFLOW.bits()); + } + + #[test] + fn duplicate_raises_coalesce_and_take_clears() { + // Contract M1, R1-R2, T1-T2, C1, and C3. + let flags = EventFlags::new(); + let producer = flags.try_producer().unwrap(); + let consumer = flags.try_consumer().unwrap(); + + producer.raise(DATA_READY); + producer.raise(DATA_READY); + + assert_eq!(consumer.take_all(), DATA_READY); + assert_eq!(consumer.take_all(), EventMask::EMPTY); + } + + #[test] + fn multi_bit_and_all_bit_masks_round_trip() { + // Contract R1, T1, C1, C3, and W1. + let flags = EventFlags::new(); + let producer = flags.try_producer().unwrap(); + let consumer = flags.try_consumer().unwrap(); + + producer.raise(DATA_READY | OVERFLOW); + assert_eq!(consumer.take_all(), DATA_READY | OVERFLOW); + producer.raise(EventMask::ALL); + assert_eq!(consumer.take_all(), EventMask::ALL); + } + + #[test] + fn empty_raise_and_empty_take_are_no_ops() { + // Contract R1 and T2. + let flags = EventFlags::new(); + let producer = flags.try_producer().unwrap(); + let consumer = flags.try_consumer().unwrap(); + + producer.raise(EventMask::EMPTY); + assert!(consumer.take_all().is_empty()); + } + + #[test] + fn handles_are_exclusive_and_reusable_after_drop() { + // Contract H1 and H3. + let flags = EventFlags::new(); + let producer = flags.try_producer().unwrap(); + let consumer = flags.try_consumer().unwrap(); + assert!(flags.try_producer().is_none()); + assert!(flags.try_consumer().is_none()); + + producer.raise(DATA_READY); + drop(producer); + drop(consumer); + + let producer = flags.try_producer().expect("producer role released"); + let consumer = flags.try_consumer().expect("consumer role released"); + assert_eq!(consumer.take_all(), DATA_READY); + producer.raise(OVERFLOW); + assert_eq!(consumer.take_all(), OVERFLOW); + } + + #[test] + fn handles_are_send_and_container_is_sync() { + // Contract H2. The compile-fail examples above pin `!Sync`. + fn assert_send() {} + fn assert_sync() {} + assert_send::>(); + assert_send::>(); + assert_sync::(); + } + + #[cfg(not(loom))] + #[test] + fn const_new_works_in_static_context() { + // Contract H4. + static FLAGS: EventFlags = EventFlags::new(); + let producer = FLAGS.try_producer().unwrap(); + let consumer = FLAGS.try_consumer().unwrap(); + producer.raise(DATA_READY); + assert_eq!(consumer.take_all(), DATA_READY); + } + + #[cfg(not(loom))] + #[test] + fn concurrent_raise_and_take_never_loses_the_condition() { + // Contract C1-C3 at stress-test scale. + use core::sync::atomic::{AtomicBool, Ordering as CoreOrdering}; + + let flags = EventFlags::new(); + let producer = flags.try_producer().unwrap(); + let consumer = flags.try_consumer().unwrap(); + let done = AtomicBool::new(false); + + let seen = std::thread::scope(|scope| { + let done_for_producer = &done; + scope.spawn(move || { + for _ in 0..crate::test_support::iterations(100_000) { + producer.raise(DATA_READY); + } + done_for_producer.store(true, CoreOrdering::Release); + }); + + let done_for_consumer = &done; + let taker = scope.spawn(move || { + let mut seen = EventMask::EMPTY; + while !done_for_consumer.load(CoreOrdering::Acquire) { + seen |= consumer.take_all(); + std::thread::yield_now(); + } + seen | consumer.take_all() + }); + + taker.join().unwrap() + }); + + assert_eq!(seen, DATA_READY); + } + + #[cfg(not(loom))] + #[test] + fn observed_raise_publishes_preceding_memory() { + // Contract S1 at native/Miri scale; Loom supplies the weak-memory proof. + use core::sync::atomic::{AtomicU32, Ordering as CoreOrdering}; + + let flags = EventFlags::new(); + let producer = flags.try_producer().unwrap(); + let consumer = flags.try_consumer().unwrap(); + let payload = AtomicU32::new(0); + + std::thread::scope(|scope| { + let payload_for_producer = &payload; + scope.spawn(move || { + payload_for_producer.store(0xA5A5_5A5A, CoreOrdering::Relaxed); + producer.raise(DATA_READY); + }); + + while !consumer.take_all().contains(DATA_READY) { + std::thread::yield_now(); + } + assert_eq!(payload.load(CoreOrdering::Relaxed), 0xA5A5_5A5A); + }); + } +} diff --git a/src/lib.rs b/src/lib.rs index d2c2daf..7e255c7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! Stack-allocated ring buffers for no-std embedded targets. +//! Deterministic handoff primitives for no-std embedded targets. //! //! # Primitives //! @@ -7,8 +7,10 @@ //! | [`RingBuf`] | Single-owner ring — simple, no atomics, `&mut` access. | //! | [`SeqRing`] | Lock-free SPSC ring that **overwrites** old entries (lossy, high-throughput). | //! | [`EventBuf`] | Lock-free SPSC ring with **backpressure** — rejects pushes when full. | +//! | [`EventFlags`] | Coalesced SPSC condition set — one bit per condition, one atomic operation per hot path. | //! -//! All three are fixed-size, zero-allocation, and generic over `T: Copy`. +//! All four are fixed-size and zero-allocation. The three buffers are generic +//! over `T: Copy`; `EventFlags` provides exactly 32 payload-free conditions. //! //! # Common traits //! @@ -101,7 +103,7 @@ //! The crate is `#![no_std]` by default. Tests require `std`. //! //! # Targets without atomics -//! `SeqRing` and `EventBuf` require 32-bit atomics. For targets that lack them +//! `SeqRing`, `EventBuf`, and `EventFlags` require 32-bit atomics. For targets that lack them //! (for example `thumbv6m-none-eabi`), enable //! `portable-atomic-unsafe-assume-single-core` or `portable-atomic-critical-section`. //! The crate always compiles those modules, so no-atomic targets need one of @@ -112,7 +114,7 @@ //! - `RingBuf` has no atomics and no interior mutability — standard Rust borrow //! rules apply. It stores slots as `MaybeUninit` and reads only live //! entries, so it does contain `unsafe`. -//! - `SeqRing` and `EventBuf` are SPSC by design: exactly one producer and one +//! - `SeqRing`, `EventBuf`, and `EventFlags` are SPSC by design: exactly one producer and one //! consumer must be active. Use `try_producer()` / `try_consumer()`, which //! return `None` rather than panicking — on a microcontroller a panic is a //! reset. The panicking `producer()` / `consumer()` are deprecated since @@ -146,8 +148,9 @@ //! The typical embedded shape is a producer in an interrupt handler and a //! consumer in a task loop. //! -//! - [`SeqRing`] and [`EventBuf`] are `Sync` when `T: Send`, so `&buf` can be -//! handed to both contexts. The `Producer` and `Consumer` handles are +//! - [`SeqRing`] and [`EventBuf`] are `Sync` when `T: Send`, and [`EventFlags`] +//! is `Sync`, so a shared reference can be handed to both contexts. The +//! `Producer` and `Consumer` handles are //! `Send + !Sync`: move each into the context that owns it, never share one. //! - The handles borrow the buffer, so the buffer must outlive them. //! - **`new()` is a `const fn`** on the normal build, so @@ -181,6 +184,12 @@ //! - `pop` returns the oldest item, or `None` when empty. //! - `peek` copies the oldest item without advancing the consumer cursor. //! - `drain(max, hook)` consumes up to `max` items through a callback. +//! +//! # EventFlags semantics +//! - [`event_flags::Producer::raise`] unions a mask into the pending set. +//! - [`event_flags::Consumer::take_all`] atomically returns and clears that set. +//! - Duplicate raises may coalesce; ordering and multiplicity are not retained. +//! - A take that observes a raise also observes memory actions sequenced before it. #![no_std] #[cfg(all(not(target_has_atomic = "32"), not(feature = "portable-atomic")))] @@ -201,12 +210,14 @@ enable either the portable-atomic-unsafe-assume-single-core or portable-atomic-c mod macros; pub mod event_buf; +pub mod event_flags; pub mod ring; pub mod seq_ring; pub(crate) mod sync; pub mod traits; pub use event_buf::EventBuf; +pub use event_flags::{EventFlags, EventMask}; pub use ring::RingBuf; pub use seq_ring::{PollStats, SeqRing}; pub use traits::{Link, Sink, Source}; diff --git a/src/loom_tests.rs b/src/loom_tests.rs index a2d6b3f..26d8076 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -19,8 +19,9 @@ // they remain public API until 0.3.0, so their orderings still need proving. #![allow(deprecated)] -use crate::{EventBuf, SeqRing}; +use crate::{EventBuf, EventFlags, EventMask, SeqRing}; use loom::sync::Arc; +use loom::sync::atomic::{AtomicU32, Ordering}; use loom::thread; /// Every item the producer pushes is popped exactly once, in order, with no @@ -214,3 +215,122 @@ fn seq_ring_latest_is_never_ahead_of_published() { consumer.join().unwrap(); }); } + +/// One raise racing one take is returned in exactly one take window: never +/// lost, duplicated, or fabricated (EventFlags contract C1-C3). +#[test] +fn event_flags_raise_racing_take_is_partitioned_exactly() { + loom::model(|| { + let flags = Arc::new(EventFlags::new()); + let condition = EventMask::from_bits(1 << 3); + + let producer_flags = Arc::clone(&flags); + let producer = thread::spawn(move || { + producer_flags + .try_producer() + .expect("sole producer") + .raise(condition); + }); + + let consumer_flags = Arc::clone(&flags); + let first_take = thread::spawn(move || { + consumer_flags + .try_consumer() + .expect("sole consumer") + .take_all() + }); + + producer.join().unwrap(); + let first = first_take.join().unwrap(); + let second = flags + .try_consumer() + .expect("consumer role released") + .take_all(); + + assert_eq!( + first | second, + condition, + "the raise was lost or fabricated" + ); + assert!( + (first & second).is_empty(), + "one raise appeared in two take windows" + ); + }); +} + +/// Distinct raises are distributed exactly across a racing take and the final +/// pending set (EventFlags contract R1, T1, and C1-C3). +#[test] +fn event_flags_distinct_raises_partition_across_takes() { + loom::model(|| { + let flags = Arc::new(EventFlags::new()); + let first_condition = EventMask::from_bits(1 << 0); + let second_condition = EventMask::from_bits(1 << 31); + + let producer_flags = Arc::clone(&flags); + let producer = thread::spawn(move || { + let producer = producer_flags.try_producer().expect("sole producer"); + producer.raise(first_condition); + producer.raise(second_condition); + }); + + let consumer_flags = Arc::clone(&flags); + let first_take = thread::spawn(move || { + consumer_flags + .try_consumer() + .expect("sole consumer") + .take_all() + }); + + producer.join().unwrap(); + let first = first_take.join().unwrap(); + let second = flags + .try_consumer() + .expect("consumer role released") + .take_all(); + let expected = first_condition | second_condition; + + assert_eq!(first | second, expected, "a distinct raise was lost"); + assert!( + (first & second).is_empty(), + "a condition was returned twice without being re-raised" + ); + assert_eq!((first | second).bits() & !expected.bits(), 0); + }); +} + +/// Observing a condition also observes memory written before its raise +/// (EventFlags contract S1). Changing either the Release `fetch_or` or Acquire +/// `swap` to Relaxed makes Loom find the stale-payload execution. +#[test] +fn event_flags_observed_raise_publishes_payload() { + loom::model(|| { + let flags = Arc::new(EventFlags::new()); + let payload = Arc::new(AtomicU32::new(0)); + let ready = EventMask::from_bits(1); + + let producer_flags = Arc::clone(&flags); + let producer_payload = Arc::clone(&payload); + let producer = thread::spawn(move || { + let producer = producer_flags.try_producer().expect("sole producer"); + producer_payload.store(0xA5A5_5A5A, Ordering::Relaxed); + producer.raise(ready); + }); + + let consumer = thread::spawn(move || { + let consumer = flags.try_consumer().expect("sole consumer"); + while !consumer.take_all().contains(ready) { + thread::yield_now(); + } + assert_eq!( + payload.load(Ordering::Relaxed), + 0xA5A5_5A5A, + "the observed raise did not publish its payload" + ); + }); + + producer.join().unwrap(); + consumer.join().unwrap(); + }); +} From af1c7a6a92048ed5907d28d4e98e2b680b041abc Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 14:39:49 -0400 Subject: [PATCH 14/87] Reword the D1 surfaces from deferred-default to closed-decision framing D1 closed as (a) + (c) on the record (PR #37). The lane-owned surfaces now cite the decision instead of a deferral: LatestItem::skipped carries the full X6 disclosure (exactness span, silent-zero full-cycle case, stall-only reachability arithmetic, deliberate non-detection, and the payload/watchdog escape hatches), the module docs mark D1 closed with D2/D3 still open, the full-cycle test comment names itself the closure's pin, and the evaluation record marks its D1 row CLOSED. No code changes; contract-copy updates arrive by resync after #37 merges. Co-Authored-By: Claude Fable 5 --- docs/proposals/latest-buf-evaluation.md | 7 ++-- src/latest_buf.rs | 44 +++++++++++++++++-------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index 1a22072..984c12a 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -13,11 +13,12 @@ ## 1. Decisions sufficient for an evaluable prototype These defaults make development possible without pretending the deferred -choices have received a permanent API decision. +choices have received a permanent API decision. D1 has since received its +permanent decision and is listed here as closed. | Decision | Prototype default | Evidence or decision that would change it | |---|---|---| -| D1, generation ambiguity | Exact within one non-zero `u32` span. Beyond that span use the documented approximation `generation_distance(last, current).saturating_sub(1)`; a repeated generation after exactly one full cycle therefore reports zero rather than panicking. Applications requiring a longer identity span carry a wider producer-assigned sequence in `T`. | Adopt an explicit `Gap::Unknown`-style API only with a concrete mechanism that can detect the wrap (extra epoch metadata, not inference from equal `u32` values), plus its RAM, flash, and cycle results. | +| D1, generation ambiguity — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default plus the payload escape hatch | Exact within one non-zero `u32` span. Beyond that span use the documented approximation `generation_distance(last, current).saturating_sub(1)`; a repeated generation after exactly one full cycle therefore reports zero rather than panicking. Applications requiring a longer identity span carry a wider producer-assigned sequence in `T`. | Closed — contract §9 records the decision, C3/C5/O2 carry the beyond-span text, and non-promise X6 states the adopter disclosure, including why a `Gap::Unknown`-style API was rejected (hot-path cost; "unknown" cannot recover the lost count). | | D2, `Source` | No `Source` implementation. Provide `LatestSource` so replacement evidence remains structural. | A design that preserves loss evidence through generic `Source`/`forward`; documentation alone is insufficient. | | D3, sample or block | Implement generic `LatestBuf` first. A complete block is a `T`; the BlockBuf candidate demonstrates `LatestBuf>` for latest and `EventBuf, Q>` for queued delivery. | A separate block transport must enforce a guarantee composition cannot, such as direct-to-granted-slot filling with a measured copy/RAM win. | | A.3, role continuation | Compare channel-resident role state with handle-resident state persisted on `Drop`. Keep H2/H4 in both candidates. | Narrow H2 only if both candidates fail the evidence bar; reacquisition must not silently restart. | @@ -147,7 +148,7 @@ Sequential unit tests pin API semantics and arithmetic: - `(last, current) = (u32::MAX, 1)` gives distance 1 and skipped 0; - `(u32::MAX - 1, 1)` gives skipped 1; and - `last == current` at the full-cycle ambiguity seam does not underflow or - panic and matches the selected D1 approximation. + panic and matches the closed D1 approximation (contract C3/X6). A threaded stress test uses a patterned multiword payload and checks that a returned value belongs wholly to one generation. It is useful scale evidence, diff --git a/src/latest_buf.rs b/src/latest_buf.rs index 7786b85..56a46d2 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -21,17 +21,22 @@ //! encoded as zero so a const-initialized channel lands in `.bss` rather than //! carrying its three payload slots in the flash-backed `.data` image. //! -//! # Deferred-policy assumptions +//! # Decision status //! -//! This evaluable prototype makes the narrow choices requested by issue #27: +//! Decision D1 (wrap-ambiguity policy) is **closed** as documented +//! approximation plus payload escape hatch (contract §9, non-promise X6): +//! skipped counts are exact while fewer than `u32::MAX` non-zero +//! generations separate successful takes, and beyond that full wrap span +//! the `u32` result is a documented modular approximation — +//! [`LatestItem::skipped`] carries the full disclosure. The remaining +//! narrow choices requested by issue #27 are still prototype defaults: //! -//! - skipped counts are exact while fewer than `u32::MAX` non-zero -//! generations separate successful takes; beyond that full wrap span the -//! `u32` result is inherently ambiguous; //! - [`Consumer`] deliberately does not implement [`crate::Source`], because -//! doing so would discard replacement evidence; use [`crate::LatestSource`]; +//! doing so would discard replacement evidence; use [`crate::LatestSource`] +//! (decision D2, open); //! - `T` is generic, so it can be one sample or a caller-defined complete -//! block without committing the first-deliverable policy. +//! block without committing the first-deliverable policy (decision D3, +//! open). //! //! # Example //! @@ -109,10 +114,22 @@ pub struct LatestItem { pub generation: u32, /// Publications assigned after the prior take and before this one. /// - /// This is exact within one complete non-zero `u32` generation span. - /// Beyond that span this prototype returns modular arithmetic's - /// approximation; a full cycle that reuses the prior generation reports - /// zero because the true count cannot be recovered from two `u32` values. + /// Exact while fewer than `u32::MAX` non-zero generations — one full + /// wrap span — separate two successful takes. Beyond that span the + /// count is modular arithmetic's approximation, and a gap of exactly + /// one full cycle reports zero: a silent under-count, accepted and + /// documented by decision D1 (contract non-promise X6). + /// + /// That boundary is reachable only through consumer stall, never + /// producer burst: crossing it means the consumer did not take while + /// `u32::MAX` publications occurred — roughly 49.7 days of continuous + /// 1 kHz publishing, or 72 minutes at 1 MHz. The channel deliberately + /// does not detect the crossing; detection would price wider hot-path + /// state into every operation and still could not recover the lost + /// count. If the count itself is the requirement (audit, metering, + /// loss accounting), carry a wider producer-assigned sequence inside + /// `T`; if consumer liveness is, use a watchdog — each catches its + /// condition sooner and correctly. pub skipped: u32, } @@ -570,8 +587,9 @@ mod tests { #[test] fn full_generation_cycle_uses_documented_approximation() { // Equal endpoints are indistinguishable from no progress after a full - // generation cycle. D1 remains deferred, so this prototype documents - // and pins its modular approximation instead of claiming exactness. + // generation cycle. D1 is closed as documented approximation plus + // payload escape hatch (contract C3/X6); this test is the closure's + // named pin for the full-cycle modular result. assert_eq!(LatestBuf::::generation_distance(17, 17), 0); } From b1e1b76a5cf331de9b1f366858195972c5941a44 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 14:52:36 -0400 Subject: [PATCH 15/87] Reword the D2 surfaces to closed and pin the absent Source impl D2 closed as the proposal's option 1 on the record (PR #37): the consumer does not implement Source - LatestSource is the type's designed contract surface, because try_pop cannot report the displacement that is this channel's designed overload behaviour. The module and Consumer docs now cite the closed decision and non- promise X7, and a compile_fail doctest (E0277) on Consumer pins the absent impl so a convenience Source cannot arrive silently - the evidence row named by the closure. README and AGENTS counts move from 3 to 4 compile-fail doctests. The evaluation record marks its D2 row CLOSED. The branch's contract copy updates by resync after #37 merges. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 6 +++-- README.md | 2 +- docs/proposals/latest-buf-evaluation.md | 2 +- src/latest_buf.rs | 34 +++++++++++++++++++++---- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5c47e19..f1c4717 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -917,8 +917,10 @@ cargo test **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -`src/latest_buf.rs`, and `src/traits.rs`. Total: 78 unit tests + 12 doctests, plus 3 `compile_fail` -doctests pinning the `N == 0` rejection (`E0080`) on all three ring types. +`src/latest_buf.rs`, and `src/traits.rs`. Total: 78 unit tests + 12 doctests, plus 4 `compile_fail` +doctests: the `N == 0` rejection (`E0080`) on all three ring types, and the +deliberately absent `Source` impl on `LatestBuf`'s consumer (`E0277`, +decision D2 — the pin keeps a convenience impl from arriving silently). ## Code Conventions diff --git a/README.md b/README.md index 5ce300c..cb8bd57 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 78 unit + 12 doctests + 3 compile-fail | Behaviour, including threaded stress tests for all SPSC types; `N == 0` rejected at compile time | +| 78 unit + 12 doctests + 4 compile-fail | Behaviour, including threaded stress tests for all SPSC types; `N == 0` rejected at compile time; `LatestBuf`'s absent `Source` impl pinned (D2) | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index 984c12a..e5c7840 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -19,7 +19,7 @@ permanent decision and is listed here as closed. | Decision | Prototype default | Evidence or decision that would change it | |---|---|---| | D1, generation ambiguity — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default plus the payload escape hatch | Exact within one non-zero `u32` span. Beyond that span use the documented approximation `generation_distance(last, current).saturating_sub(1)`; a repeated generation after exactly one full cycle therefore reports zero rather than panicking. Applications requiring a longer identity span carry a wider producer-assigned sequence in `T`. | Closed — contract §9 records the decision, C3/C5/O2 carry the beyond-span text, and non-promise X6 states the adopter disclosure, including why a `Gap::Unknown`-style API was rejected (hot-path cost; "unknown" cannot recover the lost count). | -| D2, `Source` | No `Source` implementation. Provide `LatestSource` so replacement evidence remains structural. | A design that preserves loss evidence through generic `Source`/`forward`; documentation alone is insufficient. | +| D2, `Source` — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default | No `Source` implementation. Provide `LatestSource` so replacement evidence remains structural — the maintainer's articulation: the new traits were designed as the type's contract surface, solving what the existing traits structurally could not. | Closed — contract §9 and non-promise X7 record the decision; a convenience `Source` impl remains an additive, adopter-evidence-gated future decision, and a `compile_fail` doctest on `Consumer` pins the absent impl against silent regression. | | D3, sample or block | Implement generic `LatestBuf` first. A complete block is a `T`; the BlockBuf candidate demonstrates `LatestBuf>` for latest and `EventBuf, Q>` for queued delivery. | A separate block transport must enforce a guarantee composition cannot, such as direct-to-granted-slot filling with a measured copy/RAM win. | | A.3, role continuation | Compare channel-resident role state with handle-resident state persisted on `Drop`. Keep H2/H4 in both candidates. | Narrow H2 only if both candidates fail the evidence bar; reacquisition must not silently restart. | | A.1, empty poll | Acquire-load the ready bit and return when false; a true load still proceeds through the `AcqRel` ownership swap. | Revert to the unconditional swap only if a target regresses beyond the code-size tolerance or the Loom equivalence model fails. Neither occurred in the recorded matrix. | diff --git a/src/latest_buf.rs b/src/latest_buf.rs index 56a46d2..efe142b 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -28,12 +28,18 @@ //! skipped counts are exact while fewer than `u32::MAX` non-zero //! generations separate successful takes, and beyond that full wrap span //! the `u32` result is a documented modular approximation — -//! [`LatestItem::skipped`] carries the full disclosure. The remaining -//! narrow choices requested by issue #27 are still prototype defaults: +//! [`LatestItem::skipped`] carries the full disclosure. +//! +//! Decision D2 (`Source` policy) is **closed**: [`Consumer`] does not +//! implement [`crate::Source`], because `try_pop` cannot report the +//! displacement that is this channel's *designed* overload behaviour — +//! [`crate::LatestSource`] is the consumer's designed contract surface +//! (contract §9, non-promise X7), and the absent impl is pinned by a +//! `compile_fail` doctest on [`Consumer`]. +//! +//! The remaining narrow choice requested by issue #27 is still a +//! prototype default: //! -//! - [`Consumer`] deliberately does not implement [`crate::Source`], because -//! doing so would discard replacement evidence; use [`crate::LatestSource`] -//! (decision D2, open); //! - `T` is generic, so it can be one sample or a caller-defined complete //! block without committing the first-deliverable policy (decision D3, //! open). @@ -355,6 +361,24 @@ impl core::fmt::Debug for Producer<'_, T> { } /// Unique, stateless read handle for a [`LatestBuf`]. +/// +/// # No `Source` implementation — by decision, not omission +/// `Source::try_pop` cannot report the displacement that is this channel's +/// designed overload behaviour, so a generic pipeline would silently +/// discard loss evidence during *normal* operation (decision D2; contract +/// non-promise X7). [`crate::LatestSource`] is the consumer's designed +/// contract surface. A caller whose domain genuinely permits discarding +/// the skipped count writes its own adapter, so the discard is signed in +/// application code, never by the transport. This `compile_fail` doctest +/// pins the absent impl so a convenience `Source` cannot arrive silently, +/// and pinning the error code keeps it honest: +/// +/// ```compile_fail,E0277 +/// use ph_eventing::Source; +/// let channel = ph_eventing::LatestBuf::::new(); +/// let mut consumer = channel.try_consumer().unwrap(); +/// let _ = Source::try_pop(&mut consumer); +/// ``` pub struct Consumer<'a, T: Copy> { buf: &'a LatestBuf, _not_sync: PhantomData>, From 798f635cb2b8885a00991b5c169c14c5d6e695bc Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 15:01:12 -0400 Subject: [PATCH 16/87] Reword the D3 surfaces to closed - all three decision points settled D3 closed on the record (PR #37) as the convergent answer: T stays generic by decision, a complete block is a payload via the BlockBuf composition, and no separate latest-block type exists. The module docs now state all three contract decision points closed, and the evaluation record marks its D3 row CLOSED with the registered reopening condition routed behind cycle decisions P/S. The branch's contract copy updates by resync after #37 merges. Co-Authored-By: Claude Fable 5 --- docs/proposals/latest-buf-evaluation.md | 2 +- src/latest_buf.rs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index e5c7840..c0b9f44 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -20,7 +20,7 @@ permanent decision and is listed here as closed. |---|---|---| | D1, generation ambiguity — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default plus the payload escape hatch | Exact within one non-zero `u32` span. Beyond that span use the documented approximation `generation_distance(last, current).saturating_sub(1)`; a repeated generation after exactly one full cycle therefore reports zero rather than panicking. Applications requiring a longer identity span carry a wider producer-assigned sequence in `T`. | Closed — contract §9 records the decision, C3/C5/O2 carry the beyond-span text, and non-promise X6 states the adopter disclosure, including why a `Gap::Unknown`-style API was rejected (hot-path cost; "unknown" cannot recover the lost count). | | D2, `Source` — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default | No `Source` implementation. Provide `LatestSource` so replacement evidence remains structural — the maintainer's articulation: the new traits were designed as the type's contract surface, solving what the existing traits structurally could not. | Closed — contract §9 and non-promise X7 record the decision; a convenience `Source` impl remains an additive, adopter-evidence-gated future decision, and a `compile_fail` doctest on `Consumer` pins the absent impl against silent regression. | -| D3, sample or block | Implement generic `LatestBuf` first. A complete block is a `T`; the BlockBuf candidate demonstrates `LatestBuf>` for latest and `EventBuf, Q>` for queued delivery. | A separate block transport must enforce a guarantee composition cannot, such as direct-to-granted-slot filling with a measured copy/RAM win. | +| D3, sample or block — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default | Implement generic `LatestBuf` first. A complete block is a `T`; the BlockBuf candidate demonstrates `LatestBuf>` for latest and `EventBuf, Q>` for queued delivery. | Closed — contract §9 records the decision with the joint matrix (`8593b4a`) as the acceptance measurement set for both shapes. The reopening condition is registered there unchanged: a separate block transport must enforce a guarantee composition cannot, such as direct-to-granted-slot filling with a measured copy/RAM win (behind cycle decisions P/S). | | A.3, role continuation | Compare channel-resident role state with handle-resident state persisted on `Drop`. Keep H2/H4 in both candidates. | Narrow H2 only if both candidates fail the evidence bar; reacquisition must not silently restart. | | A.1, empty poll | Acquire-load the ready bit and return when false; a true load still proceeds through the `AcqRel` ownership swap. | Revert to the unconditional swap only if a target regresses beyond the code-size tolerance or the Loom equivalence model fails. Neither occurred in the recorded matrix. | diff --git a/src/latest_buf.rs b/src/latest_buf.rs index efe142b..cf307c5 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -37,12 +37,14 @@ //! (contract §9, non-promise X7), and the absent impl is pinned by a //! `compile_fail` doctest on [`Consumer`]. //! -//! The remaining narrow choice requested by issue #27 is still a -//! prototype default: +//! Decision D3 (first deliverable form) is **closed**: `T` stays generic +//! by decision, not default — a complete block is a payload +//! (`LatestBuf>` via the BlockBuf composition), +//! sample-versus-block is release scheduling and RAM, and no separate +//! latest-block type exists (contract §9). //! -//! - `T` is generic, so it can be one sample or a caller-defined complete -//! block without committing the first-deliverable policy (decision D3, -//! open). +//! All three contract decision points (D1–D3) are closed; the contract's +//! §9 carries each record. //! //! # Example //! From deac0f502a40874a4d0f41de66520f663b68e672 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 15:01:14 -0400 Subject: [PATCH 17/87] Mark the D3 type-identity recommendation confirmed The maintainer closed D3 (2026-08-11, PR #37) exactly as this document recommends: composition is the type identity, sample-versus-block is release scheduling, and no separate block primitive exists. The closure binds the documentation obligation on the block-payload surfaces (per-shape RAM, small-N inversion guidance, no-partial-block limitation) and the promotion bar's first item is done. Remaining before PROPOSED: the deferred P budget reading and its consequences. Co-Authored-By: Claude Fable 5 --- docs/proposals/block-buf.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index 07cc196..88b474d 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -9,6 +9,8 @@ - **Related:** [`latest-buf.md`](latest-buf.md) §11 (the complete-block variant sketch) and [`latest-buf-contract.md`](latest-buf-contract.md) decision point **D3** — this document and D3 must be resolved together. + **Resolved (2026-08-11):** D3 closed as the composition this document + recommends (contract §9, PR #37; confirmation recorded in §5 below). ## 1. Design sketch (from the taxonomy) @@ -128,6 +130,12 @@ exclusive primitive forms. `LatestBuf` remains payload-agnostic while `Block` is a separately useful payload/fill abstraction. Which ships first remains release scheduling, not a type-system choice. +**Confirmed (maintainer, 2026-08-11):** D3 is closed exactly this way — the +convergent answer is the answer (contract §9, PR #37). The closure binds a +documentation obligation on the block-payload surfaces: per-shape RAM stated +plainly, the small-`N` publication-cost inversion stated as guidance, and +the no-partial-block limitation stated for adopters. + A named wrapper should be reconsidered only if it enforces a contract that composition cannot, such as direct-to-granted-slot filling with zero publication copy. That would materially be a SlotPool/grant transport. @@ -256,6 +264,7 @@ no block-specific evidence; Loom remains required for the selected transport. 1. Maintainer confirms the type-identity recommendation and treats D3 as a scheduling decision rather than separate sample/block primitive designs. + **Done 2026-08-11** — D3 closed as composition (contract §9, PR #37). 2. Compare the completed section 6 matrix against named budgets; choose Copy composition or SlotPool/grants from the result. The measurement is complete; naming the instruction/time and RAM budgets remains a maintainer decision. From f8bff27764b7aa54a31dcb5eeaa5e5a4f70165cb Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 15:21:43 -0400 Subject: [PATCH 18/87] Reword the A.3 surfaces to closed and document the X8 role boundary A.3 closed on the record (PR #37): channel-resident role state with stateless handles - the mechanism this implementation already uses - so continuation holds by construction. The module docs state every LatestBuf decision closed, and try_producer/try_consumer now carry the X8 role-recovery boundary for integrators: the role is held until the handle drops, handle lifetime is an application property, and there is deliberately no out-of-band reset because a forced release would defeat the exclusive ownership soundness rests on. Facts of the exchange only; no supervision or teardown prescriptions. The evaluation record marks its A.3 row CLOSED and reframes section 7: channel-state is the decision, persist-on-drop is considered-and-not- selected with its partial evidence preserved as the surviving artifact. Co-Authored-By: Claude Fable 5 --- docs/proposals/latest-buf-evaluation.md | 19 +++++++++++++------ src/latest_buf.rs | 25 +++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index c0b9f44..f94c25e 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -21,7 +21,7 @@ permanent decision and is listed here as closed. | D1, generation ambiguity — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default plus the payload escape hatch | Exact within one non-zero `u32` span. Beyond that span use the documented approximation `generation_distance(last, current).saturating_sub(1)`; a repeated generation after exactly one full cycle therefore reports zero rather than panicking. Applications requiring a longer identity span carry a wider producer-assigned sequence in `T`. | Closed — contract §9 records the decision, C3/C5/O2 carry the beyond-span text, and non-promise X6 states the adopter disclosure, including why a `Gap::Unknown`-style API was rejected (hot-path cost; "unknown" cannot recover the lost count). | | D2, `Source` — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default | No `Source` implementation. Provide `LatestSource` so replacement evidence remains structural — the maintainer's articulation: the new traits were designed as the type's contract surface, solving what the existing traits structurally could not. | Closed — contract §9 and non-promise X7 record the decision; a convenience `Source` impl remains an additive, adopter-evidence-gated future decision, and a `compile_fail` doctest on `Consumer` pins the absent impl against silent regression. | | D3, sample or block — **CLOSED** (maintainer, 2026-08-11, PR #37) as this default | Implement generic `LatestBuf` first. A complete block is a `T`; the BlockBuf candidate demonstrates `LatestBuf>` for latest and `EventBuf, Q>` for queued delivery. | Closed — contract §9 records the decision with the joint matrix (`8593b4a`) as the acceptance measurement set for both shapes. The reopening condition is registered there unchanged: a separate block transport must enforce a guarantee composition cannot, such as direct-to-granted-slot filling with a measured copy/RAM win (behind cycle decisions P/S). | -| A.3, role continuation | Compare channel-resident role state with handle-resident state persisted on `Drop`. Keep H2/H4 in both candidates. | Narrow H2 only if both candidates fail the evidence bar; reacquisition must not silently restart. | +| A.3, role continuation — **CLOSED** (maintainer, 2026-08-11, PR #37) as channel-resident | Channel-resident role state, stateless handles — the crate precedent and the decision-H doctrine. Continuation by construction; full-column evidence in §7. | Closed — persist-on-drop is considered and not selected (its §7 column stays as the preserved partial evidence); narrowing H2's condition (both candidates failing) was not met. Contract non-promise X8 states the role-recovery boundary for integrators. | | A.1, empty poll | Acquire-load the ready bit and return when false; a true load still proceeds through the `AcqRel` ownership swap. | Revert to the unconditional swap only if a target regresses beyond the code-size tolerance or the Loom equivalence model fails. Neither occurred in the recorded matrix. | The BlockBuf branch also makes `DropBlockBuf` a caller policy over the block @@ -212,11 +212,18 @@ An implementation is ready to compare only when its PR can fill every cell: Correctness removes a candidate; measurements select between candidates that remain. Ergonomics is evaluated only after those results. -The channel-state prototype is the integration default because it follows the -crate's stateless-handle precedent and has no Drop-time state-copying path. -The persist-on-drop prototype remains on its independent comparison branch; -it is not rejected, but its possible register-residency advantage must be -measured and its L3 model repaired before it can displace the default. +The channel-state prototype is the **closed A.3 decision** (maintainer, +2026-08-11, PR #37), not merely the integration default: it follows the +crate's stateless-handle precedent, has no Drop-time state-copying path, +and its column above is complete. The persist-on-drop prototype is +**considered and not selected**; its column stands as the preserved +partial evidence (its Drop-time state copy is a permanent failure surface, +its joined L3 does not isolate the taken-flag handoff, and its +register-residency claim was never measured). Its comparison work was +never pushed to the shared remote, so this record is its surviving +artifact; if the branch still exists in a development environment it +should be archive-tagged per the `archive/*` convention rather than +deleted. For the channel-state mutation run, Loom rejects producer `Acquire`/`Relaxed` and consumer `Release`/`Relaxed` exchange orderings, plus both roles' weakened diff --git a/src/latest_buf.rs b/src/latest_buf.rs index cf307c5..bcbe0f2 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -43,8 +43,16 @@ //! sample-versus-block is release scheduling and RAM, and no separate //! latest-block type exists (contract §9). //! -//! All three contract decision points (D1–D3) are closed; the contract's -//! §9 carries each record. +//! Review caveat A.3 (handle-state continuation) is **closed** the same +//! way this implementation works: role state is channel-resident in +//! role-owned storage and handles are stateless, so a drop-and-reacquire +//! continues by construction (contract H4; proposal Appendix A.3). +//! Contract non-promise X8 states the role-recovery boundary: the role is +//! held until the handle is dropped, handle lifetime is an application +//! property, and there is deliberately no out-of-band role reset. +//! +//! Every LatestBuf decision (D1–D3, A.3) is closed; the contract §9 and +//! proposal Appendix A.3 carry the records. //! //! # Example //! @@ -209,6 +217,15 @@ impl LatestBuf { /// /// Returns `None` while another producer handle is active. Dropping the /// active handle makes the role available without resetting its state. + /// + /// The role is held until the active handle is *dropped* — a handle + /// that is forgotten, or owned by an execution context destroyed + /// without running destructors, leaves the role held with channel + /// state intact. There is deliberately no out-of-band role reset: a + /// forced release could free a role while a live handle still exists, + /// defeating the exclusive ownership that soundness rests on + /// (contract non-promise X8). Handle lifetime is the application's + /// property; the channel observes only acquisition and drop. #[inline] pub fn try_producer(&self) -> Option> { if self.producer_taken.swap(true, Ordering::AcqRel) { @@ -225,6 +242,10 @@ impl LatestBuf { /// /// Returns `None` while another consumer handle is active. Dropping the /// active handle makes the role available without resetting its state. + /// + /// Role recovery follows the same boundary as [`Self::try_producer`]: + /// held until dropped, no out-of-band reset, handle lifetime owned by + /// the application (contract non-promise X8). #[inline] pub fn try_consumer(&self) -> Option> { if self.consumer_taken.swap(true, Ordering::AcqRel) { From 99d23868e54614c5dc3b1cfde058213e3b5fb6e6 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 15:29:02 -0400 Subject: [PATCH 19/87] Align the skipped rustdoc with the corrected C3 formula and trigger Mirrors the Codex-review corrections on PR #37: skipped is stated as its actual formula (wrap-aware distance minus one, saturated at zero) rather than "modular arithmetic's approximation", and the wrap boundary is stated neutrally as a rate x take-interval property crossed when a full span separates two takes - deliberate low-cadence deployments reach it by design, not only stalled consumers. Doc-only. Co-Authored-By: Claude Fable 5 --- src/latest_buf.rs | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/latest_buf.rs b/src/latest_buf.rs index bcbe0f2..b485aab 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -27,7 +27,7 @@ //! approximation plus payload escape hatch (contract §9, non-promise X6): //! skipped counts are exact while fewer than `u32::MAX` non-zero //! generations separate successful takes, and beyond that full wrap span -//! the `u32` result is a documented modular approximation — +//! the `u32` result is a documented under-count — //! [`LatestItem::skipped`] carries the full disclosure. //! //! Decision D2 (`Source` policy) is **closed**: [`Consumer`] does not @@ -130,22 +130,29 @@ pub struct LatestItem { pub generation: u32, /// Publications assigned after the prior take and before this one. /// - /// Exact while fewer than `u32::MAX` non-zero generations — one full - /// wrap span — separate two successful takes. Beyond that span the - /// count is modular arithmetic's approximation, and a gap of exactly - /// one full cycle reports zero: a silent under-count, accepted and + /// One formula in every case (contract C3): the wrap-aware generation + /// distance from the previously taken value to this one, minus one, + /// saturated at zero. Exact while fewer than `u32::MAX` non-zero + /// generations — one full wrap span — separate two successful takes. + /// Beyond that span the same formula under-counts: by whole spans + /// while the endpoints differ, and a gap of exactly one or more full + /// cycles reports zero — a silent under-count, accepted and /// documented by decision D1 (contract non-promise X6). /// - /// That boundary is reachable only through consumer stall, never - /// producer burst: crossing it means the consumer did not take while - /// `u32::MAX` publications occurred — roughly 49.7 days of continuous - /// 1 kHz publishing, or 72 minutes at 1 MHz. The channel deliberately - /// does not detect the crossing; detection would price wider hot-path - /// state into every operation and still could not recover the lost - /// count. If the count itself is the requirement (audit, metering, - /// loss accounting), carry a wider producer-assigned sequence inside - /// `T`; if consumer liveness is, use a watchdog — each catches its - /// condition sooner and correctly. + /// The boundary is a rate × take-interval property: it is crossed + /// exactly when a full span of publications occurs between two + /// successful takes — whether because the consumer stopped (fault) + /// or because the deployment deliberately takes rarely against a + /// fast producer (design). At representative rates that is roughly + /// 49.7 days between takes at continuous 1 kHz publishing, or + /// 72 minutes at 1 MHz; the crate bounds neither rate nor cadence, + /// so this arithmetic is the caller's to run. The channel + /// deliberately does not detect the crossing; detection would price + /// wider hot-path state into every operation and still could not + /// recover the lost count. If the count itself is the requirement + /// (audit, metering, loss accounting), carry a wider + /// producer-assigned sequence inside `T`; if consumer liveness is, + /// use a watchdog — each catches its condition sooner and correctly. pub skipped: u32, } From 618c5d2cce3e0cc6de69d35ccc3542af907b98c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 20:14:23 +0000 Subject: [PATCH 20/87] Add event-flags engineering record for Track 1 acceptance package Per #26 mechanics rule 12 / PR #38 discipline: value statement, risks and integration concerns, claims mapped to evidence, then the working record. Docs-only; cites the lane contract/proposal and measured rows without restating normative prose. Refs #26 Co-authored-by: Steven Giacomelli --- CHANGELOG.md | 2 + docs/records/event-flags.md | 144 ++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 docs/records/event-flags.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf146f..9ad3c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Documentation +- EventFlags engineering record (`docs/records/event-flags.md`) — Track 1 acceptance package: value statement, integrator risks (coalescing, sole-role H, no peek/traits, measured portable-atomic windows), claims×evidence, and the closed decision set (H/D2–D5). ### Added - `EventFlags` — a coalescing SPSC condition set for ISR-to-task notification. Exactly 32 payload-free conditions are represented by a transparent `EventMask(u32)`; the producer raises diff --git a/docs/records/event-flags.md b/docs/records/event-flags.md new file mode 100644 index 0000000..1a44057 --- /dev/null +++ b/docs/records/event-flags.md @@ -0,0 +1,144 @@ +# EventFlags — engineering record + +- **Status:** candidate, PROPOSED — complete admission package on + `candidate/event-flags` (draft PR #36); contract frozen + (M/R/T/C/S/B/H/W/X); shared handle decision H closed; awaiting + acceptance review and release assembly. +- **Normative sources:** [contract](../proposals/event-flags-contract.md) + (clause IDs cited below) · [proposal](../proposals/event-flags.md) · + admission evidence embedded in the proposal (§4) and + `scripts/event-flags-atomic-window.sh` (portable-atomic ISR windows). + +## 1. Value statement + +`EventFlags` is a fixed SPSC condition set for the ISR-to-task niche +where the payload is readiness, not data: the producer raises any of +exactly 32 payload-free conditions with one Release `fetch_or`; the +consumer takes the entire pending set with one Acquire `swap(0)`. +Duplicate raises of the same condition coalesce — multiplicity is not +observable — and a racing raise is returned by the current take or +remains pending for the next one, never erased between windows (C1–C3). +Observing a condition publishes application memory sequenced before its +raise (S1); the mask itself stores none of that state. It complements +`CountedSignal` (multiplicity retained, payloads still absent) rather +than replacing it, and it refuses to be: a FIFO, a stream, a +multi-producer bus, a non-clearing advisory peek, or a target-dependent +word width. + +## 2. Risks and integration concerns + +Ordered by how likely they are to surprise an integrator. Contract clause +IDs in parentheses; the clauses are the normative statements. + +- **Coalescing is the designed behaviour, not a fault mode (R2, X1).** + Duplicate raises of one condition before the next take collapse to a + single bit. The type is wrong for any consumer that must count how + many times a condition fired — that is `CountedSignal`'s niche — and + wrong for any consumer that must observe every distinct occurrence in + order — that is `EventBuf` / `SeqRing`. Loss of multiplicity is + observable only as an exact take window: a bit is either in this take + or pending for a later one (C1–C3), never silently dropped *and* + unreported. +- **No payload, no ordering, no identity (X1, X2).** Conditions are + unordered set members. S1 publishes separately-owned application + state; the mask does not carry it. An integrator who needs a value, + a timestamp, or a per-raise identity must store that outside the + flags and use the raise only as the readiness fence. +- **Exactly 32 conditions, forever for this type (W1, W2, X3).** The + namespace is bits 0–31 of a transparent `EventMask(u32)`. A generic + word width was rejected because it would expose target-specific atomic + availability and fallback cost as public API; `u64` in particular is + not one native operation on the 32-bit MCUs this crate supports. Need + more than 32 distinct conditions? Compose multiple instances or pick a + different primitive — do not expect a width parameter. +- **No non-clearing peek (D4, X5).** There is deliberately no advisory + snapshot. A peek's result would be immediately stale across a + concurrent take or raise, inviting check-then-act code the contract + cannot uphold. Observation *is* `take_all`: destructive, exact, and + the linearization point. +- **No stream or signal trait surface (D5, X5).** `EventFlags` does not + implement `Sink`/`Source`/`Link`: a destructive take plus a rejecting + downstream sink could lose the mask, and coalescing cannot be reported + through the stream vocabulary. The sketched + `SignalSink`/`SignalSource` pair is deferred — + `CountedSignal`'s increment/count-snapshot vocabulary disproves one + shared generic `S` as an honest surface for both. Bridging is + application code. +- **Sole-role handles only (H1–H4, X4).** At most one producer and one + consumer are active; acquisition is fallible and non-panicking; handles + are `Send + !Sync` with `&self` hot paths. `fetch_or` would be sound + with multiple raisers, but promising that here would force + `CountedSignal` toward a weaker algorithm — the shared decision H + (`f26d4c3`) keeps both lanes SPSC. An execution context destroyed + without dropping its handle leaves the role held (pending state + intact); there is no out-of-band reset. +- **Constrained-target atomics are measured, not free (B1–B3, X6).** On + thumbv6m and ESP32-S2 the RMW is a portable-atomic critical section — + measured interrupt-masked windows are **4** and **5** instructions + respectively, straight-line, one RMW, no CAS loop. ESP32-S3 is native + `s32c1i` (window **0**), correcting the exploratory assumption that S2 + and S3 share a fallback. Algorithmic bounds (B1–B3) are not a + universal wall-clock claim (X6). + +## 3. Technical claims and validation + +| Claim | Evidence | Status | +|---|---|---| +| Raise unions into pending; take returns-and-clears exactly (M1, R1–R2, T1–T2, C1, C3) | Unit set: empty/all masks, bit 31, duplicate coalesce, multi-bit round-trip, empty raise/take no-ops | Pinned | +| Racing raise partitions across take windows; never erased (C1–C3) | Loom `event_flags_raise_racing_take_is_partitioned_exactly` and `event_flags_distinct_raises_partition_across_takes`; native/Miri threaded stress | Proven | +| Observed raise publishes prior application memory (S1) | Loom `event_flags_observed_raise_publishes_payload`; fails when either Release or Acquire is independently weakened to Relaxed | Proven | +| One `fetch_or` / one `swap(0)`; no CAS loop, no history-proportional work (B1–B3) | Source review; Cortex-M3 QEMU: raise **12** / take **8** instructions whether empty or set (constant w.r.t. occupancy and set-bit count) | Measured | +| Portable-atomic ISR windows stay small on constrained targets | `event-flags-atomic-window.sh`: thumbv6m **4**, ESP32-S2 **5**, ESP32-S3 **0** (native `s32c1i`); one masked RMW, no branch/CAS loop | Measured | +| Sole-role `Send + !Sync` handles; reacquisition continues pending state (H1–H4) | Exclusivity/reuse unit test; `Send` + container-`Sync` type tests; producer/consumer `compile_fail` doctests pin `!Sync`; `const_new_works_in_static_context` | Proven | +| Transparent panic-free 32-bit mask (W1–W2) | `EventMask` four-byte transparent layout; `from_index` returns `None` outside `0..32` (no shift panic) | Pinned | +| No silent `Source`/`Sink` or peek surface (D4, D5, X5) | Absent from the public API; contract non-promises X5; stream/signal traits deferred on the record | Pinned | +| Cost claims per target | Eight gated code-size rows + three opt-in Xtensa (raise/take 24/24 B thumbv6m, 23/22 B ESP32-S2, 37/36 B ESP32-S3); object is 12 B; static in `.bss` | Measured | + +Full CI for the lane (`./scripts/verify.sh`, zero skips): 78 unit tests, +11 doctests, 5 compile-fail contracts, 93.60% line coverage, all 8 Loom +models plus ordering-mutation checks, detector-on Miri host and +i686/armv7/s390x proxies, feature/embedded builds, supply-chain checks. + +## 4. The record + +**Decision history (all closed with the contract freeze at `e9859d4`; +canonical text in contract §9; shared H closed earlier at `f26d4c3` on +issue #30):** + +- **H — sole-role `Send + !Sync` producer/consumer handles with `&self` + operations.** Accepted once for EventFlags and CountedSignal. + EventFlags' `fetch_or` could support multiple raisers; promising that + would force CountedSignal toward an unbounded CAS loop or a weaker + overflow contract. The conservative shared shape loses no SPSC use + case and leaves an independently-evidenced MPSC type possible later. +- **D2 — transparent `EventMask(u32)`.** The exploratory enum mapping + retained runtime range checks and could not prove that two variants + did not alias; the transparent mask erases to the raw word operation + within two bytes while keeping domains distinct at the API boundary. +- **D3 — exactly 32 conditions.** A generic word width would expose + target-specific atomic availability and fallback cost as public API. + `u64` is not one native operation on the shipped 32-bit targets. +- **D4 — no non-clearing peek.** An advisory snapshot invites + check-then-act reasoning the concurrent take/raise pair cannot uphold; + observation is destructive `take_all` by design. +- **D5 — no stream `Sink`/`Source`/`Link`; signal traits deferred.** + Coalesced state is not an item stream. CountedSignal's + increment/count-snapshot vocabulary disproves the original single + generic `SignalSink`/`SignalSource` sketch as an honest shared + surface. + +**Review history:** the lane's promotion bar and admission case +(portable-atomic ISR latency on thumbv6m / ESP32-S2/S3) were sequenced +behind shared decision H; once H closed, the contract freeze, secondary +confirmations (width, no-peek, trait posture), and the interrupt-window +campaign landed together as the complete admission package. Draft PR #36 +carries the package for acceptance review; issue #29 is closed as +PROPOSED — ready for candidate evaluation, not yet acceptance into the +release. Evaluation may still reject the primitive on API fit or +measured cost, with the evidence retained either way. + +**Where the numbers live:** proposal §4 (behaviour/Loom/Miri, 11-target +code size, pinned Cortex-M3 cycles beside CountedSignal's 8/7 anchor, +portable-atomic interrupt windows); `scripts/event-flags-atomic-window.sh` +(disassembly probe); tracking: issues #26/#29/#30, draft PR #36; +implementation freeze commit `e9859d4`, handle decision `f26d4c3`. From 2008350e72b0a819c6b9019807b9e1ca59836d09 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 20:14:24 +0000 Subject: [PATCH 21/87] Add counted-signal engineering record for Track 1 acceptance package Per #26 mechanics rule 12 / PR #38 discipline: value statement, risks and integration concerns, claims mapped to evidence, then the working record. Docs-only; cites the lane contract/proposal and measured rows without restating normative prose. Refs #26 Co-authored-by: Steven Giacomelli --- CHANGELOG.md | 2 + docs/records/counted-signal.md | 130 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 docs/records/counted-signal.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0853431..2afeb60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Documentation +- CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned 8/7 Cortex-M3 rows, and the H closure record. ### Added - `CountedSignal`: a payload-free SPSC counter with a bounded `increment`, atomic `take_count`, exact `u32` saturation, and observable diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md new file mode 100644 index 0000000..151158f --- /dev/null +++ b/docs/records/counted-signal.md @@ -0,0 +1,130 @@ +# CountedSignal — engineering record + +- **Status:** candidate, PROPOSED — complete admission package on + `candidate/counted-signal` (draft PR #33); accepted contract; shared + handle decision H closed on this lane's evidence; awaiting acceptance + review and release assembly. +- **Normative sources:** [contract](../proposals/counted-signal-contract.md) + (clause IDs cited below) · [proposal](../proposals/counted-signal.md) · + measurements inline in proposal §3.1 (eight-target code size; pinned + Cortex-M3 cycles via `./scripts/verify.sh cycles`). + +## 1. Value statement + +`CountedSignal` is a saturating SPSC counter for the multiplicity-without- +payload niche: the producer records identical, payload-free occurrences +with a statically bounded increment; the consumer atomically takes the +count for one interval and clears it. Every completed increment +linearizes exactly once into a take interval; when the exact range is +exhausted the signal saturates observably rather than wrapping — the +same `dropped_accum` honesty the crate already requires of overload +accounting. Its soundness argument for exact, wrap-free saturation is +**sole-producer ownership**: between the producer's load and conditional +RMW the consumer can only reset, so the RMW cannot wrap. It complements +`EventFlags` (presence) rather than replacing it, and it refuses to be: +a payload channel, an ordering authority, a multi-producer bus, or an +unbounded exact total past saturation. + +## 2. Risks and integration concerns + +Ordered by how likely they are to surprise an integrator. Contract clause +IDs in parentheses; the clauses are the normative statements. + +- **Saturation loses exact excess (A2, X3).** A saturated snapshot + reports at least `u32::MAX` increments in the interval and that + exactness was lost; the excess itself is deliberately unrecoverable. + If your mission needs an unbounded exact total, this type is wrong — + widen the accounting in the application, or take often enough that + saturation is a fault rather than a design point. +- **Sole producer is load-bearing, not style (H1–H2, X4, H-decision).** + The no-wrap / B1 proof depends on one active producer: two raisers + could both observe `u32::MAX - 1` and the second `fetch_add` would + wrap. Shareable or multi-producer signalling needs a **separate** + type, contract, algorithm, and evidence case — not a weakened reading + of this one. `&self` on the hot path is a receiver choice; it does + not permit sharing a handle (`Send + !Sync` remains). +- **No payload, no per-occurrence identity (X1, X2).** Only the count in + a take interval is retained. Ordering between individual increments + is intentionally absent; unrelated data gains no publication fence + from an increment. Callers that need payload or FIFO delivery want + `EventBuf` / `SeqRing`, not this niche. +- **Role recovery ends at `Drop` (H1–H3).** Reacquisition after a dropped + handle continues from channel-resident state without reset — I/T/A + clauses keep holding. An execution context destroyed without running + destructors, or a forgotten handle, leaves the role held. There is + deliberately no out-of-band forced release. +- **Algorithmic bounds are not wall-clock claims (B1–B3, X5).** + `increment` and `take_count` are statically bounded (no retry loop, + no wait, no panic on the hot path). Instruction and latency numbers + are environment-specific measurements; cite them with the pinned + toolchain and QEMU stamp, never as universal cycle truths. +- **Constrained-target atomics are measured, not free.** On thumbv6m / + thumbv8m.base the RMW path uses the portable-atomic single-core + backend; the gated code-size rows (proposal §3.1) are the honest + cost basis for those cores. + +## 3. Technical claims and validation + +| Claim | Evidence | Status | +|---|---|---| +| Exact accumulate + take clears; no fabricated/duplicated counts (I1, T1, T4, A1) | `increments_accumulate_and_take_clears`; threaded `concurrent_takes_do_not_lose_increments` sum check (100,000 increments) | Proven | +| Saturates at `u32::MAX`, never wraps; sticky until take (I2–I3, T2, A2–A3) | `saturates_instead_of_wrapping`; Loom `counted_signal_saturation_boundary_is_linearizable` | Proven | +| Concurrent increment belongs to exactly one take interval (T1–T3, A1) | Loom `counted_signal_take_partitions_increments`; threaded take stress | Proven | +| Wait-free bounded hot paths: load + ≤1 `fetch_add`; `swap(0)` take (B1–B2) | Source review (no loops); pinned Cortex-M3: 8 retired instructions `increment`, 7 `take_count` (rustc 1.92.0 / QEMU 10.0.11) | Measured | +| No panic reachable from hot paths (B3) | Source review; normal, Miri, Loom, and embedded-target executions | Proven | +| Sole-role exclusive handles; reacquisition continues state (H1, H3) | `handles_are_exclusive_and_reusable_after_drop` | Proven | +| Handles are `Send + !Sync` (H2) | `handles_are_send`; compile-fail doctests pin `!Sync` on both roles | Pinned | +| Constructible in static storage (H4) | `const_new_works_in_static_context` | Proven | +| Cost claims per target | Eight-target gated code-size rows (proposal §3.1); architecture split exposed for M0+/M23 vs M3/M4/M33 vs RV32 | Measured | + +Full CI for the lane (`0a22ada` admission package; `f26d4c3` H +finalization): complete pinned `./scripts/verify.sh` matrix with zero +skips — 75 unit tests, 11 doctests, 5 compile-fail, 93.75% line +coverage, Miri host and proxy targets, all seven Loom models, +eight-target code size, embedded checks, QEMU cycles. + +## 4. The record + +**Decision history (closed on `candidate/counted-signal`, canonical text +in contract §8 and proposal §3.1–§3.2):** + +- **Bounded exact saturation: sole-producer load + conditional + `fetch_add`.** Below `u32::MAX`, one Relaxed load and at most one + Relaxed `fetch_add`; at the sentinel, a no-op that already linearizes + into the saturated snapshot. Rejected for this type: an unbounded CAS + loop (fails B1), silent wrap (fails A2/I3 and the crate's + saturate-don't-wrap convention), and shareable/multiple raisers + (invalidates the intervening-writer argument). Closure set the + disclosure standard: the no-wrap proof is an ownership proof — state + it so a multi-producer caller can decide *against* the type. +- **H — sole-role `Send + !Sync` handles with `&self` hot-path ops + (`f26d4c3`).** Accepted for CountedSignal and EventFlags on this + lane's evidence. For CountedSignal the choice is correctness, not API + taste: producer exclusivity is part of the no-wrap / B1 proof. A + future multi-producer signal must be a separate type. LatestBuf's + A.3 later closed on the same sole-role doctrine (PR #37), so the + crate now states the handle model once. +- **`u32::MAX` sentinel in a one-word `CountSnapshot`.** Full exact + range below the sentinel; `is_saturated()` observes loss of precision + in the take that clears it; no second sticky atomic. +- **`swap(0)` take, relaxed ordering.** Counts only — no payload + publication to order. Atomic modification order partitions each + increment into exactly one take epoch (T3). +- **Width and shape: one `u32` counter.** Matches the atomic shim and + every shipped 32-bit target. Multi-class use composes a fixed array + of signals; a dedicated multi-counter or dynamic registry needs its + own caller and evidence. Width genericity would create + target-dependent contracts. + +**Review history:** issue #30 closed with the lane PROPOSED; draft PR +#33 carries the complete admission package. Cross-lane handle decision +recorded against #29; cycle decision matrix remains #26. Contract clause +IDs (I/T/A/B/H/X) are load-bearing for tests and models — do not +renumber. + +**Where the numbers live:** proposal §3.1 (eight-target `increment` / +`take_count` code-size table; pinned Cortex-M3 8 / 7 instruction +regions under rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11); +contract §9 evidence map; tracking: issue #30, PR #33; completion +commits `0a22ada` (clause-numbered contract + pinned costs) and +`f26d4c3` (H closed, promotion finalized). From 735e9ab70fda82d6c5ae9613a565230fa2ac227f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 20:14:26 +0000 Subject: [PATCH 22/87] Add block-buf engineering record for Track 1 acceptance package Per #26 mechanics rule 12 / PR #38 discipline: value statement, risks and integration concerns, claims mapped to evidence, then the working record. Docs-only; cites the lane contract/proposal and measured rows without restating normative prose. Refs #26 Co-authored-by: Steven Giacomelli --- CHANGELOG.md | 2 + docs/records/block-buf.md | 140 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 docs/records/block-buf.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ee24ebc..668bc41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Documentation +- BlockBuf engineering record (`docs/records/block-buf.md`) — Track 1 acceptance package: composition identity under closed D3, measured publication costs (`bc54a9a`), joint composition rows, and an honest status header that **promotion still waits on decision P**. ### Added - `Block` and `BlockBuilder` provide complete, contiguous sample windows without introducing another queue policy. The builder rejects gaps diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md new file mode 100644 index 0000000..fe5064c --- /dev/null +++ b/docs/records/block-buf.md @@ -0,0 +1,140 @@ +# BlockBuf — engineering record + +- **Status:** candidate, EVALUATION READY — complete development package on + `candidate/block-buf` (draft PR #34); D3 type-identity confirmed + (composition, no `LatestBlockBuf`); **promotion waits on cycle decision + P** (named instruction/time budget and RAM envelope); SlotPool path + (S) gated behind P. +- **Normative sources:** [proposal](../proposals/block-buf.md) + (§5–§9 cited below) · LatestBuf + [contract §9 D3](../proposals/latest-buf-contract.md) · + [publication matrix](../proposals/block-buf-measurements.md) + (`bc54a9a`) · joint D3 matrix on the LatestBuf lane + (`latest-block-composition-measurements.md`, `8593b4a`, PR #35). + +## 1. Value statement + +`Block` and `BlockBuilder` are the fill-side half of complete +window handoff: the producer privately accumulates `N` contiguous samples +and yields a finished block only when every slot is initialized; the +consumer never sees a partial window. Overload policy is composition, not +a new synchronization primitive — latest is `LatestBuf>`, +queued is `EventBuf, Q>`, and drop-new is call-site handling +of the complete block `EventBuf::push` returns on rejection. The headline +property is observable interruption *before* publication: discontinuities +preserve the partial builder and return the rejected sample; teardown of +an incomplete builder publishes nothing. It refuses to be: a separate +`LatestBlockBuf`/`QueuedBlockBuf`/`DropBlockBuf` type family, a +timestamp authority (stamps live inside `T`), a free constant-cost +publication path across block shapes, or a SlotPool grant transport until +cycle decision P authorizes that branch. + +## 2. Risks and integration concerns + +Ordered by how likely they are to surprise an integrator. Proposal +section and contract IDs in parentheses; those texts are normative. + +- **Publication cost scales with block bytes, not with the O(1) + synchronization step (proposal §6, §6.1).** Accepted + completion-plus-publication on the reference Cortex-M3 runs 159–8,658 + retired instructions across the measured grid (2/8/16-byte samples × + `N = 8/32/128`) and grows roughly linearly with logical payload + traffic (48–4,112 B per path). Synchronization being constant does + **not** make publication cheap; the acquisition budget must be checked + against the accepted row for the chosen shape, not against a scalar + handoff myth. +- **Rejection is nearly as expensive as acceptance (§6.1).** Preserving + and returning the complete rejected block keeps the rejected path + within 5–31 instructions of the accepted path on every measured row — + not a cheap scalar `Err(())`. Callers that treat rejection as free + error plumbing will mis-budget the ISR/task. +- **Small-N publication can lose to per-sample LatestBuf (D3 + obligation).** The joint composition matrix (`8593b4a`) shows block + publication beats per-sample for every 2-byte row and for 8/16-byte + samples at `N ≥ 32`, but costs 54%/31% *more* at the 8/16-byte + `N = 8` corners. Composition is not automatically the cheaper shape. +- **RAM is multiple complete blocks, always (D3 docs obligation, §6).** + Latest composition holds three payload slots plus the private builder; + the joint matrix states 136–8,280 B of combined channel + builder RAM + across the measured grid. Queued composition is `Q * B` plus atomics, + on top of fill-side storage. State the per-shape number; the docs are + required to. +- **No partial-block freshness (F1, D3).** Composition never exposes an + incomplete window — sample-level freshness inside a filling block is + unobtainable by design. A discontinuity returns the rejected sample + without mutating the partial builder; the caller must `clear` or + retry explicitly (F3). +- **Copy composition vs SlotPool is undecided (P open; S gated).** The + publication matrix (`bc54a9a`) supplies the numbers but does **not** + close P: no named ISR/task instruction/time budget or RAM envelope + exists in the record. S (representative BlockBuf-over-SlotPool + integration) remains unauthorized until P chooses. Treating Copy as + selected, or SlotPool as selected, would invent a decision the + maintainer deferred. +- **Drop-new is an action, not a type (§5.1).** There is no + `DropBlockBuf`; discarding a newly completed block is what the caller + does with `Err(block)`. Loss after completion is reported by the + selected transport (`PublishReport::replaced_unread` or the returned + block), distinct from pre-publication filtered/incomplete windows. + +## 3. Technical claims and validation + +| Claim | Evidence | Status | +|---|---|---| +| Type identity is composition — no `LatestBlockBuf` (D3, §5.1) | Maintainer closure 2026-08-11 (contract §9, PR #37); candidate implements `Block`/`BlockBuilder` only | Closed | +| Complete-only yield; teardown publishes nothing (F1, F4) | Unit pins on completion boundary; drop/clear of partial builder; Miri on `MaybeUninit` completion copy | Proven | +| Contiguous wrap-aware sequences; reserved `0` rejected (F2) | Fill-error unit set incl. wrap; `T: Copy` without `Default` | Pinned | +| Discontinuity preserves partial block and returns sample (F3) | Explicit interruption tests; no silent mutate-or-count | Proven | +| Accepted/rejected publication cost measured per shape (§6.1) | Matrix `bc54a9a`: 18 instruction rows (159–8,658 accepted; rejection within 5–31); reproduced on QEMU 10.0.11 and 10.2.1 | Measured | +| Flash cost bounded across gated targets | 8-target codesize matrix: 110–312 B per completion-plus-publication shape | Measured | +| Joint D3 composition cost vs per-sample (D3) | Matrix `8593b4a`: 171–6,958 instructions; 136–8,280 B combined channel+builder RAM; small-N inversion documented | Measured | +| No block-layer atomics; transport evidence unchanged (§8) | Block path: unit + Miri; LatestBuf/EventBuf: detector-on Miri and Loom on selected transport | Proven (transport) | +| Copy vs SlotPool foundation (P / S) | Matrix complete; named budget/envelope absent; maintainer deferred holistic reading | **Open — blocks promotion** | + +Full CI for the lane: 78 unit tests, 12 doctests, 4 compile-fail, 8 +gated codesize targets, 3 embedded checks, 6 Miri passes, 5 Loom +models. + +## 4. The record + +**Decision history (D3 closed 2026-08-11; P deferred — canonical text in +contract §9 and proposal §5–§9):** + +- **D3 — payload-agnostic `LatestBuf`; blocks are payloads + (composition).** Closed jointly with the LatestBuf lane: latest = + `LatestBuf>`, queued = `EventBuf, Q>`, + drop-new = handle `Err(block)` at the call site. No named + `LatestBlockBuf` / `QueuedBlockBuf` / `DropBlockBuf`. Reopening + condition (contract §9): a separate block transport must enforce a + guarantee composition cannot (direct-to-granted-slot filling), and + that question lives behind cycle decisions P/S. +- **Fill-side contract F1–F5 (§8) — selected as the block-only delta.** + Complete-only yield, contiguous sequences, explicit interruption, + teardown publishes nothing, reuse after completion. No concurrency + contract is added by composition; LatestBuf / EventBuf clauses apply + with `T = Block<…>`. +- **Payload and stamps (§5.3) — stamps inside `T`.** `Block` + carries `[T; N]` plus inclusive first/last sequences; zero-stamp + applications pay zero timestamp overhead. The seed sketch's + `Block` parameterisation is superseded. +- **P — Copy composition vs SlotPool: open.** Measurement package + complete (`bc54a9a` publication matrix; joint D3 matrix `8593b4a`); + maintainer deferred naming the ISR/task instruction/time budget and + RAM envelope until a holistic reading of the cycle. This record does + **not** select Copy and does **not** authorize SlotPool. +- **S — SlotPool path: gated behind P.** No representative + BlockBuf-over-SlotPool integration is authorized while P is unset; + choosing a library-wide `N` threshold in lieu of an application budget + was explicitly refused (§6.1). + +**Review history:** D3 closure and the composition identity were +confirmed in the LatestBuf contract arbitration (PR #37) as the +convergent answer both coupled lanes reached independently. BlockBuf's +remaining gate is not further type-identity review — it is the budget +decision P. + +**Where the numbers live:** `block-buf-measurements.md` (publication +matrix `bc54a9a`: 18 cycle rows, 8-target flash); joint D3 matrix on +the LatestBuf lane (`latest-block-composition-measurements.md`, +`8593b4a`, PR #35); proposal §6 / §6.1 (cost model and decision rule); +tracking: issue #26 decision P, draft PR #34, contract PR #37. From 48d41fb2ddc4fa2572d1bce7ad8c52726d069b62 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 16:16:42 -0400 Subject: [PATCH 23/87] Record the P closure: Copy composition selected, baselines directed Cycle decision P closed 2026-08-11 as Copy composition (planning-record P/S closure, PR #38). The promotion bar's remaining items resolve: the budget posture is per-shape measured rows rather than a library-wide threshold; all nine shapes become release baselines at promotion via the deliberate --bless; and the double-copy hazard guidance for DMA integrations rides to promotion as a documentation obligation. Status moves to DECISION-COMPLETE - promotion awaits the #34 acceptance review. SlotPool is deferred by decision S with its trigger registered. Co-Authored-By: Claude Fable 5 --- docs/proposals/block-buf.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index e69e3a8..50e03ce 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -1,6 +1,9 @@ # BlockBuf: complete window handoff (exploratory design document) -- **Status:** EXPLORATORY — design exploration vehicle, not yet PROPOSED. +- **Status:** DECISION-COMPLETE (2026-08-11) — D3 confirmed (composition) + and cycle decision **P** closed as **Copy composition**; every input to + promotion is settled, and promotion to PROPOSED awaits the #34 + acceptance review. Prior status: EXPLORATORY. - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §2 (received 2026-08-11); triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. @@ -281,9 +284,21 @@ no block-specific evidence; Loom remains required for the selected transport. scheduling decision rather than separate sample/block primitive designs. **Done 2026-08-11** — D3 closed as composition (contract §9, PR #37). 2. Compare the completed section 6 matrix against named budgets; choose Copy - composition or SlotPool/grants from the result. The measurement is complete; - naming the instruction/time and RAM budgets remains a maintainer decision. + composition or SlotPool/grants from the result. **Done 2026-08-11** — + cycle decision **P** closed as **Copy composition**, with the deliberate + budget posture that the per-shape measured rows *are* the budget + statement (no library-wide threshold; planning-record P/S closure). + SlotPool is deferred by decision **S** with an adopter-gated reopening + trigger. 3. Run standard CI and Miri; run Loom for the selected transport. + **Satisfied for the selected transport** — Copy composition is the + measured, fully green configuration (the block layer adds no atomics; + §8's Loom position stands). 4. If Copy wins, decide which completed matrix rows become release baselines. - If SlotPool wins, specify grant teardown and ownership proof before - PROPOSED. + **Directed 2026-08-11**: all nine shapes ride into the release baselines + at promotion via the deliberate, reviewed `--bless` (mechanics rule 8); + the pinned instruction regions stay committed. One documentation + obligation rides to promotion with them: the **double-copy hazard** + guidance for DMA integrations — make the builder the DMA target, or + publish from task context — stated in the block-payload docs, not left + for integrators to discover. From 5ec002ef1194ea3a98a7d604996b51ba9dfba0a9 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 16:18:45 -0400 Subject: [PATCH 24/87] Update the engineering record for the P and S closures The record landed minutes before P closed; per the stale-claims discipline its P-open framing is updated in the same resync that brings the closure in: status DECISION-COMPLETE, P closed as Copy with the per-shape budget posture and the registered unserved corner, S closed as deferred with the adopter-gated trigger, and the double-copy DMA guidance recorded as a bound obligation. Co-Authored-By: Claude Fable 5 --- docs/records/block-buf.md | 74 ++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index fe5064c..6b14dd5 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -1,10 +1,12 @@ # BlockBuf — engineering record -- **Status:** candidate, EVALUATION READY — complete development package on - `candidate/block-buf` (draft PR #34); D3 type-identity confirmed - (composition, no `LatestBlockBuf`); **promotion waits on cycle decision - P** (named instruction/time budget and RAM envelope); SlotPool path - (S) gated behind P. +- **Status:** candidate, DECISION-COMPLETE — complete development package + on `candidate/block-buf` (draft PR #34); D3 type-identity confirmed + (composition, no `LatestBlockBuf`); **cycle decision P closed as Copy + composition** (2026-08-11, planning-record P/S closure) with the + per-shape measured rows as the budget statement; **S closed as + deferred** (SlotPool evidence banked, adopter-gated trigger). + Promotion to PROPOSED awaits the #34 acceptance review. - **Normative sources:** [proposal](../proposals/block-buf.md) (§5–§9 cited below) · LatestBuf [contract §9 D3](../proposals/latest-buf-contract.md) · @@ -26,8 +28,9 @@ preserve the partial builder and return the rejected sample; teardown of an incomplete builder publishes nothing. It refuses to be: a separate `LatestBlockBuf`/`QueuedBlockBuf`/`DropBlockBuf` type family, a timestamp authority (stamps live inside `T`), a free constant-cost -publication path across block shapes, or a SlotPool grant transport until -cycle decision P authorizes that branch. +publication path across block shapes, or a SlotPool grant transport — +cycle decision P closed as Copy composition, and the grant branch is +deferred behind S's adopter-gated trigger. ## 2. Risks and integration concerns @@ -64,13 +67,21 @@ section and contract IDs in parentheses; those texts are normative. unobtainable by design. A discontinuity returns the rejected sample without mutating the partial builder; the caller must `clear` or retry explicitly (F3). -- **Copy composition vs SlotPool is undecided (P open; S gated).** The - publication matrix (`bc54a9a`) supplies the numbers but does **not** - close P: no named ISR/task instruction/time budget or RAM envelope - exists in the record. S (representative BlockBuf-over-SlotPool - integration) remains unauthorized until P chooses. Treating Copy as - selected, or SlotPool as selected, would invent a decision the - maintainer deferred. +- **Copy is the closed publication foundation (P, 2026-08-11); the one + unserved corner is registered, not hidden.** The budget posture is + deliberate: no library-wide threshold — the per-shape rows above *are* + the budget statement, and adopters own the arithmetic for their shape. + The measured context where the copy plausibly breaks a real budget — + large blocks × slow core × ISR-context publication × high window rate + (~180 µs per 2 KB publication on a 48 MHz M0-class part) — has no + named adopter and exits through S's trigger: a measured budget breach + at a supported shape, or a direct-to-granted-slot (DMA-class) + requirement, reopens the SlotPool branch from its banked evidence. +- **The double-copy hazard is real for DMA integrations (P + obligation).** DMA already wrote the bytes once; builder-then-publish + crosses them twice unless the builder is the DMA target or publication + moves to task context. The block-payload docs owe this guidance at + promotion — integrators must not discover the second copy. - **Drop-new is an action, not a type (§5.1).** There is no `DropBlockBuf`; discarding a newly completed block is what the caller does with `Err(block)`. Loss after completion is reported by the @@ -97,8 +108,8 @@ models. ## 4. The record -**Decision history (D3 closed 2026-08-11; P deferred — canonical text in -contract §9 and proposal §5–§9):** +**Decision history (D3, P, and S all closed 2026-08-11 — canonical text +in contract §9, proposal §5–§9, and the planning record's P/S closure):** - **D3 — payload-agnostic `LatestBuf`; blocks are payloads (composition).** Closed jointly with the LatestBuf lane: latest = @@ -117,21 +128,28 @@ contract §9 and proposal §5–§9):** carries `[T; N]` plus inclusive first/last sequences; zero-stamp applications pay zero timestamp overhead. The seed sketch's `Block` parameterisation is superseded. -- **P — Copy composition vs SlotPool: open.** Measurement package - complete (`bc54a9a` publication matrix; joint D3 matrix `8593b4a`); - maintainer deferred naming the ISR/task instruction/time budget and - RAM envelope until a holistic reading of the cycle. This record does - **not** select Copy and does **not** authorize SlotPool. -- **S — SlotPool path: gated behind P.** No representative - BlockBuf-over-SlotPool integration is authorized while P is unset; - choosing a library-wide `N` threshold in lieu of an application budget - was explicitly refused (§6.1). +- **P — Copy composition: CLOSED (maintainer, 2026-08-11).** The + measurement package (`bc54a9a` publication matrix; joint D3 matrix + `8593b4a`) was read against the integration scoping on #26, and the + budget posture is deliberate: no library-wide threshold — the + per-shape rows are the budget statement, and adopters own the + arithmetic. All nine shapes become release baselines at promotion via + the deliberate `--bless`; the double-copy DMA guidance is a bound + documentation obligation. The one measured corner Copy leaves + unserved (large-block × slow-core × ISR × high-rate) has no named + adopter and exits through S's trigger. +- **S — SlotPool path: CLOSED as DEFERRED (maintainer, 2026-08-11).** + Not rejected: full evaluation evidence banked on + `candidate/slot-pool`, draft PR #32 closed as deferred, branch + archive-tagged. Reopening trigger (any one): a measured budget breach + at a supported shape; a direct-to-granted-slot requirement + composition cannot satisfy; a standalone zero-copy adopter. On + trigger, BlockBuf-over-SlotPool is the demonstration path. **Review history:** D3 closure and the composition identity were confirmed in the LatestBuf contract arbitration (PR #37) as the -convergent answer both coupled lanes reached independently. BlockBuf's -remaining gate is not further type-identity review — it is the budget -decision P. +convergent answer both coupled lanes reached independently. With P and S +closed, BlockBuf has no remaining gate but the #34 acceptance review. **Where the numbers live:** `block-buf-measurements.md` (publication matrix `bc54a9a`: 18 cycle rows, 8-target flash); joint D3 matrix on From 947a28f3387a23569b36b2f0af42ea780a81a0fe Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 16:54:52 -0400 Subject: [PATCH 25/87] Address PR #36 review findings with evidence-backed fixes. Align AGENTS.md role claims with AcqRel swap, correct the EventFlags object size to the measured 8 B with a unit assert, gate the thumbv6m atomic-window check in verify.sh while keeping ESP rows ESP=1 opt-in, and stop loom.sh from rewriting leading Cargo flags into filters. Co-authored-by: Cursor --- AGENTS.md | 25 ++-- CHANGELOG.md | 15 ++- README.md | 2 +- docs/proposals/event-flags-contract.md | 2 +- docs/proposals/event-flags.md | 33 +++--- docs/records/event-flags.md | 10 +- scripts/codesize/src/lib.rs | 2 +- scripts/event-flags-atomic-window.sh | 151 +++++++++++++++---------- scripts/loom.sh | 15 ++- scripts/verify.sh | 45 +++++--- scripts/verify/Dockerfile | 7 +- src/event_flags.rs | 8 ++ 12 files changed, 202 insertions(+), 113 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4724b5c..03106ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -231,8 +231,8 @@ memory-ordering proof. - Release/Acquire is load-bearing publication. If a take observes a bit, it also observes application-owned state sequenced before that raise. The Loom publication litmus fails when either side is weakened to Relaxed. -- Producer and consumer role claims use AcqRel compare-exchange and Release on - handle drop. Handles are `Send + !Sync`; their `&self` operations do not grant +- Producer and consumer role claims use AcqRel `swap` and Release on handle + drop. Handles are `Send + !Sync`; their `&self` operations do not grant multiple logical producers or consumers. ### Internal model (SeqRing) @@ -382,10 +382,17 @@ across builds), the Miri nightly (`+nightly` drifts daily), and the optional pins all three, and ```bash -./scripts/verify.sh # ci + miri + loom + cycles, zero SKIPs +./scripts/verify.sh # ci + miri + loom + cycles + atomic-window, zero SKIPs ./scripts/verify.sh cycles # one script inside the image +./scripts/verify.sh atomic-window # EventFlags thumbv6m interrupt-mask gate ``` +`atomic-window` gates the thumbv6m PRIMASK window only. ESP32-S2/S3 rows stay +opt-in (`ESP=1 ./scripts/event-flags-atomic-window.sh`, like `XTENSA=1` for +codesize) because the reference Docker image deliberately does not ship +esp-rs; treating a missing fork as a green verify would break the zero-SKIP +rule. + runs the matrix inside it. The documented instruction counts are measured there; cite results together with the versions each run prints. The script refuses to run under `GITHUB_ACTIONS` — keeping the expensive checks off the @@ -710,10 +717,11 @@ Three results carry the argument: that is measured rather than asserted. 3. **EventFlags is constant across condition state.** Raise is 12 instructions whether the bit is clear or already set; take is 8 whether non-empty or - empty. On the portable paths, `event-flags-atomic-window.sh` additionally - pins straight-line masked windows of 4 instructions on thumbv6m and 5 on - ESP32-S2; ESP32-S3 masks interrupts for zero instructions under its native - `S32C1I` path. + empty. `./scripts/verify.sh atomic-window` (and the default verify matrix) + additionally pins the thumbv6m straight-line masked window at 4 + instructions. ESP32-S2 (5) and ESP32-S3 (0 under native `S32C1I`) remain + opt-in via `ESP=1 ./scripts/event-flags-atomic-window.sh` — measured on + hosts with esp-rs, not claimed by the Docker zero-SKIP matrix. The rejected push is *cheaper* than an accepted one — backpressure is an early return, not extra work. @@ -857,6 +865,7 @@ cargo test - `static_buf_yields_static_sendable_handles` — `'static`, `Send` handles off a `static` buffer **`event_flags::tests`:** +- `event_flags_object_is_eight_bytes` — `size_of::() == 8` layout pin - `event_mask_is_an_explicit_panic_free_32_bit_set` — exact-width mask and checked bit construction - `duplicate_raises_coalesce_and_take_clears` — duplicate coalescing and destructive take - `multi_bit_and_all_bit_masks_round_trip` — multi-condition and all-condition masks @@ -908,7 +917,7 @@ cargo test **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -and `src/traits.rs`. Total: 76 unit tests + 11 doctests, plus 5 +and `src/traits.rs`. Total: 77 unit tests + 11 doctests, plus 5 `compile_fail` doctests: three pin the `N == 0` rejection (`E0080`) on the buffer types and two pin the EventFlags handle `!Sync` contract. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8198429..f128b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,14 +27,17 @@ All notable changes to this project will be documented in this file. - EventFlags admission evidence: three Loom models (including a publication litmus whose Release and Acquire mutation checks both fail as intended), detector-on Miri coverage, eight gated and three opt-in Xtensa code-size rows, four Cortex-M3 instruction regions, and a reproducible - portable-atomic disassembly check. The masked window is 4 instructions on thumbv6m and 5 on - ESP32-S2; ESP32-S3 uses native `s32c1i` and masks interrupts for 0 instructions under the - measured esp-rs toolchain. + portable-atomic disassembly check. The thumbv6m masked window (4 instructions) is gated by + `./scripts/verify.sh atomic-window`; ESP32-S2 (5) and ESP32-S3 (0 under native `s32c1i`) stay + opt-in via `ESP=1` because the reference Docker image does not ship esp-rs. ### Fixed -- `scripts/loom.sh ` now scopes the filter to `loom_tests::` instead of passing a - second positional test filter to Cargo. The documented `./scripts/loom.sh event_buf` form was - rejected by Cargo before any model ran. +- `scripts/loom.sh ` now scopes a bare name filter to `loom_tests::` instead of + passing a second positional test filter to Cargo. Leading Cargo/test flags (arguments that + start with `-`) pass through unchanged, so forms like `./scripts/loom.sh -- --nocapture` are + not rewritten into a no-op `loom_tests::--` filter. +- `EventFlags` object-size claim corrected from 12 B to the measured 8 B (`size_of` unit assert); + AGENTS.md role-claim wording aligned with the AcqRel `swap` implementation. ### Documentation - `LatestBuf` contract: decision **D1** (wrap-ambiguity policy) is closed as options diff --git a/README.md b/README.md index 5df52d0..4ffea04 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 76 unit + 11 doctests + 5 compile-fail | Behaviour, including threaded stress for all concurrent types; `N == 0` and EventFlags handle `!Sync` contracts rejected at compile time | +| 77 unit + 11 doctests + 5 compile-fail | Behaviour, including threaded stress for all concurrent types; `N == 0` and EventFlags handle `!Sync` contracts rejected at compile time | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/docs/proposals/event-flags-contract.md b/docs/proposals/event-flags-contract.md index 2725969..19a97bf 100644 --- a/docs/proposals/event-flags-contract.md +++ b/docs/proposals/event-flags-contract.md @@ -121,7 +121,7 @@ tied to a compiler and architecture. | M1, R1-R2, T1-T2, C1, C3, W1-W2 | `event_mask_is_an_explicit_panic_free_32_bit_set`, `duplicate_raises_coalesce_and_take_clears`, `multi_bit_and_all_bit_masks_round_trip`, and `empty_raise_and_empty_take_are_no_ops` | | C1-C3 | Loom's `event_flags_raise_racing_take_is_partitioned_exactly` and `event_flags_distinct_raises_partition_across_takes`; native/Miri threaded stress | | S1 | Loom's `event_flags_observed_raise_publishes_payload`; the model fails with either Release or Acquire independently weakened to Relaxed | -| B1-B3 | Source review; eight-target gated code-size rows; Cortex-M3 QEMU rows; `event-flags-atomic-window.sh` disassembly checks for thumbv6m and ESP32-S2/S3 | +| B1-B3 | Source review; eight-target gated code-size rows; Cortex-M3 QEMU rows; `event-flags-atomic-window.sh` thumbv6m gate in `verify.sh`, ESP32-S2/S3 via `ESP=1` | | H1, H3 | `handles_are_exclusive_and_reusable_after_drop`, including pending-state continuation | | H2 | `handles_are_send_and_container_is_sync`; producer and consumer compile-fail doctests pin `!Sync` | | H4 | `const_new_works_in_static_context` | diff --git a/docs/proposals/event-flags.md b/docs/proposals/event-flags.md index e6dbe8d..8079375 100644 --- a/docs/proposals/event-flags.md +++ b/docs/proposals/event-flags.md @@ -132,9 +132,12 @@ and non-empty code size. | ESP32-S2 (opt-in) | 68 | 23 | 22 | | ESP32-S3 (opt-in) | 145 | 37 | 36 | -`EventFlags` itself is 12 bytes: pending plus two role-claim atomic words. The -existing static buffer remains in `.bss`, and the candidate adds no `.data`, -runtime dependency, allocation, or panic string to either hot path. +`EventFlags` itself is 8 bytes on the host and gated targets: one `AtomicU32` +pending word plus two packed `AtomicBool` role claims (`size_of` is pinned by +unit test). The codesize probe's `bss` row still measures only the existing +`EventBuf` static (`.bss.*BUF`); it does not report the EventFlags object size. +The candidate adds no `.data`, runtime dependency, allocation, or panic string +to either hot path. ### 4.3 Cortex-M3 instruction counts @@ -157,16 +160,20 @@ nor number of set bits changes the executed work. probe. Counts are instructions after the architectural disable instruction through restore/synchronization. -| Target | raise | take | Generated path | -|---|---:|---:|---| -| thumbv6m | 4 | 4 | PRIMASK: load, update/store, restore; straight-line | -| ESP32-S2 | 5 | 5 | PS.INTLEVEL=15: load, update/store, `wsr.ps`, `rsync`; straight-line | -| ESP32-S3 | 0 | 0 | Native `s32c1i`; the esp-rs target advertises 32-bit atomics and does not mask interrupts | - -The thumbv6m and S2 paths each contain exactly one interrupt-masked RMW and no -branch or hidden compare-exchange loop. The S3 result corrects the exploratory -assumption that S2 and S3 share a fallback: under esp-rs 1.95.0-nightly, S3 is -a native-atomic target, so its maximum interrupt-disabled window is zero. +| Target | raise | take | Gating | Generated path | +|---|---:|---:|---|---| +| thumbv6m | 4 | 4 | Always (`./scripts/verify.sh atomic-window`) | PRIMASK: load, update/store, restore; straight-line | +| ESP32-S2 | 5 | 5 | Opt-in (`ESP=1`, needs esp-rs) | PS.INTLEVEL=15: load, update/store, `wsr.ps`, `rsync`; straight-line | +| ESP32-S3 | 0 | 0 | Opt-in (`ESP=1`, needs esp-rs) | Native `s32c1i`; the esp-rs target advertises 32-bit atomics and does not mask interrupts | + +The Docker verify matrix gates thumbv6m only — the reference image does not +ship esp-rs, and a SKIP is not a pass. ESP rows are measured on hosts with the +fork installed (`ESP=1`), the same present-tooling pattern as `XTENSA=1` +codesize. The thumbv6m and S2 paths each contain exactly one interrupt-masked +RMW and no branch or hidden compare-exchange loop. The S3 result corrects the +exploratory assumption that S2 and S3 share a fallback: under esp-rs +1.95.0-nightly, S3 is a native-atomic target, so its maximum interrupt-disabled +window is zero. ## 5. Promotion result diff --git a/docs/records/event-flags.md b/docs/records/event-flags.md index 1a44057..471a00b 100644 --- a/docs/records/event-flags.md +++ b/docs/records/event-flags.md @@ -88,16 +88,18 @@ IDs in parentheses; the clauses are the normative statements. | Racing raise partitions across take windows; never erased (C1–C3) | Loom `event_flags_raise_racing_take_is_partitioned_exactly` and `event_flags_distinct_raises_partition_across_takes`; native/Miri threaded stress | Proven | | Observed raise publishes prior application memory (S1) | Loom `event_flags_observed_raise_publishes_payload`; fails when either Release or Acquire is independently weakened to Relaxed | Proven | | One `fetch_or` / one `swap(0)`; no CAS loop, no history-proportional work (B1–B3) | Source review; Cortex-M3 QEMU: raise **12** / take **8** instructions whether empty or set (constant w.r.t. occupancy and set-bit count) | Measured | -| Portable-atomic ISR windows stay small on constrained targets | `event-flags-atomic-window.sh`: thumbv6m **4**, ESP32-S2 **5**, ESP32-S3 **0** (native `s32c1i`); one masked RMW, no branch/CAS loop | Measured | +| Portable-atomic ISR windows stay small on constrained targets | `event-flags-atomic-window.sh`: thumbv6m **4** always gated in `verify.sh`; ESP32-S2 **5** / ESP32-S3 **0** (native `s32c1i`) opt-in via `ESP=1`; one masked RMW, no branch/CAS loop | Measured | | Sole-role `Send + !Sync` handles; reacquisition continues pending state (H1–H4) | Exclusivity/reuse unit test; `Send` + container-`Sync` type tests; producer/consumer `compile_fail` doctests pin `!Sync`; `const_new_works_in_static_context` | Proven | | Transparent panic-free 32-bit mask (W1–W2) | `EventMask` four-byte transparent layout; `from_index` returns `None` outside `0..32` (no shift panic) | Pinned | | No silent `Source`/`Sink` or peek surface (D4, D5, X5) | Absent from the public API; contract non-promises X5; stream/signal traits deferred on the record | Pinned | -| Cost claims per target | Eight gated code-size rows + three opt-in Xtensa (raise/take 24/24 B thumbv6m, 23/22 B ESP32-S2, 37/36 B ESP32-S3); object is 12 B; static in `.bss` | Measured | +| Cost claims per target | Eight gated code-size rows + three opt-in Xtensa (raise/take 24/24 B thumbv6m, 23/22 B ESP32-S2, 37/36 B ESP32-S3); object is 8 B (`size_of` unit assert); `EventBuf` static remains in `.bss` (codesize `bss` row) | Measured | -Full CI for the lane (`./scripts/verify.sh`, zero skips): 78 unit tests, +Full CI for the lane (`./scripts/verify.sh`, zero skips): 77 unit tests, 11 doctests, 5 compile-fail contracts, 93.60% line coverage, all 8 Loom models plus ordering-mutation checks, detector-on Miri host and -i686/armv7/s390x proxies, feature/embedded builds, supply-chain checks. +i686/armv7/s390x proxies, feature/embedded builds, supply-chain checks, +thumbv6m EventFlags interrupt-window gate. ESP32-S2/S3 window rows remain +opt-in (`ESP=1`) and are outside the Docker zero-SKIP matrix. ## 4. The record diff --git a/scripts/codesize/src/lib.rs b/scripts/codesize/src/lib.rs index 87f3491..24c5f38 100644 --- a/scripts/codesize/src/lib.rs +++ b/scripts/codesize/src/lib.rs @@ -18,7 +18,7 @@ fn panic(_: &PanicInfo) -> ! { /// no flash and no startup code. Measured as `.bss.*BUF`. static BUF: EventBuf = EventBuf::new(); -/// EventFlags is three atomic words: pending plus the two role claims. +/// EventFlags is one AtomicU32 plus two packed AtomicBool role claims (8 B). static FLAGS: EventFlags = EventFlags::new(); #[cfg(feature = "split")] diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh index 7eab795..911312a 100755 --- a/scripts/event-flags-atomic-window.sh +++ b/scripts/event-flags-atomic-window.sh @@ -8,10 +8,20 @@ # extracts its two EventFlags hot-path functions, and checks the exact masked # instruction sequences in their disassembly. # -# ESP32-S3 is deliberately included because the exploratory proposal grouped -# it with S2. The esp-rs target currently advertises native 32-bit atomics and -# emits S32C1I, so its measured interrupt-disabled window is zero. The check -# pins that compiler fact rather than repeating the old assumption. +# Default (always gated by verify.sh): thumbv6m only. That path needs only the +# rust-toolchain.toml targets and llvm-tools — no esp-rs — so the Docker +# reference image can run it with zero SKIPs. +# +# ESP32-S2/S3 rows are opt-in, like codesize.sh's XTENSA=1: +# ESP=1 ./scripts/event-flags-atomic-window.sh +# Without ESP=1 they are not measured and not claimed by the Docker matrix. +# With ESP=1 and missing tooling the script exits 2 (SKIP), never a silent pass. +# +# ESP32-S3 is deliberately included in the opt-in path because the exploratory +# proposal grouped it with S2. The esp-rs target currently advertises native +# 32-bit atomics and emits S32C1I, so its measured interrupt-disabled window is +# zero. The check pins that compiler fact rather than repeating the old +# assumption. set -u @@ -32,32 +42,45 @@ if [ ! -x "$llvm_ar" ] || [ ! -x "$llvm_objdump" ]; then exit 1 fi -if ! rustc +esp --print target-list 2>/dev/null | grep -qx xtensa-esp32s2-none-elf; then - printf 'SKIP: the esp-rs toolchain is not installed (install with espup).\n' - printf 'A SKIP is not a pass -- this script carries the S2/S3 admission rows.\n' - exit 2 -fi +want_esp=0 +case "${ESP:-}" in + 1|true|yes|YES) want_esp=1 ;; +esac -for tool in xtensa-esp32s2-elf-objdump xtensa-esp32s3-elf-objdump; do - if ! command -v "$tool" >/dev/null 2>&1; then - printf 'SKIP: %s is not on PATH (run espup install/export).\n' "$tool" - printf 'A SKIP is not a pass -- this script carries the S2/S3 admission rows.\n' +if [ "$want_esp" -eq 1 ]; then + if ! rustc +esp --print target-list 2>/dev/null | grep -qx xtensa-esp32s2-none-elf; then + printf 'SKIP: the esp-rs toolchain is not installed (install with espup).\n' + printf 'A SKIP is not a pass -- ESP=1 requested the S2/S3 admission rows.\n' exit 2 fi -done + for tool in xtensa-esp32s2-elf-objdump xtensa-esp32s3-elf-objdump; do + if ! command -v "$tool" >/dev/null 2>&1; then + printf 'SKIP: %s is not on PATH (run espup install/export).\n' "$tool" + printf 'A SKIP is not a pass -- ESP=1 requested the S2/S3 admission rows.\n' + exit 2 + fi + done +fi printf '==> rustc %s\n' "$(rustc -vV | sed -n 's/^release: //p')" -printf '==> esp-rs %s\n' "$(rustc +esp -vV | sed -n 's/^release: //p')" -printf '==> building thumbv6m and ESP32-S2/S3 probes\n' +if [ "$want_esp" -eq 1 ]; then + printf '==> esp-rs %s\n' "$(rustc +esp -vV | sed -n 's/^release: //p')" + printf '==> building thumbv6m and ESP32-S2/S3 probes\n' +else + printf '==> building thumbv6m probe (ESP rows opt-in: ESP=1)\n' +fi cargo build --release --target thumbv6m-none-eabi \ - --manifest-path scripts/codesize/Cargo.toml --features cm0 >/dev/null -cargo +esp build --release --target xtensa-esp32s2-none-elf \ - --manifest-path scripts/codesize/Cargo.toml --features cm0 \ - -Zbuild-std=core >/dev/null -cargo +esp build --release --target xtensa-esp32s3-none-elf \ - --manifest-path scripts/codesize/Cargo.toml --features cm0 \ - -Zbuild-std=core >/dev/null + --manifest-path scripts/codesize/Cargo.toml --features cm0 >/dev/null || exit 1 + +if [ "$want_esp" -eq 1 ]; then + cargo +esp build --release --target xtensa-esp32s2-none-elf \ + --manifest-path scripts/codesize/Cargo.toml --features cm0 \ + -Zbuild-std=core >/dev/null || exit 1 + cargo +esp build --release --target xtensa-esp32s3-none-elf \ + --manifest-path scripts/codesize/Cargo.toml --features cm0 \ + -Zbuild-std=core >/dev/null || exit 1 +fi tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT @@ -73,18 +96,6 @@ extract_probe_object() { "$llvm_ar" p "$archive" "$member" > "$output" } -arm_archive="scripts/codesize/target/thumbv6m-none-eabi/release/libph_eventing_codesize.a" -s2_archive="scripts/codesize/target/xtensa-esp32s2-none-elf/release/libph_eventing_codesize.a" -s3_archive="scripts/codesize/target/xtensa-esp32s3-none-elf/release/libph_eventing_codesize.a" - -extract_probe_object "$arm_archive" "$tmp_dir/thumbv6m.o" -extract_probe_object "$s2_archive" "$tmp_dir/esp32s2.o" -extract_probe_object "$s3_archive" "$tmp_dir/esp32s3.o" - -"$llvm_objdump" -d "$tmp_dir/thumbv6m.o" > "$tmp_dir/thumbv6m.txt" -xtensa-esp32s2-elf-objdump -d "$tmp_dir/esp32s2.o" > "$tmp_dir/esp32s2.txt" -xtensa-esp32s3-elf-objdump -d "$tmp_dir/esp32s3.o" > "$tmp_dir/esp32s3.txt" - # Count instructions after the disable instruction through the architectural # restore/synchronization instruction. This is the maximum interval for which # a previously-enabled interrupt remains masked by the operation. @@ -106,21 +117,18 @@ masked_count() { ' "$file" } +arm_archive="scripts/codesize/target/thumbv6m-none-eabi/release/libph_eventing_codesize.a" +extract_probe_object "$arm_archive" "$tmp_dir/thumbv6m.o" +"$llvm_objdump" -d "$tmp_dir/thumbv6m.o" > "$tmp_dir/thumbv6m.txt" + arm_raise="$(masked_count "$tmp_dir/thumbv6m.txt" event_flags_raise 'cpsid[[:space:]]+i' 'msr[[:space:]]+primask')" arm_take="$(masked_count "$tmp_dir/thumbv6m.txt" event_flags_take 'cpsid[[:space:]]+i' 'msr[[:space:]]+primask')" -s2_raise="$(masked_count "$tmp_dir/esp32s2.txt" event_flags_raise 'rsil.*15' 'rsync')" -s2_take="$(masked_count "$tmp_dir/esp32s2.txt" event_flags_take 'rsil.*15' 'rsync')" if [ "$arm_raise" != 4 ] || [ "$arm_take" != 4 ]; then printf 'error: thumbv6m masked window changed (raise=%s take=%s, expected 4/4).\n' \ "${arm_raise:--}" "${arm_take:--}" >&2 exit 1 fi -if [ "$s2_raise" != 5 ] || [ "$s2_take" != 5 ]; then - printf 'error: ESP32-S2 masked window changed (raise=%s take=%s, expected 5/5).\n' \ - "${s2_raise:--}" "${s2_take:--}" >&2 - exit 1 -fi # Both portable paths must remain straight-line. Any branch in a hot function # means the claimed single critical section has acquired a retry/dispatch path. @@ -129,30 +137,55 @@ if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/ printf 'error: thumbv6m EventFlags hot path contains a branch.\n' >&2 exit 1 fi -if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/esp32s2.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]]b[a-z]*[[:space:]]'; then - printf 'error: ESP32-S2 EventFlags hot path contains a branch.\n' >&2 - exit 1 -fi -if grep -Eq 'rsil|wsr\.ps' "$tmp_dir/esp32s3.txt"; then - printf 'error: ESP32-S3 unexpectedly masks interrupts in EventFlags.\n' >&2 - exit 1 -fi -if [ "$(grep -c 's32c1i' "$tmp_dir/esp32s3.txt")" -lt 2 ]; then - printf 'error: ESP32-S3 no longer emits native S32C1I for both hot paths.\n' >&2 - exit 1 +s2_raise="-" +s2_take="-" +if [ "$want_esp" -eq 1 ]; then + s2_archive="scripts/codesize/target/xtensa-esp32s2-none-elf/release/libph_eventing_codesize.a" + s3_archive="scripts/codesize/target/xtensa-esp32s3-none-elf/release/libph_eventing_codesize.a" + extract_probe_object "$s2_archive" "$tmp_dir/esp32s2.o" + extract_probe_object "$s3_archive" "$tmp_dir/esp32s3.o" + xtensa-esp32s2-elf-objdump -d "$tmp_dir/esp32s2.o" > "$tmp_dir/esp32s2.txt" + xtensa-esp32s3-elf-objdump -d "$tmp_dir/esp32s3.o" > "$tmp_dir/esp32s3.txt" + + s2_raise="$(masked_count "$tmp_dir/esp32s2.txt" event_flags_raise 'rsil.*15' 'rsync')" + s2_take="$(masked_count "$tmp_dir/esp32s2.txt" event_flags_take 'rsil.*15' 'rsync')" + + if [ "$s2_raise" != 5 ] || [ "$s2_take" != 5 ]; then + printf 'error: ESP32-S2 masked window changed (raise=%s take=%s, expected 5/5).\n' \ + "${s2_raise:--}" "${s2_take:--}" >&2 + exit 1 + fi + if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/esp32s2.txt" \ + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]]b[a-z]*[[:space:]]'; then + printf 'error: ESP32-S2 EventFlags hot path contains a branch.\n' >&2 + exit 1 + fi + if grep -Eq 'rsil|wsr\.ps' "$tmp_dir/esp32s3.txt"; then + printf 'error: ESP32-S3 unexpectedly masks interrupts in EventFlags.\n' >&2 + exit 1 + fi + if [ "$(grep -c 's32c1i' "$tmp_dir/esp32s3.txt")" -lt 2 ]; then + printf 'error: ESP32-S3 no longer emits native S32C1I for both hot paths.\n' >&2 + exit 1 + fi fi printf '\n%-18s %8s %8s %s\n' TARGET raise take implementation printf '%-18s %8s %8s %s\n' '------------------' '-----' '----' '--------------' printf '%-18s %8s %8s %s\n' thumbv6m "$arm_raise" "$arm_take" \ 'PRIMASK critical section' -printf '%-18s %8s %8s %s\n' esp32-s2 "$s2_raise" "$s2_take" \ - 'PS.INTLEVEL=15 critical section' -printf '%-18s %8s %8s %s\n' esp32-s3 0 0 \ - 'native S32C1I; no interrupt masking' +if [ "$want_esp" -eq 1 ]; then + printf '%-18s %8s %8s %s\n' esp32-s2 "$s2_raise" "$s2_take" \ + 'PS.INTLEVEL=15 critical section' + printf '%-18s %8s %8s %s\n' esp32-s3 0 0 \ + 'native S32C1I; no interrupt masking' +else + printf '%-18s %8s %8s %s\n' esp32-s2 - - 'opt-in: ESP=1 (needs esp-rs)' + printf '%-18s %8s %8s %s\n' esp32-s3 - - 'opt-in: ESP=1 (needs esp-rs)' +fi printf '\nCounts are instructions after interrupt disable through restore/sync.\n' -printf 'Both portable paths are straight-line and contain exactly one read,\n' +printf 'thumbv6m is always gated; ESP rows require ESP=1 and the esp-rs toolchain.\n' +printf 'Portable paths are straight-line and contain exactly one read,\n' printf 'one update/store sequence, and no branch or compare-exchange loop.\n' diff --git a/scripts/loom.sh b/scripts/loom.sh index 95ecc32..6f08063 100755 --- a/scripts/loom.sh +++ b/scripts/loom.sh @@ -35,11 +35,18 @@ export LOOM_MAX_PREEMPTIONS printf '==> loom (max_preemptions=%s)\n' "$LOOM_MAX_PREEMPTIONS" +# A bare name becomes loom_tests::. Leading Cargo/test flags (anything +# starting with '-') must pass through unchanged — rewriting them produced +# filters like loom_tests::--quiet that match nothing and look like a pass. +filter="loom_tests" if [ "$#" -gt 0 ]; then - filter="loom_tests::$1" - shift -else - filter="loom_tests" + case "$1" in + -*) ;; + *) + filter="loom_tests::$1" + shift + ;; + esac fi if RUSTFLAGS='--cfg loom' cargo test --lib "$filter" "$@"; then diff --git a/scripts/verify.sh b/scripts/verify.sh index e27eb66..877764c 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -5,19 +5,20 @@ # builds run without network, but the cargo-deny advisory refresh is # time-varying by design and still wants one. # -# Local runs of ci.sh / miri.sh / loom.sh / cycles.sh are only as reproducible -# as the machine they run on: QEMU builds differ, `+nightly` drifts daily, and -# the optional gates skip when their tools are absent. This wraps the same -# scripts in the image scripts/verify/Dockerfile pins, which is the -# environment the documented numbers come from. See that file for exactly -# what is pinned and why. +# Local runs of ci.sh / miri.sh / loom.sh / cycles.sh / the EventFlags +# atomic-window gate are only as reproducible as the machine they run on: +# QEMU builds differ, `+nightly` drifts daily, and the optional gates skip when +# their tools are absent. This wraps the same scripts in the image +# scripts/verify/Dockerfile pins, which is the environment the documented +# numbers come from. See that file for exactly what is pinned and why. # # Usage: -# ./scripts/verify.sh # ci + miri + loom + cycles, in order +# ./scripts/verify.sh # full matrix, in order # ./scripts/verify.sh ci # one script # ./scripts/verify.sh miri # ./scripts/verify.sh loom # ./scripts/verify.sh cycles +# ./scripts/verify.sh atomic-window # ./scripts/verify.sh shell # interactive shell in the image # # Requires Docker. Everything else is inside the image. @@ -66,30 +67,44 @@ run() { -v "$(pwd):/work" -w /work "$IMAGE" "$@" } +run_script() { + case "$1" in + ci) run sh scripts/ci.sh ;; + miri) run sh scripts/miri.sh ;; + loom) run sh scripts/loom.sh ;; + cycles) run sh scripts/cycles.sh ;; + # thumbv6m only inside the image; ESP rows need esp-rs and stay opt-in + # (ESP=1) outside Docker — see event-flags-atomic-window.sh. + atomic-window) run sh scripts/event-flags-atomic-window.sh ;; + *) + printf 'unknown argument: %s (ci|miri|loom|cycles|atomic-window|shell)\n' "$1" >&2 + return 1 + ;; + esac +} + case "${1:-all}" in shell) DOCKER_TTY=1 run bash ;; - ci) run sh scripts/ci.sh ;; - miri) run sh scripts/miri.sh ;; - loom) run sh scripts/loom.sh ;; - cycles) run sh scripts/cycles.sh ;; all) # Version stamp first, so any pasted output carries its environment. run sh -c 'rustc --version; rustc "+$MIRI_TOOLCHAIN" --version; qemu-system-arm --version | head -1' failed=0 - for s in ci miri loom cycles; do + for s in ci miri loom cycles atomic-window; do printf '\n########## %s ##########\n' "$s" - run sh "scripts/$s.sh" || failed=$((failed + 1)) + run_script "$s" || failed=$((failed + 1)) done if [ "$failed" -gt 0 ]; then printf '\n%s script(s) failed.\n' "$failed" exit 1 fi printf '\nFull matrix passed in the reference environment.\n' + printf 'Note: EventFlags ESP32-S2/S3 interrupt windows are opt-in\n' + printf '(ESP=1 ./scripts/event-flags-atomic-window.sh); they are not\n' + printf 'part of this Docker matrix (no esp-rs in the image).\n' ;; *) - printf 'unknown argument: %s (ci|miri|loom|cycles|shell)\n' "$1" >&2 - exit 1 + run_script "$1" || exit 1 ;; esac diff --git a/scripts/verify/Dockerfile b/scripts/verify/Dockerfile index 76459ca..86b5fb2 100644 --- a/scripts/verify/Dockerfile +++ b/scripts/verify/Dockerfile @@ -28,10 +28,15 @@ # # Run any script inside it, or everything: # -# ./scripts/verify.sh # ci.sh + miri.sh + loom.sh + cycles.sh +# ./scripts/verify.sh # ci + miri + loom + cycles + atomic-window # ./scripts/verify.sh cycles # one of them +# ./scripts/verify.sh atomic-window # EventFlags thumbv6m interrupt-mask gate # ./scripts/verify.sh shell # poke around # +# Deliberately NO esp-rs here. EventFlags ESP32-S2/S3 interrupt-window rows are +# opt-in on hosts that have the fork (ESP=1); baking esp-rs into this image +# would make the fork mandatory for a green verify matrix. +# # The stable toolchain baked here is whatever was current at image build time. # That is deliberate: the stable check exists to catch regressions on a NEWER # compiler than the pin, so freezing it would defeat its purpose. Every run diff --git a/src/event_flags.rs b/src/event_flags.rs index 0945119..b657426 100644 --- a/src/event_flags.rs +++ b/src/event_flags.rs @@ -286,6 +286,14 @@ mod tests { const DATA_READY: EventMask = EventMask::from_bits(1 << 0); const OVERFLOW: EventMask = EventMask::from_bits(1 << 1); + #[test] + fn event_flags_object_is_eight_bytes() { + // Pending AtomicU32 + two packed AtomicBool role claims. Docs and the + // admission record cite this figure; keep it from drifting silently. + assert_eq!(core::mem::size_of::(), 8); + assert_eq!(core::mem::align_of::(), 4); + } + #[test] fn event_mask_is_an_explicit_panic_free_32_bit_set() { // Contract M1, R1, and W1-W2. From c9dd4719d8058b3b931b764e9baf127795e19ec8 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:06:42 -0400 Subject: [PATCH 26/87] Scope ESP32-S3 S32C1I gate to EventFlags hot paths. Whole-object s32c1i counts could pass on bringup/acquire alone while raise/take regress off the native path. Co-authored-by: Cursor --- CHANGELOG.md | 3 +++ scripts/event-flags-atomic-window.sh | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3941ea0..7daa597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ All notable changes to this project will be documented in this file. not rewritten into a no-op `loom_tests::--` filter. - `EventFlags` object-size claim corrected from 12 B to the measured 8 B (`size_of` unit assert); AGENTS.md role-claim wording aligned with the AcqRel `swap` implementation. +- ESP32-S3 opt-in atomic-window gate now requires native `s32c1i` inside + `event_flags_raise` and `event_flags_take` specifically; a whole-object count + could pass on `bringup_two_calls` / `event_flags_acquire_roles` alone. ### Documentation - Cycle decisions **P** and **S** are closed (2026-08-11). **P**: BlockBuf's publication diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh index 911312a..72ab294 100755 --- a/scripts/event-flags-atomic-window.sh +++ b/scripts/event-flags-atomic-window.sh @@ -161,12 +161,20 @@ if [ "$want_esp" -eq 1 ]; then printf 'error: ESP32-S2 EventFlags hot path contains a branch.\n' >&2 exit 1 fi - if grep -Eq 'rsil|wsr\.ps' "$tmp_dir/esp32s3.txt"; then - printf 'error: ESP32-S3 unexpectedly masks interrupts in EventFlags.\n' >&2 + # Masking and S32C1I checks are scoped to the EventFlags hot paths. The + # probe object also contains bringup_two_calls / event_flags_acquire_roles, + # each of which can emit S32C1I on their own — a whole-object count of 2 + # would pass while raise/take regress away from the native path. + if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/esp32s3.txt" \ + | grep -Eq 'rsil|wsr\.ps'; then + printf 'error: ESP32-S3 EventFlags hot path unexpectedly masks interrupts.\n' >&2 exit 1 fi - if [ "$(grep -c 's32c1i' "$tmp_dir/esp32s3.txt")" -lt 2 ]; then - printf 'error: ESP32-S3 no longer emits native S32C1I for both hot paths.\n' >&2 + s3_raise_s32="$(sed -n '/:/,/^$/p' "$tmp_dir/esp32s3.txt" | grep -c 's32c1i' || true)" + s3_take_s32="$(sed -n '/:/,/^$/p' "$tmp_dir/esp32s3.txt" | grep -c 's32c1i' || true)" + if [ "$s3_raise_s32" -lt 1 ] || [ "$s3_take_s32" -lt 1 ]; then + printf 'error: ESP32-S3 EventFlags hot paths missing native S32C1I (raise=%s take=%s).\n' \ + "$s3_raise_s32" "$s3_take_s32" >&2 exit 1 fi fi From 9d65bdf68e7d54fcdcdf0104f398d6c180e5e7ae Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:16:33 -0400 Subject: [PATCH 27/87] Fix CountedSignal stale-MAX short-circuit that dropped post-take increments. Confirm saturation with MAX->MAX CAS (or fetch_add into the reset epoch) so a completed take cannot lose a later occurrence under Relaxed observation; pin with a Loom litmus and bless the intentional codesize growth. Co-authored-by: Cursor --- AGENTS.md | 45 ++++++++++--- CHANGELOG.md | 23 ++++--- README.md | 11 ++-- docs/proposals/counted-signal-contract.md | 25 +++++--- docs/proposals/counted-signal.md | 75 +++++++++++++--------- docs/records/counted-signal.md | 77 ++++++++++++----------- scripts/codesize/baseline.tsv | 16 ++--- src/counted_signal.rs | 51 +++++++++------ src/loom_tests.rs | 44 ++++++++++++- 9 files changed, 243 insertions(+), 124 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c199e93..558a3b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,7 +164,7 @@ ph-eventing/ | `event_buf::Producer<'a, T, N>` | EventBuf write handle; `push(T) -> Result<(), T>` | | `event_buf::Consumer<'a, T, N>` | EventBuf read handle; `pop() -> Option`, `peek()`, `drain()` | | `CountedSignal` | Saturating count for payload-free SPSC events | -| `counted_signal::Producer<'a>` | Sole incrementing handle; bounded load plus conditional RMW | +| `counted_signal::Producer<'a>` | Sole incrementing handle; `fetch_add` below MAX, CAS-confirmed sentinel | | `counted_signal::Consumer<'a>` | Sole taking handle; `swap(0)` partitions count epochs | | `Sink` | Trait — accept events via `try_push(&mut self, T) -> Result<(), Error>` | | `Source` | Trait — yield events via `try_pop(&mut self) -> Option` | @@ -186,13 +186,19 @@ The `SeqRing` implementation uses careful atomic ordering for thread safety: - The semantic contract and stable clause IDs live in `docs/proposals/counted-signal-contract.md`. -- The sole producer performs a Relaxed load and, below `u32::MAX`, one Relaxed - `fetch_add`. The consumer performs a Relaxed `swap(0)`. +- The sole producer loads the counter (Relaxed). Below `u32::MAX` it + `fetch_add`s once (Relaxed). Observed `u32::MAX` is maybe-stale: a + `compare_exchange(MAX, MAX)` success is a saturated no-op; failure means a + completed take reset the epoch and the producer `fetch_add`s once into it. + Under sole-producer ownership that is at most one contention retry and the + RMW cannot wrap. +- The consumer performs a Relaxed `swap(0)`. - Relaxed is sufficient because there is no payload publication; the atomic's modification order alone partitions increments between take epochs. -- Producer exclusivity is load-bearing. Between its load and RMW, only the - consumer can write and it can only lower the count, so the RMW cannot wrap. - Never make the producer handle `Sync` without replacing this algorithm. +- Producer exclusivity is load-bearing. Never make the producer handle `Sync` + without replacing this algorithm. Do not restore a load-and-skip-on-`MAX` + short-circuit — after a completed take it can drop an occurrence from both + intervals (T3/A1). ### Memory Ordering Strategy (EventBuf) @@ -692,7 +698,7 @@ Cross-environment diffs of ±1 are noise; compare inside the image. | `SeqRing::latest_value` | — | 30 | | | `RingBuf::push` | 20 | **20** (overwriting) | | | `RingBuf::get` / `latest` | 22 / 16 | | | -| `CountedSignal::increment` / `take_count` | 8 / 7 | | | +| `CountedSignal::increment` / `take_count` | 8 / 7 (pre-CAS; re-measure) | | | Three results carry the argument: @@ -807,7 +813,9 @@ Individual checks are also available as cargo aliases (see ## Testing -Tests are in `src/ring.rs`, `src/seq_ring.rs`, `src/event_buf.rs`, and `src/traits.rs` in their respective `tests` modules. They require std and use the standard Rust test framework. +Tests are in `src/ring.rs`, `src/seq_ring.rs`, `src/event_buf.rs`, +`src/counted_signal.rs`, and `src/traits.rs` in their respective `tests` +modules. They require std and use the standard Rust test framework. **Run tests:** ```bash @@ -846,6 +854,14 @@ cargo test - `const_new_works_in_const_context` — `static` / const `new()` (`#[cfg(not(loom))]`) - `static_buf_yields_static_sendable_handles` — `'static`, `Send` handles off a `static` buffer +**`counted_signal::tests`:** +- `increments_accumulate_and_take_clears` — Exact accumulate + take clears (I1, T1, T4, A1) +- `saturates_instead_of_wrapping` — Boundary at `u32::MAX` (I2–I3, T2, A2–A3) +- `handles_are_exclusive_and_reusable_after_drop` — Role exclusivity and state continuation (H1, H3) +- `handles_are_send` — Producer/Consumer are Send (H2); compile-fail doctests pin `!Sync` +- `const_new_works_in_static_context` — `static` / const `new()` (`#[cfg(not(loom))]`) (H4) +- `concurrent_takes_do_not_lose_increments` — Threaded no-loss stress (T3, A1) + **`seq_ring::tests`:** - `poll_one_empty_returns_false` — Empty ring behavior - `polls_in_order` — Sequential consumption @@ -908,6 +924,14 @@ two pin the CountedSignal handles' `!Sync` contract. - Unsafe code is confined to `MaybeUninit` / `UnsafeCell` slot access in all three buffers - `CountedSignal` handles are `Send + !Sync`; the producer's `!Sync` property is part of the saturation proof, not merely API uniformity +- `CountedSignal`: `increment` uses `fetch_add` below `MAX` and confirms the + sentinel with `compare_exchange(MAX, MAX)` (or one follow-up `fetch_add` after + a take reset) — never a plain load-and-skip on `MAX`, which drops post-take + occurrences under Relaxed observation (T3/A1) +- `CountedSignal`: under sole producer, at most one contention retry from the + consumer's `swap(0)`; the counter never wraps +- `CountedSignal`: `take_count` is a single Relaxed `swap(0)` that partitions + every increment into exactly one take epoch - No panics in hot paths. All three `new()` reject `N == 0` with a **const** assertion, so a zero-capacity buffer fails the build rather than panicking — which also means no test can cover the rejection, and the const assertion is @@ -959,6 +983,11 @@ The project supports these targets (defined in `rust-toolchain.toml`): - `EventBuf`: `len()` never exceeds `N` and never blocks, even while both handles are active - `SeqRing`: `dropped_accum` saturates — it must never overflow, and `usize` is 32-bit on every shipped target - `EventBuf`: Producer and Consumer handles are `Send + !Sync` +- `CountedSignal`: Producer and Consumer handles are `Send + !Sync`; producer + exclusivity is load-bearing for wrap-free saturation (B1) +- `CountedSignal`: never restore a load-and-skip-on-`MAX` short-circuit; the + CAS path exists so a post-take increment cannot vanish under Relaxed + observation of a stale sentinel - `SeqRing` / `EventBuf`: the panicking `producer()` / `consumer()` were deprecated in 0.2.0 and **removed in 0.3.0**. `try_producer()` / `try_consumer()` are the only handle-acquisition API; a panic is a reset on the targets this crate exists for. Do not reintroduce a panicking diff --git a/CHANGELOG.md b/CHANGELOG.md index d02171d..454b014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,17 +3,26 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Fixed +- `CountedSignal::increment`: replace the load-and-skip-on-`MAX` short-circuit + with a sole-producer–bounded CAS that treats observed `MAX` as maybe-stale + (`MAX → MAX` confirms saturation; failure retries once into the post-take + epoch). A completed `take_count` followed by a later `increment` can no + longer lose the occurrence under Relaxed observation (contract T3/A1). Loom + litmus: `counted_signal_post_take_increment_observes_reset_epoch`. ### Documentation -- CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned 8/7 Cortex-M3 rows, and the H closure record. +- CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned Cortex-M3 rows (re-measure after CAS), and the H closure record. +- CountedSignal contract B1: at most one contention retry from the sole + consumer reset; proposal §3.1 linearization claim corrected; README Sink/Source + claim qualified to payload-buffer handles. ### Added - `CountedSignal`: a payload-free SPSC counter with a bounded `increment`, atomic `take_count`, exact `u32` saturation, and observable - saturation. Loom models pin both ordinary take partitioning and the - saturation-boundary interleaving; its frozen contract maps citable clauses - to unit, threaded, Loom, Miri, code-size, and QEMU evidence. The reference - Cortex-M3 probe measures 8 retired instructions for `increment` and 7 for - `take_count`. Exact bounded saturation depends on retaining a sole - `Send + !Sync` producer handle. + saturation. Loom models pin take partitioning, the saturation-boundary + interleaving, and the post-take stale-`MAX` litmus; its frozen contract maps + citable clauses to unit, threaded, Loom, Miri, code-size, and QEMU evidence. + Exact bounded saturation depends on retaining a sole `Send + !Sync` producer + handle. Cortex-M3 instruction counts require re-measure after the CAS path. ### Removed - **Breaking:** the panicking `SeqRing::{producer, consumer}` and `EventBuf::{producer, consumer}`, deprecated since 0.2.0 with removal scheduled for 0.3.0. diff --git a/README.md b/README.md index 1d5b2c9..509057a 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,9 @@ assert!(producer.push(3).is_ok()); // space freed ### CountedSignal A saturating count for repeated events whose payload and ordering do not -matter. The sole producer is load-bearing: it permits exact saturation -with a bounded load plus conditional `fetch_add`, without a CAS retry loop. +matter. The sole producer is load-bearing: it permits exact saturation with a +bounded compare-exchange that treats observed `u32::MAX` as maybe-stale, with +at most one contention retry from the consumer reset. ```rust use ph_eventing::CountedSignal; @@ -155,8 +156,10 @@ assert!(!snapshot.is_saturated()); ### Common Traits -All producers implement `Sink` and all consumers implement `Source`, -so you can write generic code that works with any combination: +Payload-buffer producers implement `Sink` and their consumers implement +`Source`, so you can write generic code that works across `RingBuf`, +`SeqRing`, and `EventBuf`. Signal types such as `CountedSignal` are outside +that stream vocabulary (no `T` payload). ```rust use ph_eventing::{SeqRing, EventBuf}; diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index a1b6c2b..9cb349c 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -78,8 +78,9 @@ Algorithmic bounds live here; instruction counts remain measured claims tied to a target, toolchain, and reference environment. - **B1.** `increment` performs a statically bounded amount of work independent - of signal history and consumer activity: no retry loop, no dynamic - allocation, no user code, and no wait for the consumer. + of signal history and consumer activity: at most one contention retry from + the sole consumer's reset (no unbounded retry loop), no dynamic allocation, + no user code, and no wait for the consumer. - **B2.** `take_count` performs a statically bounded amount of work independent of the number of increments in the interval and producer activity: no retry loop, no dynamic allocation, no user code, and no wait for the producer. @@ -115,10 +116,12 @@ to a target, toolchain, and reference environment. The accepted answer shared with EventFlags is H1–H4: sole-role `Send + !Sync` handles with `&self` hot-path operations and state resident in the signal. For CountedSignal, this is a correctness choice rather than an API -style preference. The bounded candidate implementation reads the state and -then conditionally increments it. Sole-producer ownership means the consumer -is the only possible intervening writer and can only reset the state; a second -raiser would invalidate the no-wrap proof and the current evidence for B1. +style preference. The bounded candidate implementation confirms saturation with +a compare-exchange and advances below the sentinel the same way. Sole-producer +ownership means the consumer is the only possible intervening writer and can +only reset the state, so there is at most one contention retry and the RMW +cannot wrap; a second raiser would invalidate the no-wrap proof and the current +evidence for B1. The independent LatestBuf A.3 decision currently recommends the same handle shape. This closes H for EventFlags and CountedSignal only. Whether the @@ -132,17 +135,19 @@ a separate maintainer decision on issue #26. | I1, T1, T4, A1 | `increments_accumulate_and_take_clears`; the threaded `concurrent_takes_do_not_lose_increments` sum check | | I2–I3, T2, A2–A3 | `saturates_instead_of_wrapping`; Loom's `counted_signal_saturation_boundary_is_linearizable` model | | T1–T3, A1 | Loom's `counted_signal_take_partitions_increments` model; threaded take stress | -| B1–B2 | Source review (no loops); eight-target gated code-size rows; pinned Cortex-M3 probe: 8 retired instructions for `increment`, 7 for `take_count` under rustc 1.92.0 (`ded5c06cf`) and QEMU 10.0.11 | +| T3, A1 (post-take / stale MAX) | Loom's `counted_signal_post_take_increment_observes_reset_epoch` model (seeded at `MAX`; Relaxed gate only) | +| B1–B2 | Source review (≤1 contention retry under H1); eight-target gated code-size rows blessed post-fix (46–76 B `increment`); Cortex-M3 cycles pending re-measure after sentinel CAS | | B3 | Source review plus normal, Miri, Loom, and embedded-target executions of both hot paths | | H1, H3 | `handles_are_exclusive_and_reusable_after_drop`, including state continuation after reacquisition | | H2 | `handles_are_send`; producer and consumer compile-fail doctests pin `!Sync` | | H4 | `const_new_works_in_static_context` | -The candidate's relaxed atomic orderings and its load-plus-conditional-RMW -proof are implementation evidence for this abstract contract. They remain in +The candidate's relaxed atomic orderings and its sole-producer CAS proof are +implementation evidence for this abstract contract. They remain in [`counted-signal.md`](counted-signal.md) §3.1 and the module documentation, not in the semantic clauses above. The candidate tree passed the complete pinned reference matrix with zero skips on 2026-08-11: CI/features/docs/deny/coverage, Miri host and proxy targets, all -seven Loom models, eight-target code size, embedded checks, and QEMU cycles. +Loom models, eight-target code size, embedded checks, and QEMU cycles. Re-run +after the MAX short-circuit fix before treating that stamp as current. diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index 42cd96c..bfe67e9 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -63,8 +63,9 @@ should remain statically sized and avoid a general dynamic registry. ## 3. Resolved candidate decisions -- Saturation uses the sole-producer algorithm in §3.1: one Relaxed load and, - below `u32::MAX`, one Relaxed `fetch_add`. There is no retry loop. +- Saturation uses the sole-producer algorithm in §3.1: a Relaxed load plus a + compare-exchange that treats observed `u32::MAX` as maybe-stale, with at most + one contention retry from the sole consumer reset. - `u32::MAX` is the observable saturation sentinel, wrapped in `CountSnapshot`; exact excess beyond the sentinel is intentionally absent. - `take_count` is a `swap(0)` and reports one take interval, not a running @@ -83,26 +84,35 @@ answer to the central question that is stronger than the three initially listed, but only under a deliberately narrow handle model: ```rust -if count.load(Relaxed) != u32::MAX { - count.fetch_add(1, Relaxed); +let observed = count.load(Relaxed); +if observed == u32::MAX + && count.compare_exchange(u32::MAX, u32::MAX, Relaxed, Relaxed).is_ok() +{ + return; // confirmed saturated no-op } +count.fetch_add(1, Relaxed); ``` -This is **not** correct for multiple producers: two producers could both load -`u32::MAX - 1` and the second `fetch_add` would wrap. It is correct with the -crate's existing sole `Send + !Sync` producer handle. Between that handle's -load and `fetch_add`, the consumer's `swap(0)` is the only possible competing -write and it can only lower the value. The RMW therefore increments either the -observed epoch or the newly reset epoch and cannot wrap. If the load observes -`u32::MAX`, that no-op linearizes before a concurrent take and is already -represented by the saturated snapshot. +A plain load-and-skip-on-`MAX` short-circuit is **not** exact: after +`take_count` has returned, a later `increment` may still observe a stale +`MAX` under Relaxed ordering and drop the occurrence from both take +intervals (violating T3/A1). The sentinel path therefore confirms with +`MAX → MAX` — success is a true saturated no-op; failure falls through to +`fetch_add` into the post-take epoch (at most one contention retry under B1). + +This remains **not** correct for multiple producers: two producers could both +observe `u32::MAX - 1` and the second `fetch_add` would wrap. It is correct with the +crate's existing sole `Send + !Sync` producer handle. Between the load and +RMW (or between the failed sentinel CAS and the follow-up `fetch_add`), the +consumer's `swap(0)` is the only possible competing write and it can only lower +the value, so the RMW cannot wrap. Consequences for the deferred decisions: | Choice | Evaluation result | |---|---| -| Sole `Send + !Sync` producer, methods take `&self` | Exact saturation; bounded to one load plus at most one RMW; matches existing handle ownership. | -| Shareable/multiple raisers | The two-operation proof fails; needs an unbounded CAS loop, a weaker overflow contract, or a substantially more complex bounded algorithm. | +| Sole `Send + !Sync` producer, methods take `&self` | Exact saturation; common path is one load plus one `fetch_add`; sentinel path adds one confirming CAS and at most one follow-up `fetch_add`; matches existing handle ownership. | +| Shareable/multiple raisers | The intervening-writer proof fails; needs an unbounded CAS loop, a weaker overflow contract, or a substantially more complex bounded algorithm. | | `u32::MAX` reserved sentinel wrapped by `CountSnapshot` | Full exact range below the sentinel; `is_saturated()` observes saturation in the take that clears it; the snapshot remains one word and needs no second sticky atomic. | | `swap(0)` take, relaxed ordering | Counts only, with no payload publication to order. Atomic modification order partitions each increment into exactly one take epoch. | @@ -119,40 +129,42 @@ Evidence added with the prototype: saturated (never wrapped) snapshot; - a threaded stress test proves repeated concurrent takes preserve the sum of 100,000 increments; -- Loom exhaustively models ordinary take partitioning and the load/take/RMW - interleaving at `u32::MAX - 1`; +- Loom exhaustively models ordinary take partitioning, the observe/take/commit + interleaving at `u32::MAX - 1`, and a post-take increment seeded at `MAX` + sequenced only by a Relaxed gate; - the cycle and code-size probes include the ISR-side `increment` and consumer `take_count` paths so the remaining admission decision can be made from per-target measurements rather than from source shape. The isolated release-mode code-size rows on the pinned Rust 1.92.0 toolchain -are small and, importantly, expose the architecture split for review: +(after the sentinel-CAS fix) expose the architecture split for review: | Target family | `increment` | `take_count` | |---|---:|---:| -| Cortex-M0 (`thumbv6m`) | 30 B | 24 B | -| Cortex-M23 (`thumbv8m.base`) | 28 B | 22 B | -| Cortex-M3/M4/M33 | 28 B | 22 B | -| Armv7-R / Armv7-A | 40 B | 28 B | -| RV32IMAC | 18 B | 8 B | +| Cortex-M0 (`thumbv6m`) | 46 B | 24 B | +| Cortex-M23 (`thumbv8m.base`) | 56 B | 22 B | +| Cortex-M3/M4/M33 | 54 B | 22 B | +| Armv7-R / Armv7-A | 76 B | 28 B | +| RV32IMAC | 34 B | 8 B | The M0/M23 rows use the existing portable-atomic single-core probe backend. +Versus the pre-fix load-and-skip path this is a deliberate +16…+36 B growth +on gated rows: exactness after a completed take requires the confirming CAS. -The cycle probe has also been run in the pinned reference environment via -`./scripts/verify.sh cycles`: +The cycle probe was previously measured in the pinned reference environment via +`./scripts/verify.sh cycles` for the load-and-skip path: | Cortex-M3 hot path | Retired guest instructions | |---|---:| -| `increment` | 8 | +| `increment` | 8 (pre-fix; re-measure after sentinel CAS) | | `take_count` | 7 | Environment stamp: rustc 1.92.0 (`ded5c06cf`), LLVM 21.1.3, QEMU 10.0.11 (Debian trixie), release `opt-level = "z"` with LTO. The runner subtracts its marker overhead and uses one guest instruction per translation block with `-icount shift=0`; these are deterministic instruction/tick counts for that -environment, not a universal microarchitectural cycle claim. Together with -the eight-target code-size rows, this closes the lane's missing pinned-cost -evidence. +environment, not a universal microarchitectural cycle claim. Re-run cycles in +the reference image before treating the 8/7 cells as current. ## 3.2 Shared handle decision @@ -167,7 +179,7 @@ type's proof. ## 4. Promotion bar to PROPOSED 1. **Complete for the SPSC candidate:** the bounded-saturating-increment - question has the load-plus-conditional-RMW answer proved in §3.1. + question has the sole-producer CAS answer proved in §3.1. 2. **Complete:** the shared handle model is sole-role `Send + !Sync` handles with `&self` operations for both this lane and EventFlags. The proof dependency is recorded in the contract's H-decision section. @@ -176,7 +188,8 @@ type's proof. I/T/A/B/H/X clause IDs and maps each guarantee to evidence. 4. **Complete for the SPSC candidate:** eight-target code-size rows and pinned Cortex-M3 instruction counts carry the cost case; the unit boundary test, - threaded stress, Miri, and two Loom models cover atomic take, - no-lost-increment accounting, and saturation without wrapping. + threaded stress, Miri, and three Loom models cover atomic take, + no-lost-increment accounting, saturation without wrapping, and the + post-take stale-`MAX` litmus. The promotion bar is complete; the candidate is ready for evaluation. diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index 151158f..24afb2d 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -1,13 +1,13 @@ # CountedSignal — engineering record - **Status:** candidate, PROPOSED — complete admission package on - `candidate/counted-signal` (draft PR #33); accepted contract; shared - handle decision H closed on this lane's evidence; awaiting acceptance - review and release assembly. + `candidate/counted-signal` (PR #33); accepted contract; shared + handle decision H closed on this lane's evidence; MAX short-circuit + exactness fix landed; awaiting acceptance review and release assembly. - **Normative sources:** [contract](../proposals/counted-signal-contract.md) (clause IDs cited below) · [proposal](../proposals/counted-signal.md) · measurements inline in proposal §3.1 (eight-target code size; pinned - Cortex-M3 cycles via `./scripts/verify.sh cycles`). + Cortex-M3 cycles via `./scripts/verify.sh cycles` — re-measure after CAS). ## 1. Value statement @@ -19,11 +19,11 @@ linearizes exactly once into a take interval; when the exact range is exhausted the signal saturates observably rather than wrapping — the same `dropped_accum` honesty the crate already requires of overload accounting. Its soundness argument for exact, wrap-free saturation is -**sole-producer ownership**: between the producer's load and conditional -RMW the consumer can only reset, so the RMW cannot wrap. It complements -`EventFlags` (presence) rather than replacing it, and it refuses to be: -a payload channel, an ordering authority, a multi-producer bus, or an -unbounded exact total past saturation. +**sole-producer ownership**: between CAS attempts the consumer can only +reset, so the RMW cannot wrap and there is at most one contention retry. +It complements `EventFlags` (presence) rather than replacing it, and it +refuses to be: a payload channel, an ordering authority, a multi-producer +bus, or an unbounded exact total past saturation. ## 2. Risks and integration concerns @@ -38,11 +38,11 @@ IDs in parentheses; the clauses are the normative statements. saturation is a fault rather than a design point. - **Sole producer is load-bearing, not style (H1–H2, X4, H-decision).** The no-wrap / B1 proof depends on one active producer: two raisers - could both observe `u32::MAX - 1` and the second `fetch_add` would - wrap. Shareable or multi-producer signalling needs a **separate** - type, contract, algorithm, and evidence case — not a weakened reading - of this one. `&self` on the hot path is a receiver choice; it does - not permit sharing a handle (`Send + !Sync` remains). + could both observe `u32::MAX - 1` and the second commit would wrap. + Shareable or multi-producer signalling needs a **separate** type, + contract, algorithm, and evidence case — not a weakened reading of + this one. `&self` on the hot path is a receiver choice; it does not + permit sharing a handle (`Send + !Sync` remains). - **No payload, no per-occurrence identity (X1, X2).** Only the count in a take interval is retained. Ordering between individual increments is intentionally absent; unrelated data gains no publication fence @@ -54,9 +54,10 @@ IDs in parentheses; the clauses are the normative statements. destructors, or a forgotten handle, leaves the role held. There is deliberately no out-of-band forced release. - **Algorithmic bounds are not wall-clock claims (B1–B3, X5).** - `increment` and `take_count` are statically bounded (no retry loop, - no wait, no panic on the hot path). Instruction and latency numbers - are environment-specific measurements; cite them with the pinned + `increment` allows at most one contention retry from the sole + consumer reset; `take_count` has no retry loop. Neither waits or + panics on the hot path. Instruction and latency numbers are + environment-specific measurements; cite them with the pinned toolchain and QEMU stamp, never as universal cycle truths. - **Constrained-target atomics are measured, not free.** On thumbv6m / thumbv8m.base the RMW path uses the portable-atomic single-core @@ -70,29 +71,33 @@ IDs in parentheses; the clauses are the normative statements. | Exact accumulate + take clears; no fabricated/duplicated counts (I1, T1, T4, A1) | `increments_accumulate_and_take_clears`; threaded `concurrent_takes_do_not_lose_increments` sum check (100,000 increments) | Proven | | Saturates at `u32::MAX`, never wraps; sticky until take (I2–I3, T2, A2–A3) | `saturates_instead_of_wrapping`; Loom `counted_signal_saturation_boundary_is_linearizable` | Proven | | Concurrent increment belongs to exactly one take interval (T1–T3, A1) | Loom `counted_signal_take_partitions_increments`; threaded take stress | Proven | -| Wait-free bounded hot paths: load + ≤1 `fetch_add`; `swap(0)` take (B1–B2) | Source review (no loops); pinned Cortex-M3: 8 retired instructions `increment`, 7 `take_count` (rustc 1.92.0 / QEMU 10.0.11) | Measured | +| Post-take increment after saturated take is not dropped by a stale MAX observe (T3, A1) | Loom `counted_signal_post_take_increment_observes_reset_epoch` (Relaxed gate only) | Proven | +| Wait-free bounded hot paths: load + `fetch_add`, sentinel CAS + ≤1 follow-up `fetch_add`; `swap(0)` take (B1–B2) | Source review; eight-target code size blessed after intentional +16…+36 B growth; Cortex-M3 cycles pending re-measure (was 8 / 7 pre-fix) | Measured (cycles pending) | | No panic reachable from hot paths (B3) | Source review; normal, Miri, Loom, and embedded-target executions | Proven | | Sole-role exclusive handles; reacquisition continues state (H1, H3) | `handles_are_exclusive_and_reusable_after_drop` | Proven | | Handles are `Send + !Sync` (H2) | `handles_are_send`; compile-fail doctests pin `!Sync` on both roles | Pinned | | Constructible in static storage (H4) | `const_new_works_in_static_context` | Proven | -| Cost claims per target | Eight-target gated code-size rows (proposal §3.1); architecture split exposed for M0+/M23 vs M3/M4/M33 vs RV32 | Measured | +| Cost claims per target | Eight-target gated code-size rows (proposal §3.1); re-bless if CAS grows any gated row beyond +16 B | Measured | Full CI for the lane (`0a22ada` admission package; `f26d4c3` H finalization): complete pinned `./scripts/verify.sh` matrix with zero -skips — 75 unit tests, 11 doctests, 5 compile-fail, 93.75% line -coverage, Miri host and proxy targets, all seven Loom models, -eight-target code size, embedded checks, QEMU cycles. +skips — unit tests, 11 doctests, 5 compile-fail, coverage, Miri host +and proxy targets, Loom models, eight-target code size, embedded +checks, QEMU cycles. Re-run after the MAX short-circuit fix. ## 4. The record **Decision history (closed on `candidate/counted-signal`, canonical text in contract §8 and proposal §3.1–§3.2):** -- **Bounded exact saturation: sole-producer load + conditional - `fetch_add`.** Below `u32::MAX`, one Relaxed load and at most one - Relaxed `fetch_add`; at the sentinel, a no-op that already linearizes - into the saturated snapshot. Rejected for this type: an unbounded CAS - loop (fails B1), silent wrap (fails A2/I3 and the crate's +- **Bounded exact saturation: sole-producer `fetch_add` with stale-MAX + confirmation.** Below `MAX`, one Relaxed load and one Relaxed + `fetch_add`. Observed `MAX` is confirmed with `compare_exchange(MAX, + MAX)`; success is a saturated no-op, failure falls through to one + `fetch_add` into the post-take epoch (at most one contention retry). + Rejected for this type: a load-and-skip-on-`MAX` short-circuit (fails + T3/A1 after a completed take under Relaxed observation), an unbounded + CAS loop (fails B1), silent wrap (fails A2/I3 and the crate's saturate-don't-wrap convention), and shareable/multiple raisers (invalidates the intervening-writer argument). Closure set the disclosure standard: the no-wrap proof is an ownership proof — state @@ -116,15 +121,17 @@ in contract §8 and proposal §3.1–§3.2):** own caller and evidence. Width genericity would create target-dependent contracts. -**Review history:** issue #30 closed with the lane PROPOSED; draft PR -#33 carries the complete admission package. Cross-lane handle decision -recorded against #29; cycle decision matrix remains #26. Contract clause -IDs (I/T/A/B/H/X) are load-bearing for tests and models — do not -renumber. +**Review history:** issue #30 closed with the lane PROPOSED; PR #33 +carries the admission package. Bugbot high finding (stale MAX load drops +increments) agreed and fixed with the CAS path + Loom litmus. Codex +P2 (qualify README Sink/Source claim) agreed. Cross-lane handle +decision recorded against #29; cycle decision matrix remains #26. +Contract clause IDs (I/T/A/B/H/X) are load-bearing for tests and models +— do not renumber. **Where the numbers live:** proposal §3.1 (eight-target `increment` / -`take_count` code-size table; pinned Cortex-M3 8 / 7 instruction -regions under rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11); -contract §9 evidence map; tracking: issue #30, PR #33; completion +`take_count` code-size table; pinned Cortex-M3 instruction regions under +rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11 — re-measure after +CAS); contract §9 evidence map; tracking: issue #30, PR #33; completion commits `0a22ada` (clause-numbered contract + pinned costs) and `f26d4c3` (H closed, promotion finalized). diff --git a/scripts/codesize/baseline.tsv b/scripts/codesize/baseline.tsv index 9d475c1..8a3befe 100644 --- a/scripts/codesize/baseline.tsv +++ b/scripts/codesize/baseline.tsv @@ -8,42 +8,42 @@ # Xtensa is deliberately absent -- it needs the esp-rs fork, and gating it # would make that fork mandatory for every contributor. armv7a-none-eabi bss 268 -armv7a-none-eabi counted_increment 40 +armv7a-none-eabi counted_increment 76 armv7a-none-eabi counted_take 28 armv7a-none-eabi data 0 armv7a-none-eabi two_calls 220 armv7r-none-eabi bss 268 -armv7r-none-eabi counted_increment 40 +armv7r-none-eabi counted_increment 76 armv7r-none-eabi counted_take 28 armv7r-none-eabi data 0 armv7r-none-eabi two_calls 220 riscv32imac-unknown-none-elf bss 268 -riscv32imac-unknown-none-elf counted_increment 18 +riscv32imac-unknown-none-elf counted_increment 34 riscv32imac-unknown-none-elf counted_take 8 riscv32imac-unknown-none-elf data 0 riscv32imac-unknown-none-elf two_calls 152 thumbv6m-none-eabi bss 268 -thumbv6m-none-eabi counted_increment 30 +thumbv6m-none-eabi counted_increment 46 thumbv6m-none-eabi counted_take 24 thumbv6m-none-eabi data 0 thumbv6m-none-eabi two_calls 156 thumbv7em-none-eabi bss 268 -thumbv7em-none-eabi counted_increment 28 +thumbv7em-none-eabi counted_increment 54 thumbv7em-none-eabi counted_take 22 thumbv7em-none-eabi data 0 thumbv7em-none-eabi two_calls 180 thumbv7m-none-eabi bss 268 -thumbv7m-none-eabi counted_increment 28 +thumbv7m-none-eabi counted_increment 54 thumbv7m-none-eabi counted_take 22 thumbv7m-none-eabi data 0 thumbv7m-none-eabi two_calls 180 thumbv8m.base-none-eabi bss 268 -thumbv8m.base-none-eabi counted_increment 28 +thumbv8m.base-none-eabi counted_increment 56 thumbv8m.base-none-eabi counted_take 22 thumbv8m.base-none-eabi data 0 thumbv8m.base-none-eabi two_calls 156 thumbv8m.main-none-eabi bss 268 -thumbv8m.main-none-eabi counted_increment 28 +thumbv8m.main-none-eabi counted_increment 54 thumbv8m.main-none-eabi counted_take 22 thumbv8m.main-none-eabi data 0 thumbv8m.main-none-eabi two_calls 152 diff --git a/src/counted_signal.rs b/src/counted_signal.rs index 006a298..0860685 100644 --- a/src/counted_signal.rs +++ b/src/counted_signal.rs @@ -2,15 +2,16 @@ //! //! [`CountedSignal`] is an exploratory SPSC primitive for events whose //! multiplicity matters but whose payload and ordering do not. Its producer -//! performs at most one load and one read-modify-write per increment; its -//! consumer atomically takes the accumulated count. +//! commits each increment with a sole-producer–bounded path; its consumer +//! atomically takes the accumulated count. //! -//! The single-producer handle is load-bearing. A producer first observes that -//! the counter is below [`u32::MAX`] and then increments it with `fetch_add`. -//! Between those operations the sole consumer may reset the counter to zero, -//! but no other operation can increase it. Consequently `fetch_add` cannot -//! wrap: it either advances the value observed by the producer or advances a -//! newly reset epoch. Multiple producers would invalidate that proof. +//! The single-producer handle is load-bearing. Below [`u32::MAX`] the producer +//! uses a Relaxed `fetch_add`, which cannot wrap because only the consumer may +//! write between the load and the RMW and it can only reset to zero. An +//! observed `u32::MAX` is treated as maybe-stale: a successful `MAX → MAX` CAS +//! confirms true saturation (no-op), while failure means a completed take +//! reset the counter and the producer `fetch_add`s into the new epoch. That is +//! at most one contention retry. Multiple producers would invalidate the proof. //! //! The counter carries no payload-publication semantics. These Relaxed //! operations order the count itself, but do not publish unrelated application @@ -116,8 +117,9 @@ impl core::fmt::Debug for CountedSignal { /// The sole incrementing handle for a [`CountedSignal`]. /// -/// This handle is `Send + !Sync`. Its exclusivity is what makes exact, -/// bounded saturation possible without a compare-exchange loop. +/// This handle is `Send + !Sync`. Its exclusivity is what keeps exact +/// saturation wrap-free with at most one contention retry on the sentinel +/// path from the consumer reset. /// /// The load-bearing `!Sync` property is pinned at compile time: /// @@ -135,18 +137,31 @@ pub struct Producer<'a> { impl Producer<'_> { /// Record one occurrence. /// - /// This operation is bounded to one atomic load and, unless the counter - /// was already saturated, one atomic `fetch_add`. It never loops and the - /// counter never wraps. + /// Below `u32::MAX` this is one Relaxed load and one Relaxed `fetch_add`. + /// Observed `u32::MAX` is confirmed with a `MAX → MAX` CAS (saturated + /// no-op) or, on failure, one `fetch_add` into the post-take epoch. Under + /// sole-producer ownership the consumer's `swap(0)` is the only competing + /// write, so there is at most one contention retry and the counter never + /// wraps. #[inline] pub fn increment(&self) { // Only this handle may increase `count`; the consumer can only reset it - // to zero. If this load is below MAX, the later fetch_add therefore - // observes either a value no greater than this one or a post-take - // value. In both cases adding one cannot wrap. - if self.signal.count.load(Ordering::Relaxed) != u32::MAX { - self.signal.count.fetch_add(1, Ordering::Relaxed); + // to zero. A plain skip on MAX can be stale after take returns, so the + // sentinel path must CAS to distinguish true saturation from a reset + // epoch that still needs this occurrence (contract T3 / A1). + let observed = self.signal.count.load(Ordering::Relaxed); + if observed == u32::MAX + && self + .signal + .count + .compare_exchange(u32::MAX, u32::MAX, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + return; } + // Wrap-free under H1: intervening writes can only lower the value. + // Reached for every non-MAX observe, and for a stale MAX after take. + self.signal.count.fetch_add(1, Ordering::Relaxed); } } diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 8b894a6..0156905 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -16,6 +16,7 @@ //! [Loom]: https://github.com/tokio-rs/loom use crate::{CountedSignal, EventBuf, SeqRing}; +use loom::sync::atomic::{AtomicU32, Ordering}; use loom::sync::Arc; use loom::thread; @@ -46,9 +47,9 @@ fn counted_signal_take_partitions_increments() { }); } -/// At the saturation boundary, a take between the producer's load and -/// `fetch_add` moves the increment into the new epoch; it cannot make the RMW -/// wrap or lose the increment (contract I2-I3, T2-T3, and A2-A3). +/// At the saturation boundary, a take between the producer's observe and +/// commit moves the increment into the new epoch; it cannot make the RMW wrap +/// or lose the increment (contract I2-I3, T2-T3, and A2-A3). #[test] fn counted_signal_saturation_boundary_is_linearizable() { loom::model(|| { @@ -73,6 +74,43 @@ fn counted_signal_saturation_boundary_is_linearizable() { }); } +/// Seeded at `MAX`, a take that has already returned must not let a later +/// `increment` disappear: wall-clock order is established only by a Relaxed +/// gate (no Acquire/Release on the count). A stale MAX short-circuit would +/// lose the occurrence from both intervals (contract T3 and A1). +#[test] +fn counted_signal_post_take_increment_observes_reset_epoch() { + loom::model(|| { + let signal = Arc::new(CountedSignal::with_count_for_model(u32::MAX)); + let gate = Arc::new(AtomicU32::new(0)); + + let producer_signal = Arc::clone(&signal); + let producer_gate = Arc::clone(&gate); + let producer = thread::spawn(move || { + let producer = producer_signal.try_producer().unwrap(); + while producer_gate.load(Ordering::Relaxed) == 0 { + thread::yield_now(); + } + producer.increment(); + }); + + let consumer_signal = Arc::clone(&signal); + let consumer_gate = Arc::clone(&gate); + let consumer = thread::spawn(move || { + let consumer = consumer_signal.try_consumer().unwrap(); + let early = consumer.take_count().count(); + consumer_gate.store(1, Ordering::Relaxed); + early + }); + + producer.join().unwrap(); + let early = consumer.join().unwrap(); + let final_count = signal.try_consumer().unwrap().take_count().count(); + assert_eq!(early, u32::MAX); + assert_eq!(final_count, 1); + }); +} + /// Every item the producer pushes is popped exactly once, in order, with no /// duplicates and no losses — under every interleaving. /// From 329a53ad1d5a9064e28b42a86b2cac4e38177669 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:20:03 -0400 Subject: [PATCH 28/87] Confirm CountedSignal Cortex-M3 cycle rows remain 8/7 after the sentinel-CAS fix. Co-authored-by: Cursor --- AGENTS.md | 2 +- CHANGELOG.md | 7 +++++-- docs/proposals/counted-signal-contract.md | 2 +- docs/proposals/counted-signal.md | 11 ++++++----- docs/records/counted-signal.md | 11 ++++++----- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 558a3b7..e5fda35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -698,7 +698,7 @@ Cross-environment diffs of ±1 are noise; compare inside the image. | `SeqRing::latest_value` | — | 30 | | | `RingBuf::push` | 20 | **20** (overwriting) | | | `RingBuf::get` / `latest` | 22 / 16 | | | -| `CountedSignal::increment` / `take_count` | 8 / 7 (pre-CAS; re-measure) | | | +| `CountedSignal::increment` / `take_count` | 8 / 7 | | | Three results carry the argument: diff --git a/CHANGELOG.md b/CHANGELOG.md index 454b014..bc012c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ All notable changes to this project will be documented in this file. longer lose the occurrence under Relaxed observation (contract T3/A1). Loom litmus: `counted_signal_post_take_increment_observes_reset_epoch`. ### Documentation -- CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned Cortex-M3 rows (re-measure after CAS), and the H closure record. +- CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned Cortex-M3 rows (confirmed 8 / 7 post-CAS), and the H closure record. +- CountedSignal Cortex-M3 cycle rows re-confirmed at 8 / 7 after the sentinel-CAS + fix (`./scripts/verify.sh cycles`, QEMU 10.0.11); pending-remeasure markers cleared. - CountedSignal contract B1: at most one contention retry from the sole consumer reset; proposal §3.1 linearization claim corrected; README Sink/Source claim qualified to payload-buffer handles. @@ -22,7 +24,8 @@ All notable changes to this project will be documented in this file. interleaving, and the post-take stale-`MAX` litmus; its frozen contract maps citable clauses to unit, threaded, Loom, Miri, code-size, and QEMU evidence. Exact bounded saturation depends on retaining a sole `Send + !Sync` producer - handle. Cortex-M3 instruction counts require re-measure after the CAS path. + handle. Cortex-M3 instruction counts remain 8 / 7 after the CAS path + (`./scripts/verify.sh cycles`). ### Removed - **Breaking:** the panicking `SeqRing::{producer, consumer}` and `EventBuf::{producer, consumer}`, deprecated since 0.2.0 with removal scheduled for 0.3.0. diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index 9cb349c..13ae5da 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -136,7 +136,7 @@ a separate maintainer decision on issue #26. | I2–I3, T2, A2–A3 | `saturates_instead_of_wrapping`; Loom's `counted_signal_saturation_boundary_is_linearizable` model | | T1–T3, A1 | Loom's `counted_signal_take_partitions_increments` model; threaded take stress | | T3, A1 (post-take / stale MAX) | Loom's `counted_signal_post_take_increment_observes_reset_epoch` model (seeded at `MAX`; Relaxed gate only) | -| B1–B2 | Source review (≤1 contention retry under H1); eight-target gated code-size rows blessed post-fix (46–76 B `increment`); Cortex-M3 cycles pending re-measure after sentinel CAS | +| B1–B2 | Source review (≤1 contention retry under H1); eight-target gated code-size rows blessed post-fix (46–76 B `increment`); Cortex-M3 cycles re-measured post-CAS at 8 / 7 (`./scripts/verify.sh cycles`, QEMU 10.0.11) | | B3 | Source review plus normal, Miri, Loom, and embedded-target executions of both hot paths | | H1, H3 | `handles_are_exclusive_and_reusable_after_drop`, including state continuation after reacquisition | | H2 | `handles_are_send`; producer and consumer compile-fail doctests pin `!Sync` | diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index bfe67e9..38df636 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -151,20 +151,21 @@ The M0/M23 rows use the existing portable-atomic single-core probe backend. Versus the pre-fix load-and-skip path this is a deliberate +16…+36 B growth on gated rows: exactness after a completed take requires the confirming CAS. -The cycle probe was previously measured in the pinned reference environment via -`./scripts/verify.sh cycles` for the load-and-skip path: +Re-measured in the pinned reference environment via `./scripts/verify.sh cycles` +after the sentinel-CAS fix. The probe brackets the common below-`MAX` path +(`load` + `fetch_add`) and a `swap(0)` take — the confirming CAS only runs when +the producer observes `MAX`, so these rows stay the hot-path cost: | Cortex-M3 hot path | Retired guest instructions | |---|---:| -| `increment` | 8 (pre-fix; re-measure after sentinel CAS) | +| `increment` | 8 | | `take_count` | 7 | Environment stamp: rustc 1.92.0 (`ded5c06cf`), LLVM 21.1.3, QEMU 10.0.11 (Debian trixie), release `opt-level = "z"` with LTO. The runner subtracts its marker overhead and uses one guest instruction per translation block with `-icount shift=0`; these are deterministic instruction/tick counts for that -environment, not a universal microarchitectural cycle claim. Re-run cycles in -the reference image before treating the 8/7 cells as current. +environment, not a universal microarchitectural cycle claim. ## 3.2 Shared handle decision diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index 24afb2d..aee8831 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -7,7 +7,7 @@ - **Normative sources:** [contract](../proposals/counted-signal-contract.md) (clause IDs cited below) · [proposal](../proposals/counted-signal.md) · measurements inline in proposal §3.1 (eight-target code size; pinned - Cortex-M3 cycles via `./scripts/verify.sh cycles` — re-measure after CAS). + Cortex-M3 cycles via `./scripts/verify.sh cycles`, confirmed 8 / 7 post-CAS). ## 1. Value statement @@ -72,7 +72,7 @@ IDs in parentheses; the clauses are the normative statements. | Saturates at `u32::MAX`, never wraps; sticky until take (I2–I3, T2, A2–A3) | `saturates_instead_of_wrapping`; Loom `counted_signal_saturation_boundary_is_linearizable` | Proven | | Concurrent increment belongs to exactly one take interval (T1–T3, A1) | Loom `counted_signal_take_partitions_increments`; threaded take stress | Proven | | Post-take increment after saturated take is not dropped by a stale MAX observe (T3, A1) | Loom `counted_signal_post_take_increment_observes_reset_epoch` (Relaxed gate only) | Proven | -| Wait-free bounded hot paths: load + `fetch_add`, sentinel CAS + ≤1 follow-up `fetch_add`; `swap(0)` take (B1–B2) | Source review; eight-target code size blessed after intentional +16…+36 B growth; Cortex-M3 cycles pending re-measure (was 8 / 7 pre-fix) | Measured (cycles pending) | +| Wait-free bounded hot paths: load + `fetch_add`, sentinel CAS + ≤1 follow-up `fetch_add`; `swap(0)` take (B1–B2) | Source review; eight-target code size blessed after intentional +16…+36 B growth; Cortex-M3 cycles re-measured post-CAS at 8 / 7 (`./scripts/verify.sh cycles`) | Measured | | No panic reachable from hot paths (B3) | Source review; normal, Miri, Loom, and embedded-target executions | Proven | | Sole-role exclusive handles; reacquisition continues state (H1, H3) | `handles_are_exclusive_and_reusable_after_drop` | Proven | | Handles are `Send + !Sync` (H2) | `handles_are_send`; compile-fail doctests pin `!Sync` on both roles | Pinned | @@ -83,7 +83,8 @@ Full CI for the lane (`0a22ada` admission package; `f26d4c3` H finalization): complete pinned `./scripts/verify.sh` matrix with zero skips — unit tests, 11 doctests, 5 compile-fail, coverage, Miri host and proxy targets, Loom models, eight-target code size, embedded -checks, QEMU cycles. Re-run after the MAX short-circuit fix. +checks, QEMU cycles. Cycles alone re-confirmed post-fix at 8 / 7; +full matrix not re-run for this cycles-doc follow-up. ## 4. The record @@ -131,7 +132,7 @@ Contract clause IDs (I/T/A/B/H/X) are load-bearing for tests and models **Where the numbers live:** proposal §3.1 (eight-target `increment` / `take_count` code-size table; pinned Cortex-M3 instruction regions under -rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11 — re-measure after -CAS); contract §9 evidence map; tracking: issue #30, PR #33; completion +rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11 — confirmed 8 / 7 +post-CAS); contract §9 evidence map; tracking: issue #30, PR #33; completion commits `0a22ada` (clause-numbered contract + pinned costs) and `f26d4c3` (H closed, promotion finalized). From a629eb6f3dfa74420dca60479d696b93ae130f85 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:27:41 -0400 Subject: [PATCH 29/87] Address the candidate-review findings on the acceptance package Three arbitrated findings, all confirmed against the tree: - README's common-traits claim was absolute ("all producers implement Sink") and EventFlags falsified it by design. The sentence is narrowed to payload-buffer handles with the EventFlags exclusion stated inline. - Both handle !Sync compile_fail doctests now pin E0277, matching the crate convention that a bare compile_fail also passes on a typo; both verified failing with the pinned code. - The atomic-window gate's Xtensa branch detector matched only ARM-shaped b* mnemonics; a retry loop emitted as j/jx or a zero-overhead loop* would have passed while the masked-window counts still read 5/5. The ESP32-S2 check now rejects b*, j/jx, and loop*; verified against synthetic disassembly (5/5 banned forms caught, 0/5 false positives incl. s32c1i and msr). The thumbv6m gate is unchanged by construction - ARM emits none of the added tokens. Co-Authored-By: Claude Fable 5 --- README.md | 6 ++++-- scripts/event-flags-atomic-window.sh | 4 ++-- src/event_flags.rs | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4ffea04..130eb83 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,10 @@ a rejecting downstream sink could silently lose the mask. ### Common Traits -All producers implement `Sink` and all consumers implement `Source`, -so you can write generic code that works with any combination: +All payload-buffer producers implement `Sink` and their consumers +implement `Source`, so generic code works with any combination of the +stream types (`EventFlags` is condition signalling, not a payload stream — +its handles deliberately implement neither; see its section above): ```rust use ph_eventing::{SeqRing, EventBuf}; diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh index 72ab294..ad7516d 100755 --- a/scripts/event-flags-atomic-window.sh +++ b/scripts/event-flags-atomic-window.sh @@ -133,7 +133,7 @@ fi # Both portable paths must remain straight-line. Any branch in a hot function # means the claimed single critical section has acquired a retry/dispatch path. if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/thumbv6m.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]]b[a-z]*[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|jx?)[[:space:]]'; then printf 'error: thumbv6m EventFlags hot path contains a branch.\n' >&2 exit 1 fi @@ -157,7 +157,7 @@ if [ "$want_esp" -eq 1 ]; then exit 1 fi if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/esp32s2.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]]b[a-z]*[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|jx?|loop[a-z]*)[[:space:]]'; then printf 'error: ESP32-S2 EventFlags hot path contains a branch.\n' >&2 exit 1 fi diff --git a/src/event_flags.rs b/src/event_flags.rs index b657426..79a0db1 100644 --- a/src/event_flags.rs +++ b/src/event_flags.rs @@ -205,7 +205,7 @@ impl core::fmt::Debug for EventFlags { /// This handle is `Send + !Sync`: it may move into an ISR or another execution /// context, but it may not be shared between contexts. /// -/// ```compile_fail +/// ```compile_fail,E0277 /// use ph_eventing::event_flags::Producer; /// /// fn assert_sync() {} @@ -245,7 +245,7 @@ impl core::fmt::Debug for Producer<'_> { /// This handle is `Send + !Sync` and may take pending conditions while its /// paired producer raises them from another context. /// -/// ```compile_fail +/// ```compile_fail,E0277 /// use ph_eventing::event_flags::Consumer; /// /// fn assert_sync() {} From 2d6f93da4f8f8be13345aff8cfb7bd7963804213 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:28:34 -0400 Subject: [PATCH 30/87] Fail LatestBuf matrices on .data and pin handle !Sync. The admission probe reported init=data without failing while the claim is an all-zero .bss image; nonempty .data. now fails both latest matrices. Also pin Producer/Consumer !Sync with compile_fail doctests (H2) and mark the proposal as implemented on this candidate branch. Co-authored-by: Cursor --- AGENTS.md | 7 ++++--- README.md | 2 +- docs/proposals/latest-buf.md | 2 +- docs/records/latest-buf.md | 3 ++- scripts/codesize.sh | 18 ++++++++++++++++-- src/latest_buf.rs | 21 +++++++++++++++++++++ 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 26c9061..cabede8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -915,10 +915,11 @@ cargo test **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -`src/latest_buf.rs`, and `src/traits.rs`. Total: 76 unit tests + 12 doctests, plus 4 `compile_fail` -doctests: the `N == 0` rejection (`E0080`) on all three ring types, and the +`src/latest_buf.rs`, and `src/traits.rs`. Total: 76 unit tests + 12 doctests, plus 6 `compile_fail` +doctests: the `N == 0` rejection (`E0080`) on all three ring types, the deliberately absent `Source` impl on `LatestBuf`'s consumer (`E0277`, -decision D2 — the pin keeps a convenience impl from arriving silently). +decision D2 — the pin keeps a convenience impl from arriving silently), and +two pins that `LatestBuf` producer/consumer handles are `!Sync` (contract H2). ## Code Conventions diff --git a/README.md b/README.md index a5ba02e..dca5a47 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 76 unit + 12 doctests + 4 compile-fail | Behaviour, including threaded stress tests for all SPSC types; `N == 0` rejected at compile time; `LatestBuf`'s absent `Source` impl pinned (D2) | +| 76 unit + 12 doctests + 6 compile-fail | Behaviour, including threaded stress tests for all SPSC types; `N == 0` rejected at compile time; `LatestBuf`'s absent `Source` impl and handle `!Sync` pinned (D2/H2) | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/docs/proposals/latest-buf.md b/docs/proposals/latest-buf.md index 4cf9a28..545d16d 100644 --- a/docs/proposals/latest-buf.md +++ b/docs/proposals/latest-buf.md @@ -3,7 +3,7 @@ - **Status:** Exploratory design proposal - **Scope:** Additive primitives and traits only - **Compatibility:** No changes to existing `Sink`, `Source`, or `Link` traits -- **Implementation status:** Not implemented +- **Implementation status:** Implemented on `candidate/latest-buf` (PR #35) - **Received:** 2026-08-11 (0.3.0 cycle; see `docs/0.3.0-candidates.md` §3) ## 1. Summary diff --git a/docs/records/latest-buf.md b/docs/records/latest-buf.md index 1ceb11b..27a8d94 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -89,9 +89,10 @@ IDs in parentheses; the clauses are the normative statements. | Reacquisition continues, never restarts (H4) | Drop-and-reacquire continuation tests; both cross-context role-handoff Loom models; detector-on Miri | Proven | | Empty poll costs one `Acquire` load, no RMW (A.1) | Loom equivalence model; measured: 7–8 instructions off empty polls, +5–6 on pending Cortex-M3 paths | Measured, selected | | No `Source` impl can arrive silently (D2, X7) | `compile_fail,E0277` doctest on `Consumer` | Pinned | +| Handles stay `Send + !Sync` (H2) | `compile_fail` doctests on `Producer` and `Consumer` | Pinned | | Cost claims per target | 11-target code-size matrix incl. ESP32-S2/S3; pinned QEMU 10.0.11 cycle regions; 66-region joint block matrix reproduced across two QEMU versions | Measured | -Full CI for the lane: 78 unit tests, 12 doctests, 4 compile-fail, 94.12% +Full CI for the lane: 78 unit tests, 12 doctests, 6 compile-fail, 94.12% line coverage, zero skips. ## 4. The record diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 53469d6..1861215 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -151,6 +151,7 @@ fi skipped=0 failed=0 matrix_missing=0 +matrix_not_bss=0 for entry in $TARGETS; do target="$(printf '%s' "$entry" | cut -d'|' -f1)" @@ -217,8 +218,11 @@ for entry in $TARGETS; do channel_data="$(section_size "$ar" ".data.$static_name")" channel_bss="$(section_size "$ar" ".bss.$static_name")" if [ -n "$channel_data" ]; then + # Admission claim: const LatestBuf lands in .bss (all-zero image). + # .data would charge flash and a startup copy proportional to T. channel="$channel_data" init=data + matrix_not_bss=$((matrix_not_bss + 1)) else channel="$channel_bss" init=bss @@ -264,8 +268,11 @@ for entry in $TARGETS; do channel_data="$(section_size "$ar" ".data.$static_name")" channel_bss="$(section_size "$ar" ".bss.$static_name")" if [ -n "$channel_data" ]; then + # Admission claim: const LatestBuf lands in .bss (all-zero image). + # .data would charge flash and a startup copy proportional to T. channel="$channel_data" init=data + matrix_not_bss=$((matrix_not_bss + 1)) else channel="$channel_bss" init=bss @@ -309,6 +316,13 @@ if [ "$LATEST_MATRIX" = "1" ] || [ "$LATEST_BLOCK_MATRIX" = "1" ]; then "$matrix_missing" >&2 exit 1 fi + if [ "$matrix_not_bss" -gt 0 ]; then + printf '%s LatestBuf channel(s) landed in .data instead of .bss.\n' \ + "$matrix_not_bss" >&2 + printf 'Const channels must be an all-zero .bss image; .data charges flash\n' >&2 + printf 'and a startup copy proportional to the payload.\n' >&2 + exit 1 + fi [ "$failed" -gt 0 ] && exit 1 [ "$skipped" -gt 0 ] && exit 2 printf 'publish_B and take_B are emitted flash for one operation monomorph.\n' @@ -319,8 +333,8 @@ if [ "$LATEST_MATRIX" = "1" ] || [ "$LATEST_BLOCK_MATRIX" = "1" ]; then printf 'Block and builder rows use the exact shapes from #28 at bc54a9a.\n' fi printf 'channel_B is target-object RAM for three slots plus channel state.\n' - printf 'init reports whether that const-initialized image is .data or .bss;\n' - printf '.data also occupies flash and is copied during startup.\n' + printf 'init must be .bss (all-zero const image); a .data row fails the matrix\n' + printf 'because .data occupies flash and is copied during startup.\n' if [ "$LATEST_MATRIX" = "1" ]; then printf 'Run scripts/cycles.sh latest-matrix for state-dependent paths.\n' else diff --git a/src/latest_buf.rs b/src/latest_buf.rs index b485aab..9e6f3c1 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -329,6 +329,17 @@ impl core::fmt::Debug for LatestBuf { } /// Unique, stateless write handle for a [`LatestBuf`]. +/// +/// This handle is `Send + !Sync`: it may move into an ISR or another execution +/// context, but it may not be shared between contexts. Sole-producer ownership +/// is load-bearing for the exclusive-slot soundness argument (contract H2). +/// +/// ```compile_fail +/// use ph_eventing::latest_buf::Producer; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` pub struct Producer<'a, T: Copy> { buf: &'a LatestBuf, _not_sync: PhantomData>, @@ -392,6 +403,16 @@ impl core::fmt::Debug for Producer<'_, T> { /// Unique, stateless read handle for a [`LatestBuf`]. /// +/// This handle is `Send + !Sync`: it may move into a consumer context, but it +/// may not be shared between contexts (contract H2). +/// +/// ```compile_fail +/// use ph_eventing::latest_buf::Consumer; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` +/// /// # No `Source` implementation — by decision, not omission /// `Source::try_pop` cannot report the displacement that is this channel's /// designed overload behaviour, so a generic pipeline would silently From 8ffe532e25f1eb4f8ec84b54ecef1cb9739f34f7 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:33:42 -0400 Subject: [PATCH 31/87] Pin the handle !Sync compile_fail doctests to E0277 Arbiter follow-through on the same convention the #36 round enforced: a bare compile_fail also passes on a typo, and this lane's own D2 Source pin already uses the pinned form. Both new handle !Sync doctests now assert the specific Sync-bound failure. Both verified failing with the pinned code; 6 compile-fail doctests green. Co-Authored-By: Claude Fable 5 --- src/latest_buf.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/latest_buf.rs b/src/latest_buf.rs index 9e6f3c1..0045e3b 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -334,7 +334,7 @@ impl core::fmt::Debug for LatestBuf { /// context, but it may not be shared between contexts. Sole-producer ownership /// is load-bearing for the exclusive-slot soundness argument (contract H2). /// -/// ```compile_fail +/// ```compile_fail,E0277 /// use ph_eventing::latest_buf::Producer; /// /// fn assert_sync() {} @@ -406,7 +406,7 @@ impl core::fmt::Debug for Producer<'_, T> { /// This handle is `Send + !Sync`: it may move into a consumer context, but it /// may not be shared between contexts (contract H2). /// -/// ```compile_fail +/// ```compile_fail,E0277 /// use ph_eventing::latest_buf::Consumer; /// /// fn assert_sync() {} From 203028d2262eb98d6f6a63820333dd11770100c0 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:44:46 -0400 Subject: [PATCH 32/87] Reconcile the P closure across every surface and pin must_use Arbiter close of the round-1 findings, both confirmed: - The proposal section 6.1, the measurements "Result for decisions P and S", the section 7 Copy-vs-SlotPool row, and the earlier changelog bullet still described P as open on the same tip whose header and section 9 record it closed - a package that read as both ready-for-acceptance and awaiting-budget. All four now read historical-then-closed: the measurement deliberately closed nothing, and the reading that followed closed P as Copy composition with S deferred behind the adopter-gated trigger. - Block and FillError now carry type-level must_use with reasoned messages: a completed block is the publishable window, and the rejected sample rides in the error - neither should vanish in a silent drop. Matches the PollStats precedent; no existing code trips the lint (clippy clean, 76/12/4 green). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- docs/proposals/block-buf-measurements.md | 16 ++++++++++------ docs/proposals/block-buf.md | 17 ++++++++++------- src/block.rs | 2 ++ 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da32110..590034c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to this project will be documented in this file. shipped orderings. ### Documentation -- BlockBuf engineering record (`docs/records/block-buf.md`) — Track 1 acceptance package: composition identity under closed D3, measured publication costs (`bc54a9a`), joint composition rows, and an honest status header that **promotion still waits on decision P**. +- BlockBuf engineering record (`docs/records/block-buf.md`) — Track 1 acceptance package: composition identity under closed D3, measured publication costs (`bc54a9a`), joint composition rows. Its status header initially recorded promotion as waiting on decision P; P has since closed as Copy composition and the record reads DECISION-COMPLETE. ### Added - `Block` and `BlockBuilder` provide complete, contiguous sample windows without introducing another queue policy. The builder rejects gaps diff --git a/docs/proposals/block-buf-measurements.md b/docs/proposals/block-buf-measurements.md index 1dbcd9c..0537077 100644 --- a/docs/proposals/block-buf-measurements.md +++ b/docs/proposals/block-buf-measurements.md @@ -85,9 +85,12 @@ ranges from 159 to 8,658 retired instructions; preserving a rejected complete block is within 5–31 instructions of the accepted path rather than being a cheap scalar error return. -The record still contains no named ISR/task instruction budget and no RAM -envelope. Therefore this measurement does **not** close decision **P** and does -not authorize either branch of **S**: +At measurement time the record contained no named ISR/task instruction +budget and no RAM envelope, so this measurement deliberately closed +nothing — it made the decision readable, and the reading followed: +**decision P closed 2026-08-11 as Copy composition, and S closed as +deferred** (planning-record P/S closure). The decision logic below is +retained as the record of how the rows were to be read: - if a selected shape's budget covers its accepted row and the extra builder-sized storage is acceptable, Copy composition remains the safe @@ -97,6 +100,7 @@ not authorize either branch of **S**: BlockBuf-over-SlotPool integration. Choosing a threshold here would substitute an arbitrary library-wide number -for the application budget the proposal explicitly requires. The maintainer's -next input is a named instruction/time budget and RAM envelope for the shapes -the cycle intends to support. +for the application budget the proposal explicitly requires. That input +arrived as the P closure's budget posture: the per-shape measured rows are +the budget statement, adopters own the arithmetic for their shape, and the +one unserved corner exits through S's registered trigger. diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index ee21b26..5b86b40 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -241,19 +241,22 @@ On the reference Cortex-M3, accepted completion plus publication ranges from accepted path because the complete rejected block is preserved and returned to the caller. Logical payload traffic ranges from 48 to 4,112 bytes per path. -This supplies the missing measurements but does not close the foundation -decision: no named ISR/task instruction budget or RAM envelope exists in the -record. Decision **P** therefore remains open, and decision **S** must continue -to wait. The next maintainer input is the budget for the supported shapes; only -then can the branch choose Copy composition or authorize the representative -BlockBuf-over-SlotPool integration. +At measurement time this supplied the missing numbers without closing the +foundation decision — no named ISR/task budget existed in the record, so +the branch deliberately chose nothing. **The maintainer input has since +arrived: decision P closed 2026-08-11 as Copy composition** (the per-shape +rows above are the budget statement; no library-wide threshold is +claimed), **and decision S closed as deferred** — the SlotPool branch is +banked behind an adopter-gated trigger (a measured budget breach at a +supported shape, or a direct-to-granted-slot requirement). See the +planning record's P/S closure and §9 below. ## 7. Deferred choices and decision evidence | Choice | Safe default | Evidence that changes it | |---|---|---| | Block or sample LatestBuf first (D3 scheduling) | Implement generic `LatestBuf`; blocks already compose | A block-first integration partner or benchmark showing the sample payload misses the relevant costs | -| Copy composition or SlotPool | Copy composition | Section 6 exceeds a named ISR/task budget or RAM envelope | +| Copy composition or SlotPool — **closed as Copy** (decision P, 2026-08-11) | Copy composition | Closed — reopening runs through decision S's adopter-gated trigger (a measured budget breach at a supported shape, or a direct-to-granted-slot requirement), from the evidence banked at `archive/slot-pool-0.3.0-evaluation` | | Automatic reset after a gap | Reject without mutation | Real integrations converge on one restart and accounting policy | | Mandatory per-sample timestamps | Caller-selected `T` | A transport-level time contract cannot be expressed in payload | | Dedicated `DropBlockBuf` | Observe and handle `Err(block)` | A distinct synchronization/accounting contract, not convenience | diff --git a/src/block.rs b/src/block.rs index 678a543..72018fe 100644 --- a/src/block.rs +++ b/src/block.rs @@ -47,6 +47,7 @@ use core::mem::MaybeUninit; /// Sequence `0` is reserved. The first and last sequence values describe the /// inclusive range represented by `samples`; wrap skips the reserved value. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use = "a completed block is the publishable window; dropping it discards N samples"] pub struct Block { first_sequence: u32, last_sequence: u32, @@ -81,6 +82,7 @@ impl Block { /// Why a sample could not be appended to a [`BlockBuilder`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use = "the rejected sample rides in this error; dropping it unseen loses the sample"] pub enum FillError { /// Sequence `0` is reserved and never identifies a sample. ReservedSequence { From 62a69620ff7b2be2bd61269ec1810d16f8432af8 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:50:28 -0400 Subject: [PATCH 33/87] Land the bound block-payload documentation obligations The D3 and P closures bound four disclosures to the block-payload public docs at promotion; src/block.rs module docs now carry all four, stated so integrators choose rather than discover: - per-shape RAM (three slots + builder, 136-8,280 B across the grid; Q blocks + builder for the queued shape); - the small-N inversion (block release loses to per-sample publication by 54%/31% at the 8/16-byte N = 8 corners, wins elsewhere measured); - publication cost scaling and the rejection-within-5-31-instructions honesty (budget rejection like acceptance); - the double-copy DMA guidance (builder as DMA target, or task-context publication; cache maintenance stays outside the crate). The proposal promotion bar and engineering record mark the obligation landed. Doc-only; rustdoc clean under -D warnings, lane green 76/12/4. Co-Authored-By: Claude Fable 5 --- docs/proposals/block-buf.md | 4 +++- docs/records/block-buf.md | 5 +++-- src/block.rs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index 5b86b40..70a78e3 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -305,4 +305,6 @@ no block-specific evidence; Loom remains required for the selected transport. obligation rides to promotion with them: the **double-copy hazard** guidance for DMA integrations — make the builder the DMA target, or publish from task context — stated in the block-payload docs, not left - for integrators to discover. + for integrators to discover. **Landed:** the `src/block.rs` module + docs now carry all four bound disclosures (per-shape RAM, small-`N` + inversion, rejection cost, double-copy DMA guidance). diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index 6b14dd5..9729474 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -80,8 +80,9 @@ section and contract IDs in parentheses; those texts are normative. - **The double-copy hazard is real for DMA integrations (P obligation).** DMA already wrote the bytes once; builder-then-publish crosses them twice unless the builder is the DMA target or publication - moves to task context. The block-payload docs owe this guidance at - promotion — integrators must not discover the second copy. + moves to task context. Landed: the `src/block.rs` module docs carry + this guidance (with the RAM, inversion, and rejection-cost + disclosures), so integrators choose rather than discover. - **Drop-new is an action, not a type (§5.1).** There is no `DropBlockBuf`; discarding a newly completed block is what the caller does with `Err(block)`. Loss after completion is reported by the diff --git a/src/block.rs b/src/block.rs index 72018fe..2350fff 100644 --- a/src/block.rs +++ b/src/block.rs @@ -15,6 +15,42 @@ //! Timestamps are payload policy: use a timestamped sample type for `T` when //! each sample needs a stamp. The transport does not impose one. //! +//! # Costs and integration (measured; bound by decisions D3 and P) +//! +//! **RAM is multiple complete blocks, always.** The latest composition +//! (`LatestBuf>`) holds three block slots plus this private +//! builder — 136–8,280 bytes of combined channel + builder RAM across the +//! measured 2/8/16-byte × `N = 8/32/128` grid. The queued composition +//! stores `Q` blocks plus the builder. The cost is fixed and in `.bss`, +//! but it is not small: state the number for your shape. +//! +//! **Small windows can invert the economics.** Continuous block release +//! beats `N` individual sample publications for every measured 2-byte row +//! and for 8/16-byte samples at `N >= 32`, but costs 54% / 31% *more* at +//! the 8/16-byte `N = 8` corners. For tiny windows, per-sample +//! publication through a plain channel may be the cheaper shape. +//! +//! **Publication cost scales with block bytes** — 159–8,658 reference +//! instructions across the measured grid — and rejection is within 5–31 +//! instructions of acceptance, because the complete rejected block is +//! preserved and returned rather than reduced to a scalar error. Budget +//! rejection like acceptance, not like error plumbing. +//! +//! **DMA integrations: mind the double copy.** A DMA engine has already +//! written the samples once; filling this builder from the DMA buffer and +//! then publishing crosses the payload a second time. Either make the +//! builder's storage the DMA target, or publish from task context where +//! the copy is off the interrupt path. (DMA cache maintenance remains +//! outside this crate, per the taxonomy's out-of-scope list.) +//! +//! **No partial block is ever visible** — sample-level freshness inside a +//! filling window is unobtainable by design, stated here so it is chosen, +//! not discovered. +//! +//! The measured rows behind these numbers live in +//! `docs/proposals/block-buf-measurements.md` and the joint composition +//! matrix; the decision record is `docs/records/block-buf.md`. +//! //! # Example //! ``` //! use ph_eventing::{BlockBuilder, EventBuf}; From 965601f77725ad7e256bc5532b155acca6625890 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 17:53:04 -0400 Subject: [PATCH 34/87] Add engineering records for SeqRing and EventBuf Owed under mechanics rule 12's when-touched clause: #25 materially touched both types (constructors removed, Loom models retargeted), so their retroactive records land this cycle. Both follow the exemplar structure and state only tree-verified claims: - seq-ring.md leads with the crate's one accepted formal deviation - the seqlock race, its rejected alternatives, and the honest bound that adopters' own Miri runs will flag it - plus the wrap extra-drop limitation, the saturating loss-accounting convention this type originated, and the 115-instruction constant-recovery measurement. - event-buf.md records the fully race-free backpressure arm: Err(val) as returned-not-handled policy, N as an application sizing decision, and its 0.3.0 role as the queued transport of the D3 composition with no new concurrency contract. RingBuf was doc-touched only this cycle and waits for its material touch; the records README and changelog say so explicitly. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++++ docs/records/README.md | 6 ++- docs/records/event-buf.md | 78 +++++++++++++++++++++++++++++++++++ docs/records/seq-ring.md | 87 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 docs/records/event-buf.md create mode 100644 docs/records/seq-ring.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f2dd62..725529a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Documentation (engineering records) +- Engineering records for the 0.2.0 types touched by the constructor removal: + [`records/seq-ring.md`](docs/records/seq-ring.md) — with the seqlock deviation, the wrap + extra-drop limitation, and the 115-instruction constant-recovery claim stated in the + briefing layer — and [`records/event-buf.md`](docs/records/event-buf.md) — the fully + race-free backpressure arm, now also the queued transport of the D3 block composition. + `RingBuf` (doc-touched only this cycle) receives its record at its next material touch. + ### Removed - **Breaking:** the panicking `SeqRing::{producer, consumer}` and `EventBuf::{producer, consumer}`, deprecated since 0.2.0 with removal scheduled for 0.3.0. diff --git a/docs/records/README.md b/docs/records/README.md index e0e1049..fd56e39 100644 --- a/docs/records/README.md +++ b/docs/records/README.md @@ -45,7 +45,9 @@ the supporting work behind it second: - Records obey the stale-claims discipline: when a decision closes, a measurement lands, or a claim changes, the record is updated in the same change that moves the canonical source. -- The 0.2.0 types (`RingBuf`, `EventBuf`, `SeqRing`) receive records - retroactively when next materially touched. +- The 0.2.0 types receive records retroactively when next materially + touched. [`seq-ring.md`](seq-ring.md) and [`event-buf.md`](event-buf.md) + landed under that rule when #25 removed their constructors; `RingBuf` + (doc-touched only this cycle) still waits for its material touch. - [`latest-buf.md`](latest-buf.md) is the exemplar for structure and depth. diff --git a/docs/records/event-buf.md b/docs/records/event-buf.md new file mode 100644 index 0000000..8b37801 --- /dev/null +++ b/docs/records/event-buf.md @@ -0,0 +1,78 @@ +# EventBuf — engineering record + +- **Status:** shipped since 0.1.x; 0.2.0 made its costs measured and gated; + 0.3.0 removed the deprecated panicking constructors (#25) and confirmed + it as the queued transport in the D3 block composition + (`EventBuf, Q>`). Record written retroactively per mechanics + rule 12 (materially touched by the 0.3.0 breaking anchor). +- **Normative sources:** the module documentation in `src/event_buf.rs`, + AGENTS.md (invariants, the backpressure proof, worked rejections), the + 0.2.0 changelog (measured claims), `scripts/codesize.sh` baseline rows. + +## 1. Value statement + +`EventBuf` is the channel for **when every event matters**: a +bounded, lock-free SPSC ring that **rejects** the newest push when full — +`push` returns `Err(val)` with the value handed back — so no event is +ever silently lost and the producer always knows, at the call site, when +delivery failed. It is a classic Lamport queue: each side owns its own +cursor, Release/Acquire pairs on the cursors are the publication fence, +and producer and consumer never touch the same slot. Unlike `SeqRing` it +is **fully race-free** — no deviation, no caveat; Miri's detector passes +it outright. It refuses to be: lossy (that is `SeqRing`), freshness-first +(that is `LatestBuf`), or an unbounded queue (nothing in this crate is). + +## 2. Risks and integration concerns + +- **Backpressure is returned, not handled.** `Err(val)` puts the policy + decision — retry, log, count, deliberately discard — at the call site, + every time. A producer that cannot afford to handle "full" belongs on + `SeqRing`, where overload is absorbed and counted instead of returned. + With `Block` payloads the returned value is the *entire completed + block* (`#[must_use]`, within 5–31 instructions of the accepted path) + — budget rejection like acceptance, not like error plumbing. +- **`N` is the backpressure threshold, chosen per application.** The + point at which `push` starts failing is a sizing decision the crate + cannot make; too small converts load spikes into rejection storms. +- **Fixed RAM, `N` slots always** — const-constructed into `.bss` (no + flash image, no startup copy, measured in the 0.2.0 268-byte/11-target + result). With block payloads that is `Q × size_of::>()` + on top of the fill-side builder — state the per-shape number. +- **Role lifecycle follows the crate doctrine:** sole-role + `Send + !Sync` handles, `try_*`-only acquisition since 0.3.0, roles + held until the handle drops, deliberately no out-of-band reset (the + LatestBuf contract's X8 states the boundary; the mechanism here is + the same). + +## 3. Technical claims and validation + +| Claim | Evidence | Status | +|---|---|---| +| Race-free — no data race on slots or cursors, no deviation | Miri with the race detector on (full pass, no accommodation); Loom models incl. the full-buffer backpressure path | Proven | +| No silent loss — every failed delivery is observable at the call site | `push` returns `Err(val)`; the type has no overwrite path; unit + threaded stress incl. lossless-and-ordered Loom model | Proven | +| Bounded operations, no CAS retry loop | Lamport single-owner cursors: one Relaxed load + one Acquire load + one Release store per side | Proven by construction; cycle rows measured in 0.2.0 | +| `len` reads a consistent cursor pair | The bracketed `tail`/`head`/`tail` sampling documented in the module docs | Pinned | +| Const-constructs into `.bss` | 0.2.0 measurement across 11 targets; codesize baseline gated in CI | Measured, gated | +| Queued block transport (D3) adds no new concurrency contract | The block layer adds no atomics; existing EventBuf clauses apply with `T = Block<…>`; publication-cost matrix at `bc54a9a` | Measured | + +## 4. The record + +- **Backpressure over overwrite is the type's identity** (AGENTS): the + crate's predictability rule demands loss be either impossible or + reported; `EventBuf` is the "impossible" branch, `SeqRing` the + "reported" branch, and the pair brackets the design space the + taxonomy's admission rule polices. +- **D3 (closed 2026-08-11):** `EventBuf, Q>` is the queued + arm of the confirmed block composition — no `QueuedBlockBuf` type + exists; existing admission, rejection, and race-freedom carry over + unchanged. Decision P's publication-cost rows (159–8,658 reference + instructions; rejection within 5–31 of acceptance) are the measured + cost basis. +- **Shared worked rejections (AGENTS):** `try_split()` rejected on the + 0.2.0 measurement (+43–59% flash on constrained cores); the panic + machinery removed with the 0.3.0 constructor removal — the 0.2.0 + code-size probe showed no panic strings reach the binary on the + `try_*` path. +- **0.3.0 changes:** panicking constructors removed (#25); Loom models + drive `try_*` directly, so the proven orderings are the shipped + orderings. diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md new file mode 100644 index 0000000..dd3b3bc --- /dev/null +++ b/docs/records/seq-ring.md @@ -0,0 +1,87 @@ +# SeqRing — engineering record + +- **Status:** shipped since 0.1.x; 0.2.0 made its costs measured and gated; + 0.3.0 removed the deprecated panicking constructors (#25). Record written + retroactively per mechanics rule 12 (the type was materially touched by + the 0.3.0 breaking anchor). +- **Normative sources:** the module documentation in `src/seq_ring.rs` + (the canonical seqlock-deviation analysis lives there), AGENTS.md + (invariants and worked rejections), the 0.2.0 changelog (measured + claims), `scripts/codesize.sh` baseline rows. + +## 1. Value statement + +`SeqRing` is the **ordered recent window**: a lock-free SPSC +overwrite ring for high-rate telemetry where the producer must never +block and stale data is droppable — but unlike a latest-value snapshot, +the consumer drains *in order* (`poll_one`/`poll_up_to`) or samples the +newest (`latest`), and every loss is counted. A consumer that falls more +than `N` behind skips ahead and is told exactly how many items it lost +(`PollStats::dropped`; the saturating `dropped_accum` is the origin of +the crate's observable-loss convention). It refuses to be: a delivery +guarantee (`EventBuf` is), a race-free primitive (see §2 — this is the +crate's one deliberate formal-soundness trade), or a latest-value-only +channel (`LatestBuf` is, with a race-free ownership argument). + +## 2. Risks and integration concerns + +- **The seqlock deviation — the crate's one accepted formal data race.** + The consumer may copy a slot the producer is overwriting; the sequence + re-check discards the racy copy as raw `MaybeUninit` bytes. Miri's + race detector is *right* to flag this: `read_volatile` constrains the + compiler but is not atomic. The module docs carry the full analysis, + including the honest bounds: nothing is known to miscompile (the Linux + kernel's `seqlock_t` is the same construct), but "no known failure" is + not a guarantee, and **your own Miri runs over consumer-under-writer + tests will report UB** — the crate's own Miri matrix runs a dedicated + seqlock pass for exactly this reason. Generality (`T: Copy`) was + chosen over formal soundness deliberately; a word-payload-restricted + ring would be fully race-free and remains referential (C1). If your + certification regime cannot accept a documented deviation, use + `EventBuf` (fully race-free) or `LatestBuf` (race-free by ownership). +- **Extra drops at the sequence wrap — known limitation.** Around the + `u32` sequence wrap the skip-ahead can drop a few more entries than + the lag alone requires (bounded, `N`-dependent, documented in the + module docs). Realignment was analysed and left referential (C2, in + the planning record) — reopen it with a real adopter, not by default. +- **Loss is designed behaviour.** Overwrite is the overload policy; the + counters report it, nothing prevents it. Consumers that must see every + event belong on `EventBuf`. +- **Role lifecycle follows the crate doctrine:** sole-role + `Send + !Sync` handles, `try_*`-only acquisition (the panicking + constructors are gone in 0.3.0), roles held until the handle drops, + deliberately no out-of-band reset (the LatestBuf contract's X8 states + the boundary; the mechanism here is the same). + +## 3. Technical claims and validation + +| Claim | Evidence | Status | +|---|---|---| +| No torn value ever materialises as `T` (racy copies discarded before use) | Volatile access + `MaybeUninit` holding + re-check discipline; Loom models; the dedicated seqlock Miri pass | Proven within the documented deviation | +| Fence placement is necessary (Release before value write; Acquire before re-check) | Module-doc argument; Loom; ordering discipline enforced by AGENTS' rerun-after-atomic-change rule | Proven | +| Recovery cost is constant w.r.t. lag | 0.2.0 measurement: a consumer 2,000 sequences behind recovers in the same 115 instructions as one 16 behind | Measured | +| Loss accounting is exact and saturating (`read + dropped` accounts for every sequence; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned | +| Const-constructs into `.bss` — no flash image, no startup copy | 0.2.0 measurement: 268 bytes, zero flash, across 11 targets / 4 ISA families; codesize baseline gated in CI | Measured, gated | + +## 4. The record + +- **The seqlock trade (module docs, §"Known deviation"):** three + alternatives rejected on the record — blocking the producer (removes + the reason the type exists), per-word atomic copies (needs unstable + `generic_const_exprs`; per-byte atomics hit padding UB), and + word-restricted payloads (fully sound, passed over for generality; + kept referential as C1). +- **`pop` + `Source` worked rejection (AGENTS):** O(1) and bounded, but + overwrite would lose data `forward` structurally cannot report — + unreportable loss is unpredictable behaviour. The same reasoning later + shaped LatestBuf's D2. +- **`try_split()` rejected on measurement (AGENTS):** better ergonomics, + +43–59% flash on the constrained cores least able to pay — invisible + on Cortex-M4, which is why one-target evidence is banned (mechanics + rule 9's scar). +- **0.3.0 changes:** panicking constructors removed (#25); the Loom + models now drive the `try_*` path directly, so the proven orderings + are the shipped orderings. +- **Relationships:** `LatestBuf` covers race-free latest-value with + general payloads; `SeqRing` remains the ordered-recent-window; the + taxonomy's C1/C2 entries hold its two reopening paths. From 6d8a9c9cbcae5df0f84c3a84f4591bf708b4e144 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:02:06 -0400 Subject: [PATCH 35/87] Address the round-2 Codex findings on the acceptance package All three confirmed: - The block-matrix mode measured rows and threw them away, so the promotion-directed bless physically could not create baseline rows. Block rows now persist to RESULTS and fall through to the existing bless/compare machinery against their own baseline-block.tsv - a bless from a block-matrix run must never rewrite the default baseline, which would silently drop every default row. Regenerate hints are parameterized per mode. - The README block section presented the composition without its budgeting hazards; it now carries the compact cost disclosure (publication scaling, rejection-cost honesty, RAM, the small-N inversion, the double-copy DMA guidance) pointing at the module docs for the full measured detail. - The engineering record's last stale P/S evidence row now reads closed; one actionable promotion state remains. Co-Authored-By: Claude Fable 5 --- README.md | 11 +++++++++++ docs/records/block-buf.md | 2 +- scripts/codesize.sh | 28 +++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 912a09b..ead3f45 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,17 @@ you need: `EventBuf, Q>` queues complete blocks and rejects the newest when full; the proposed `LatestBuf>` will retain only the latest complete block. +**Budget the composition before choosing it.** Publication copies the +complete block, so cost scales with block bytes (159–8,658 reference +instructions across the measured 2/8/16-byte × N = 8/32/128 grid), a +rejected push costs nearly as much as an accepted one (the complete block +is preserved and returned, within 5–31 instructions), and RAM is multiple +complete blocks — `Q` slots plus the private builder. Small windows can +invert the economics (per-sample publication beats blocks at the +8/16-byte `N = 8` corners), and DMA integrations must avoid the double +copy: make the builder the DMA target, or publish from task context. The +`block` module docs carry the full measured disclosure. + ```rust use ph_eventing::{BlockBuilder, EventBuf}; diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index 9729474..2a549be 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -101,7 +101,7 @@ section and contract IDs in parentheses; those texts are normative. | Flash cost bounded across gated targets | 8-target codesize matrix: 110–312 B per completion-plus-publication shape | Measured | | Joint D3 composition cost vs per-sample (D3) | Matrix `8593b4a`: 171–6,958 instructions; 136–8,280 B combined channel+builder RAM; small-N inversion documented | Measured | | No block-layer atomics; transport evidence unchanged (§8) | Block path: unit + Miri; LatestBuf/EventBuf: detector-on Miri and Loom on selected transport | Proven (transport) | -| Copy vs SlotPool foundation (P / S) | Matrix complete; named budget/envelope absent; maintainer deferred holistic reading | **Open — blocks promotion** | +| Copy vs SlotPool foundation (P / S) | Matrix complete (`bc54a9a`); read against the #26 integration scoping; per-shape rows adopted as the budget statement | **Closed — P = Copy composition, S = deferred (2026-08-11); nothing blocks promotion but the #34 acceptance review** | Full CI for the lane: 78 unit tests, 12 doctests, 4 compile-fail, 8 gated codesize targets, 3 embedded checks, 6 Miri passes, 5 Loom diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 10e6cae..6d4cf32 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -63,6 +63,15 @@ for arg in "$@"; do done BASELINE="scripts/codesize/baseline.tsv" +REGEN="./scripts/codesize.sh --bless" +# Block-matrix rows gate against their own baseline file: a bless from a +# block-matrix run writes only block rows, and letting that touch the +# default baseline would silently drop every default row -- the exact +# partial-bless hazard the refusal below exists to prevent. +if [ "$BLOCK_MATRIX" = "1" ]; then + BASELINE="scripts/codesize/baseline-block.tsv" + REGEN="./scripts/codesize.sh block-matrix --bless" +fi # Growth beyond this many bytes on any row fails the gate. Absolute, not a # percentage: at 100-200 bytes a percentage is noise, and the regression this # exists to catch was +60 on Cortex-M0+ and +78 on ESP32-S2. Shrinkage never @@ -191,6 +200,13 @@ for entry in $TARGETS; do printf '%-30s %-10s %8s %8s %10s %10s\n' \ "$target" "$shape" "${code:--}" "$block_bytes" \ "$accepted_bytes" "$rejected_bytes" + # Persist gated rows so the baseline machinery below can bless + # and compare them; Xtensa stays ungated as in the default mode. + case "$target" in + xtensa-*) ;; + *) [ -n "$code" ] && printf '%s\tblock_%s\t%s\n' \ + "$target" "$shape" "$code" >> "$RESULTS" ;; + esac done continue fi @@ -231,7 +247,9 @@ if [ "$BLOCK_MATRIX" = "1" ]; then printf 'returning the complete rejected block to the caller.\n' printf 'These are logical payload-traffic bounds; code_B is emitted flash.\n' printf 'Run scripts/cycles.sh for accepted/rejected instruction paths.\n' - exit 0 + printf '\n' + # Fall through to the baseline gate: block rows bless into and compare + # against baseline-block.tsv, so post-promotion regressions are gated. fi # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -255,8 +273,8 @@ if [ "$BLESS" = "1" ]; then exit 1 fi { - printf '# ph-eventing code-size baseline. Regenerate: ./scripts/codesize.sh --bless -' + printf '# ph-eventing code-size baseline. Regenerate: %s +' "$REGEN" printf '# rustc-commit: %s ' "$RUSTC_ID" printf '# @@ -291,8 +309,8 @@ if [ ! -f "$BASELINE" ]; then printf ' SKIP baseline gate: %s does not exist yet. ' "$BASELINE" - printf 'Create it with: ./scripts/codesize.sh --bless -' + printf 'Create it with: %s +' "$REGEN" exit 2 fi From 060ea7c0e7f1306bff1f5929a457940a56276284 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:03:20 -0400 Subject: [PATCH 36/87] Correct README: the panicking constructors were removed in 0.3.0 Round-2 Codex finding, confirmed: the checkout contains only try_* acquisition, and the resync had kept the lane-side pre-removal wording. Doc-only. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 509057a..a689aca 100644 --- a/README.md +++ b/README.md @@ -251,8 +251,8 @@ them is a runtime step and always will be. - `SeqRing`, `EventBuf`, and `CountedSignal` are SPSC by design: exactly one producer and one consumer may be active. Use `try_producer()`/`try_consumer()`, which return `None` rather than panicking — on a microcontroller a panic is a reset, and the panic machinery costs flash you may not have. - The panicking `producer()`/`consumer()` are **deprecated since 0.2.0** and will be removed in - 0.3.0. Using unsafe to bypass `SeqRing`/`EventBuf` ownership can be undefined + The panicking `producer()`/`consumer()`, deprecated since 0.2.0, **were removed in + 0.3.0**. Using unsafe to bypass `SeqRing`/`EventBuf` ownership can be undefined behavior. Forging or concurrently sharing a `CountedSignal` producer breaks its bounded no-wrap contract; the handle is `!Sync` to prevent that in safe Rust. - `T: Copy` is required by all payload-carrying types to avoid allocation and return values by copy. From fa84c2cd7930a9c523073f110c3dca244af92c8e Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:03:22 -0400 Subject: [PATCH 37/87] Mirror the X6 silent-zero disclosure in the README Round-2 Codex finding, confirmed: calling the beyond-span count an approximation omitted the one behaviour a crates.io reader must not misread - an exact whole-cycle gap reports skipped = 0, and zero there is not evidence nothing was lost. The README now carries the silent-zero warning, the rate x take-interval reachability, and both escape hatches, pointing at LatestItem::skipped for the full disclosure. Doc-only. Co-Authored-By: Claude Fable 5 --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dca5a47..daab7df 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,14 @@ assert_eq!(consumer.poll_one_value(), Some((1, 123))); A three-slot SPSC snapshot channel for state where freshness dominates FIFO delivery. Publishing never rejects; it reports whether an unread value was replaced. Taking returns the newest complete value with generation and skipped -counts. Exact skipped counts are guaranteed within one non-zero `u32` wrap; -beyond a full cycle the wrapped `u32` count is only an approximation. The +counts. Exact skipped counts are guaranteed within one non-zero `u32` wrap +span; beyond it the count **under-counts, and a gap of exactly one or more +whole cycles reports `skipped = 0`** — silence there is not evidence that +nothing was lost. The boundary is a rate × take-interval property (~49.7 +days between takes at 1 kHz publishing, ~72 minutes at 1 MHz); if the +count itself is your requirement, carry a wider producer-assigned sequence +in `T`, and if consumer liveness is, use a watchdog — the +`LatestItem::skipped` docs carry the full disclosure. The consumer intentionally implements `LatestSource`, not `Source`, so gap evidence is not silently discarded. `T` may be one sample or a complete block. Empty polls use an Acquire load rather than an atomic RMW; pending polls transfer From 2bbae0fcf475a399f829e665f5d18e501b92b66b Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:03:24 -0400 Subject: [PATCH 38/87] Widen the thumbv6m branch detector to CBZ/CBNZ Round-2 Codex finding, arbitrated as partly disproven, fix applied anyway: ARMv6-M has no CBZ/CBNZ encodings (they arrive with ARMv7-M / v8-M baseline), so rustc targeting thumbv6m cannot emit them and the claimed gap is unreachable on the gated target. The widening costs nothing, guards any future extension of the gate to thumbv7m-class targets, and is regex-verified: cbz/cbnz caught, cpsid/msr untouched. Co-Authored-By: Claude Fable 5 --- scripts/event-flags-atomic-window.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh index ad7516d..d42cb8e 100755 --- a/scripts/event-flags-atomic-window.sh +++ b/scripts/event-flags-atomic-window.sh @@ -133,7 +133,7 @@ fi # Both portable paths must remain straight-line. Any branch in a hot function # means the claimed single critical section has acquired a retry/dispatch path. if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/thumbv6m.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|jx?)[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|c(b|bn)z|jx?|loop[a-z]*)[[:space:]]'; then printf 'error: thumbv6m EventFlags hot path contains a branch.\n' >&2 exit 1 fi From 5941e60eac1305ce22bb39ac6a85c23daaa85f12 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:19:55 -0400 Subject: [PATCH 39/87] Replace the sentinel CAS with a no-op RMW re-read (P1 fix) Round-3 Codex P1, confirmed: strong compare_exchange lowers to an LR/SC retry loop on riscv32imac with no static bound, contradicting B1 and the crate's no-retry-loop rule - the round-1 fix traded a stale- read bug for an ISA-level unbounded loop on one gated target. The sentinel path now re-reads through fetch_or(0): a no-op RMW is guaranteed to observe the latest value in modification order (unlike a load or a failed CAS), which preserves the round-1 correctness proof, and it compiles to a single AMO. Proof on the gated LR/SC ISA - riscv32imac counted_increment disassembly is now lw / lw / li / bne / amoor.w / beq / li / amoadd.w / ret: nine instructions, zero lr.w/sc.w. The mutation discipline holds: replacing the RMW with a plain load fails the round-1 Loom litmus; all three CountedSignal Loom models pass with the fix; 73/11/5 green. Code size improved on seven of eight gated rows (-8..-12 B; thumbv6m critical-section row unchanged); baseline re-blessed deliberately with the diff reviewed. Cortex-M3 8/7 cycle rows stand - the sentinel change never touches the measured common path. Contract B1, the proposal (section 3/3.1, measured tables), the engineering record, and all rustdoc surfaces now describe the RMW algorithm; compare-exchange survives only in negations and history. Co-Authored-By: Claude Fable 5 --- docs/proposals/counted-signal-contract.md | 8 +++-- docs/proposals/counted-signal.md | 44 +++++++++++++---------- docs/records/counted-signal.md | 18 +++++----- scripts/codesize/baseline.tsv | 14 ++++---- src/counted_signal.rs | 41 ++++++++++----------- 5 files changed, 69 insertions(+), 56 deletions(-) diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index 13ae5da..80c463d 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -78,9 +78,11 @@ Algorithmic bounds live here; instruction counts remain measured claims tied to a target, toolchain, and reference environment. - **B1.** `increment` performs a statically bounded amount of work independent - of signal history and consumer activity: at most one contention retry from - the sole consumer's reset (no unbounded retry loop), no dynamic allocation, - no user code, and no wait for the consumer. + of signal history and consumer activity: a fixed instruction sequence on + every gated ISA — one load, at most one no-op RMW re-read on the saturation + sentinel, and at most one `fetch_add`; no compare-exchange (and therefore no + LR/SC retry loop), no dynamic allocation, no user code, and no wait for the + consumer. - **B2.** `take_count` performs a statically bounded amount of work independent of the number of increments in the interval and producer activity: no retry loop, no dynamic allocation, no user code, and no wait for the producer. diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index 38df636..8f259c3 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -63,9 +63,11 @@ should remain statically sized and avoid a general dynamic registry. ## 3. Resolved candidate decisions -- Saturation uses the sole-producer algorithm in §3.1: a Relaxed load plus a - compare-exchange that treats observed `u32::MAX` as maybe-stale, with at most - one contention retry from the sole consumer reset. +- Saturation uses the sole-producer algorithm in §3.1: a Relaxed load, with + an observed `u32::MAX` re-read through a no-op RMW (`fetch_or(0)`) that is + guaranteed to see the latest value in modification order — a fixed + instruction sequence on every gated ISA, with no compare-exchange and + therefore no LR/SC retry loop on RISC-V. - `u32::MAX` is the observable saturation sentinel, wrapped in `CountSnapshot`; exact excess beyond the sentinel is intentionally absent. - `take_count` is a `swap(0)` and reports one take interval, not a running @@ -85,10 +87,8 @@ listed, but only under a deliberately narrow handle model: ```rust let observed = count.load(Relaxed); -if observed == u32::MAX - && count.compare_exchange(u32::MAX, u32::MAX, Relaxed, Relaxed).is_ok() -{ - return; // confirmed saturated no-op +if observed == u32::MAX && count.fetch_or(0, Relaxed) == u32::MAX { + return; // RMW re-read confirms current saturation: absorbed no-op } count.fetch_add(1, Relaxed); ``` @@ -96,14 +96,18 @@ count.fetch_add(1, Relaxed); A plain load-and-skip-on-`MAX` short-circuit is **not** exact: after `take_count` has returned, a later `increment` may still observe a stale `MAX` under Relaxed ordering and drop the occurrence from both take -intervals (violating T3/A1). The sentinel path therefore confirms with -`MAX → MAX` — success is a true saturated no-op; failure falls through to -`fetch_add` into the post-take epoch (at most one contention retry under B1). +intervals (violating T3/A1). The sentinel path therefore re-reads through a +no-op RMW — an RMW, unlike a load or a failed compare-exchange, observes the +latest value in modification order, so `MAX` proves current saturation +(absorbed no-op) and anything else falls through to `fetch_add` into the +post-take epoch. Every path is a fixed instruction sequence under B1; the +original sentinel used a strong compare-exchange, which was replaced when +review showed it lowers to an unbounded LR/SC retry loop on RISC-V. This remains **not** correct for multiple producers: two producers could both observe `u32::MAX - 1` and the second `fetch_add` would wrap. It is correct with the crate's existing sole `Send + !Sync` producer handle. Between the load and -RMW (or between the failed sentinel CAS and the follow-up `fetch_add`), the +RMW (or between the sentinel re-read and the follow-up `fetch_add`), the consumer's `swap(0)` is the only possible competing write and it can only lower the value, so the RMW cannot wrap. @@ -111,7 +115,7 @@ Consequences for the deferred decisions: | Choice | Evaluation result | |---|---| -| Sole `Send + !Sync` producer, methods take `&self` | Exact saturation; common path is one load plus one `fetch_add`; sentinel path adds one confirming CAS and at most one follow-up `fetch_add`; matches existing handle ownership. | +| Sole `Send + !Sync` producer, methods take `&self` | Exact saturation; common path is one load plus one `fetch_add`; sentinel path adds one no-op RMW re-read and at most one follow-up `fetch_add`; matches existing handle ownership. | | Shareable/multiple raisers | The intervening-writer proof fails; needs an unbounded CAS loop, a weaker overflow contract, or a substantially more complex bounded algorithm. | | `u32::MAX` reserved sentinel wrapped by `CountSnapshot` | Full exact range below the sentinel; `is_saturated()` observes saturation in the take that clears it; the snapshot remains one word and needs no second sticky atomic. | | `swap(0)` take, relaxed ordering | Counts only, with no payload publication to order. Atomic modification order partitions each increment into exactly one take epoch. | @@ -137,7 +141,7 @@ Evidence added with the prototype: per-target measurements rather than from source shape. The isolated release-mode code-size rows on the pinned Rust 1.92.0 toolchain -(after the sentinel-CAS fix) expose the architecture split for review: +(after the sentinel-RMW fix) expose the architecture split for review: | Target family | `increment` | `take_count` | |---|---:|---:| @@ -148,12 +152,16 @@ The isolated release-mode code-size rows on the pinned Rust 1.92.0 toolchain | RV32IMAC | 34 B | 8 B | The M0/M23 rows use the existing portable-atomic single-core probe backend. -Versus the pre-fix load-and-skip path this is a deliberate +16…+36 B growth -on gated rows: exactness after a completed take requires the confirming CAS. +Versus the pre-fix load-and-skip path this remains a deliberate growth on +gated rows — exactness after a completed take requires the confirming RMW +re-read — but the RMW refinement recovered 8–12 B on seven of the eight +rows relative to the interim compare-exchange form (thumbv6m's +critical-section row is unchanged), and on RV32IMAC the sentinel is a +single `amoor.w` with no `lr.w`/`sc.w` pair in the disassembly. Re-measured in the pinned reference environment via `./scripts/verify.sh cycles` -after the sentinel-CAS fix. The probe brackets the common below-`MAX` path -(`load` + `fetch_add`) and a `swap(0)` take — the confirming CAS only runs when +after the sentinel fix. The probe brackets the common below-`MAX` path +(`load` + `fetch_add`) and a `swap(0)` take — the sentinel re-read only runs when the producer observes `MAX`, so these rows stay the hot-path cost: | Cortex-M3 hot path | Retired guest instructions | @@ -180,7 +188,7 @@ type's proof. ## 4. Promotion bar to PROPOSED 1. **Complete for the SPSC candidate:** the bounded-saturating-increment - question has the sole-producer CAS answer proved in §3.1. + question has the sole-producer sentinel-RMW answer proved in §3.1. 2. **Complete:** the shared handle model is sole-role `Send + !Sync` handles with `&self` operations for both this lane and EventFlags. The proof dependency is recorded in the contract's H-decision section. diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index aee8831..f9ed70a 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -7,7 +7,7 @@ - **Normative sources:** [contract](../proposals/counted-signal-contract.md) (clause IDs cited below) · [proposal](../proposals/counted-signal.md) · measurements inline in proposal §3.1 (eight-target code size; pinned - Cortex-M3 cycles via `./scripts/verify.sh cycles`, confirmed 8 / 7 post-CAS). + Cortex-M3 cycles via `./scripts/verify.sh cycles`, confirmed 8 / 7 post-fix). ## 1. Value statement @@ -19,8 +19,10 @@ linearizes exactly once into a take interval; when the exact range is exhausted the signal saturates observably rather than wrapping — the same `dropped_accum` honesty the crate already requires of overload accounting. Its soundness argument for exact, wrap-free saturation is -**sole-producer ownership**: between CAS attempts the consumer can only -reset, so the RMW cannot wrap and there is at most one contention retry. +**sole-producer ownership**: between the producer's reads and RMWs the +consumer can only reset, so the RMW cannot wrap — and every path is a fixed +instruction sequence (the saturation sentinel is re-read via a no-op +`fetch_or(0)` RMW, never a compare-exchange, so no LR/SC retry on RISC-V). It complements `EventFlags` (presence) rather than replacing it, and it refuses to be: a payload channel, an ordering authority, a multi-producer bus, or an unbounded exact total past saturation. @@ -54,8 +56,8 @@ IDs in parentheses; the clauses are the normative statements. destructors, or a forgotten handle, leaves the role held. There is deliberately no out-of-band forced release. - **Algorithmic bounds are not wall-clock claims (B1–B3, X5).** - `increment` allows at most one contention retry from the sole - consumer reset; `take_count` has no retry loop. Neither waits or + `increment` is a fixed instruction sequence on every gated ISA + (no retry of any kind); `take_count` has no retry loop. Neither waits or panics on the hot path. Instruction and latency numbers are environment-specific measurements; cite them with the pinned toolchain and QEMU stamp, never as universal cycle truths. @@ -72,12 +74,12 @@ IDs in parentheses; the clauses are the normative statements. | Saturates at `u32::MAX`, never wraps; sticky until take (I2–I3, T2, A2–A3) | `saturates_instead_of_wrapping`; Loom `counted_signal_saturation_boundary_is_linearizable` | Proven | | Concurrent increment belongs to exactly one take interval (T1–T3, A1) | Loom `counted_signal_take_partitions_increments`; threaded take stress | Proven | | Post-take increment after saturated take is not dropped by a stale MAX observe (T3, A1) | Loom `counted_signal_post_take_increment_observes_reset_epoch` (Relaxed gate only) | Proven | -| Wait-free bounded hot paths: load + `fetch_add`, sentinel CAS + ≤1 follow-up `fetch_add`; `swap(0)` take (B1–B2) | Source review; eight-target code size blessed after intentional +16…+36 B growth; Cortex-M3 cycles re-measured post-CAS at 8 / 7 (`./scripts/verify.sh cycles`) | Measured | +| Wait-free bounded hot paths: load + `fetch_add`; sentinel no-op RMW re-read + ≤1 follow-up `fetch_add`; `swap(0)` take (B1–B2) | Source review; riscv32imac disassembly shows lw/amoor.w/amoadd.w with **no lr.w/sc.w**; eight-target code size re-blessed post-RMW; Cortex-M3 cycles 8 / 7 (common path untouched by the sentinel change) | Measured | | No panic reachable from hot paths (B3) | Source review; normal, Miri, Loom, and embedded-target executions | Proven | | Sole-role exclusive handles; reacquisition continues state (H1, H3) | `handles_are_exclusive_and_reusable_after_drop` | Proven | | Handles are `Send + !Sync` (H2) | `handles_are_send`; compile-fail doctests pin `!Sync` on both roles | Pinned | | Constructible in static storage (H4) | `const_new_works_in_static_context` | Proven | -| Cost claims per target | Eight-target gated code-size rows (proposal §3.1); re-bless if CAS grows any gated row beyond +16 B | Measured | +| Cost claims per target | Eight-target gated code-size rows (proposal §3.1), re-blessed after the sentinel-RMW change | Measured | Full CI for the lane (`0a22ada` admission package; `f26d4c3` H finalization): complete pinned `./scripts/verify.sh` matrix with zero @@ -95,7 +97,7 @@ in contract §8 and proposal §3.1–§3.2):** confirmation.** Below `MAX`, one Relaxed load and one Relaxed `fetch_add`. Observed `MAX` is confirmed with `compare_exchange(MAX, MAX)`; success is a saturated no-op, failure falls through to one - `fetch_add` into the post-take epoch (at most one contention retry). + `fetch_add` into the post-take epoch (fixed sequence, no retry). Rejected for this type: a load-and-skip-on-`MAX` short-circuit (fails T3/A1 after a completed take under Relaxed observation), an unbounded CAS loop (fails B1), silent wrap (fails A2/I3 and the crate's diff --git a/scripts/codesize/baseline.tsv b/scripts/codesize/baseline.tsv index 8a3befe..86f19be 100644 --- a/scripts/codesize/baseline.tsv +++ b/scripts/codesize/baseline.tsv @@ -8,17 +8,17 @@ # Xtensa is deliberately absent -- it needs the esp-rs fork, and gating it # would make that fork mandatory for every contributor. armv7a-none-eabi bss 268 -armv7a-none-eabi counted_increment 76 +armv7a-none-eabi counted_increment 64 armv7a-none-eabi counted_take 28 armv7a-none-eabi data 0 armv7a-none-eabi two_calls 220 armv7r-none-eabi bss 268 -armv7r-none-eabi counted_increment 76 +armv7r-none-eabi counted_increment 64 armv7r-none-eabi counted_take 28 armv7r-none-eabi data 0 armv7r-none-eabi two_calls 220 riscv32imac-unknown-none-elf bss 268 -riscv32imac-unknown-none-elf counted_increment 34 +riscv32imac-unknown-none-elf counted_increment 26 riscv32imac-unknown-none-elf counted_take 8 riscv32imac-unknown-none-elf data 0 riscv32imac-unknown-none-elf two_calls 152 @@ -28,22 +28,22 @@ thumbv6m-none-eabi counted_take 24 thumbv6m-none-eabi data 0 thumbv6m-none-eabi two_calls 156 thumbv7em-none-eabi bss 268 -thumbv7em-none-eabi counted_increment 54 +thumbv7em-none-eabi counted_increment 44 thumbv7em-none-eabi counted_take 22 thumbv7em-none-eabi data 0 thumbv7em-none-eabi two_calls 180 thumbv7m-none-eabi bss 268 -thumbv7m-none-eabi counted_increment 54 +thumbv7m-none-eabi counted_increment 44 thumbv7m-none-eabi counted_take 22 thumbv7m-none-eabi data 0 thumbv7m-none-eabi two_calls 180 thumbv8m.base-none-eabi bss 268 -thumbv8m.base-none-eabi counted_increment 56 +thumbv8m.base-none-eabi counted_increment 44 thumbv8m.base-none-eabi counted_take 22 thumbv8m.base-none-eabi data 0 thumbv8m.base-none-eabi two_calls 156 thumbv8m.main-none-eabi bss 268 -thumbv8m.main-none-eabi counted_increment 54 +thumbv8m.main-none-eabi counted_increment 44 thumbv8m.main-none-eabi counted_take 22 thumbv8m.main-none-eabi data 0 thumbv8m.main-none-eabi two_calls 152 diff --git a/src/counted_signal.rs b/src/counted_signal.rs index 0860685..d2ce067 100644 --- a/src/counted_signal.rs +++ b/src/counted_signal.rs @@ -8,10 +8,13 @@ //! The single-producer handle is load-bearing. Below [`u32::MAX`] the producer //! uses a Relaxed `fetch_add`, which cannot wrap because only the consumer may //! write between the load and the RMW and it can only reset to zero. An -//! observed `u32::MAX` is treated as maybe-stale: a successful `MAX → MAX` CAS -//! confirms true saturation (no-op), while failure means a completed take -//! reset the counter and the producer `fetch_add`s into the new epoch. That is -//! at most one contention retry. Multiple producers would invalidate the proof. +//! observed `u32::MAX` is treated as maybe-stale and re-read through a no-op +//! RMW (`fetch_or(0)`), which — unlike a load — observes the latest value in +//! modification order: `MAX` confirms true saturation (skip), anything else +//! means a completed take reset the counter and the producer `fetch_add`s into +//! the new epoch. Every path is a fixed instruction sequence on every gated +//! ISA — no compare-exchange, so no LR/SC retry loop on RISC-V. Multiple +//! producers would invalidate the proof. //! //! The counter carries no payload-publication semantics. These Relaxed //! operations order the count itself, but do not publish unrelated application @@ -118,8 +121,8 @@ impl core::fmt::Debug for CountedSignal { /// The sole incrementing handle for a [`CountedSignal`]. /// /// This handle is `Send + !Sync`. Its exclusivity is what keeps exact -/// saturation wrap-free with at most one contention retry on the sentinel -/// path from the consumer reset. +/// saturation wrap-free with a fixed instruction sequence on every path — +/// the sentinel re-read is a no-op RMW, never a retry loop. /// /// The load-bearing `!Sync` property is pinned at compile time: /// @@ -138,25 +141,23 @@ impl Producer<'_> { /// Record one occurrence. /// /// Below `u32::MAX` this is one Relaxed load and one Relaxed `fetch_add`. - /// Observed `u32::MAX` is confirmed with a `MAX → MAX` CAS (saturated - /// no-op) or, on failure, one `fetch_add` into the post-take epoch. Under - /// sole-producer ownership the consumer's `swap(0)` is the only competing - /// write, so there is at most one contention retry and the counter never - /// wraps. + /// An observed `u32::MAX` is re-read through a no-op RMW (`fetch_or(0)`): + /// `MAX` confirms saturation (skip), anything else is a post-take epoch + /// and one `fetch_add` records the occurrence. Under sole-producer + /// ownership the consumer's `swap(0)` is the only competing write, so + /// every path is a fixed instruction sequence — no retry loop on any + /// gated ISA — and the counter never wraps. #[inline] pub fn increment(&self) { // Only this handle may increase `count`; the consumer can only reset it // to zero. A plain skip on MAX can be stale after take returns, so the - // sentinel path must CAS to distinguish true saturation from a reset - // epoch that still needs this occurrence (contract T3 / A1). + // sentinel path re-reads through an RMW: `fetch_or(0)` writes nothing + // back but, unlike a load or a failed compare_exchange, is guaranteed + // to observe the latest value in modification order — and it stays a + // single atomic op on LR/SC ISAs, where a strong compare_exchange + // lowers to a retry loop with no static bound (contract T3 / A1 / B1). let observed = self.signal.count.load(Ordering::Relaxed); - if observed == u32::MAX - && self - .signal - .count - .compare_exchange(u32::MAX, u32::MAX, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { + if observed == u32::MAX && self.signal.count.fetch_or(0, Ordering::Relaxed) == u32::MAX { return; } // Wrap-free under H1: intervening writes can only lower the value. From 34b169cf146d3ec3904a5a5acec9755721ea816c Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:21:05 -0400 Subject: [PATCH 40/87] Address round-3 findings: REGEN recovery hints and honest DMA guidance Both confirmed. The rustc-mismatch and regression recovery texts now use the per-mode REGEN hint, so a block-matrix gate event directs the re-bless at baseline-block.tsv instead of rewriting the default baseline. And the double-copy guidance no longer recommends the unimplementable: BlockBuilder storage is deliberately private, so the docs (module, README, record) now say so - budget both copies or publish from task context - and route the direct-fill requirement to cycle decision S reopening trigger where it belongs, rather than prying the builder open under review. Co-Authored-By: Claude Fable 5 --- README.md | 5 +++-- scripts/codesize.sh | 8 ++++---- src/block.rs | 18 ++++++++++++------ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ead3f45..daec598 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,9 @@ rejected push costs nearly as much as an accepted one (the complete block is preserved and returned, within 5–31 instructions), and RAM is multiple complete blocks — `Q` slots plus the private builder. Small windows can invert the economics (per-sample publication beats blocks at the -8/16-byte `N = 8` corners), and DMA integrations must avoid the double -copy: make the builder the DMA target, or publish from task context. The +8/16-byte `N = 8` corners), and DMA integrations currently cannot avoid +the double copy in ISR context — the builder's storage is deliberately +private, so either budget both copies or publish from task context. The `block` module docs carry the full measured disclosure. ```rust diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 6d4cf32..654d777 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -328,8 +328,8 @@ SKIP baseline gate: baseline was recorded with rustc %s, this is %s. ' printf 'signal. Re-bless deliberately after reviewing the diff: ' - printf ' ./scripts/codesize.sh --bless -' + printf ' %s +' "$REGEN" exit 2 fi @@ -384,8 +384,8 @@ if [ "${regressions:-0}" -gt 0 ]; then printf ' %s row(s) grew by more than %s bytes. ' "$regressions" "$TOLERANCE" - printf 'If the growth is intended, review it and run: ./scripts/codesize.sh --bless -' + printf 'If the growth is intended, review it and run: %s +' "$REGEN" exit 1 fi printf ' ok -- no row grew by more than %s bytes\n' "$TOLERANCE" diff --git a/src/block.rs b/src/block.rs index 2350fff..1f4fa4f 100644 --- a/src/block.rs +++ b/src/block.rs @@ -36,12 +36,18 @@ //! preserved and returned rather than reduced to a scalar error. Budget //! rejection like acceptance, not like error plumbing. //! -//! **DMA integrations: mind the double copy.** A DMA engine has already -//! written the samples once; filling this builder from the DMA buffer and -//! then publishing crosses the payload a second time. Either make the -//! builder's storage the DMA target, or publish from task context where -//! the copy is off the interrupt path. (DMA cache maintenance remains -//! outside this crate, per the taxonomy's out-of-scope list.) +//! **DMA integrations: the double copy is currently unavoidable in ISR +//! context.** A DMA engine has already written the samples once, and this +//! builder's storage is deliberately private — the public API offers no +//! address or writable slice a DMA controller could target — so filling +//! the builder from the DMA buffer crosses the payload a second time. +//! Either budget both copies against the accepted row for your shape, or +//! publish from task context where the copy is off the interrupt path. A +//! direct-to-granted-slot fill API is exactly the registered reopening +//! condition of cycle decision S (the deferred SlotPool foundation) — a +//! real adopter with this requirement reopens that lane rather than +//! prying the builder open. (DMA cache maintenance remains outside this +//! crate, per the taxonomy's out-of-scope list.) //! //! **No partial block is ever visible** — sample-level freshness inside a //! filling window is unobtainable by design, stated here so it is chosen, From 184a2027a2116f090ea1f3c35a995f5ec516b9b2 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:22:38 -0400 Subject: [PATCH 41/87] Address round-3 findings: Xtensa calls, README tense, test count All three confirmed. The ESP32-S2 straight-line detector now rejects call0/call4/call8/callx* alongside branches, jumps, and zero-overhead loops - an outlined helper is a control transfer the gate must see (regex-verified: all three call forms caught, s32c1i/rsr untouched). The README constructor sentence reads past tense on this lane copy too, and the proposal admission count now matches the 77 tests the tree actually contains. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- docs/proposals/event-flags.md | 2 +- scripts/event-flags-atomic-window.sh | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 130eb83..d0dd9b8 100644 --- a/README.md +++ b/README.md @@ -257,8 +257,8 @@ them is a runtime step and always will be. - `SeqRing`, `EventBuf`, and `EventFlags` are SPSC by design: exactly one producer and one consumer may be active. Use `try_producer()`/`try_consumer()`, which return `None` rather than panicking — on a microcontroller a panic is a reset, and the panic machinery costs flash you may not have. - The panicking `producer()`/`consumer()` are **deprecated since 0.2.0** and will be removed in - 0.3.0. Using unsafe to bypass the SPSC constraint (or sharing handles concurrently) is + The panicking `producer()`/`consumer()`, deprecated since 0.2.0, **were removed in + 0.3.0**. Using unsafe to bypass the SPSC constraint (or sharing handles concurrently) is undefined behavior. - `T: Copy` is required by the three buffer types to avoid allocation and return values by copy. - `EventFlags` has no unsafe slot access and passes Miri with the race detector enabled. diff --git a/docs/proposals/event-flags.md b/docs/proposals/event-flags.md index 8079375..04db119 100644 --- a/docs/proposals/event-flags.md +++ b/docs/proposals/event-flags.md @@ -102,7 +102,7 @@ stand in for the publication test. ### 4.1 Behaviour, Loom, and Miri -- 78 unit tests exercise empty/all masks, bit 31, checked index construction, +- 77 unit tests exercise empty/all masks, bit 31, checked index construction, duplicate and multi-bit raises, clear-on-take, role acquisition, static construction, threaded take races, and publication. - Three EventFlags Loom models exhaustively cover one-raise and two-distinct- diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh index d42cb8e..ab4abe5 100755 --- a/scripts/event-flags-atomic-window.sh +++ b/scripts/event-flags-atomic-window.sh @@ -133,7 +133,7 @@ fi # Both portable paths must remain straight-line. Any branch in a hot function # means the claimed single critical section has acquired a retry/dispatch path. if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/thumbv6m.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|c(b|bn)z|jx?|loop[a-z]*)[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|c(b|bn)z|call[0-9x]*|jx?|loop[a-z]*)[[:space:]]'; then printf 'error: thumbv6m EventFlags hot path contains a branch.\n' >&2 exit 1 fi @@ -157,7 +157,7 @@ if [ "$want_esp" -eq 1 ]; then exit 1 fi if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/esp32s2.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|jx?|loop[a-z]*)[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|call[0-9x]*|jx?|loop[a-z]*)[[:space:]]'; then printf 'error: ESP32-S2 EventFlags hot path contains a branch.\n' >&2 exit 1 fi From 1cb57b9b0bddb61f735b53851ca00b85e4414af9 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:22:43 -0400 Subject: [PATCH 42/87] Correct the Common Traits section for LatestBuf Round-3 Codex finding, confirmed: the blanket all-producers claim was falsified by this very lane - neither LatestBuf handle implements the stream traits, by decision D2. The prose now scopes Sink/Source to the ring-buffer handles, explains why LatestBuf stands outside the pair, and the trait table gains LatestSink/LatestSource rows. Co-Authored-By: Claude Fable 5 --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index daab7df..07fa46b 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,13 @@ assert!(producer.push(3).is_ok()); // space freed ### Common Traits -All producers implement `Sink` and all consumers implement `Source`, -so you can write generic code that works with any combination: +The ring-buffer producers implement `Sink` and their consumers +implement `Source`, so generic code works with any combination of the +listed handles. `LatestBuf` deliberately stands outside that pair: its +consumer implements `LatestSource` (and its producer `LatestSink`), +because `try_pop` cannot report the displacement that is this channel's +designed overload behaviour — a generic `Source` bound will not compile +against it, by decision D2: ```rust use ph_eventing::{SeqRing, EventBuf}; @@ -195,6 +200,8 @@ assert!(err.is_none()); | `Sink` | Accept events | `RingBuf`, `seq_ring::Producer`, `event_buf::Producer` | | `Source` | Yield events | `seq_ring::Consumer`, `event_buf::Consumer` | | `Link` | Both | Blanket impl for `Sink + Source` | +| `LatestSink` | Publish latest | `latest_buf::Producer` (reports replacement) | +| `LatestSource` | Take latest | `latest_buf::Consumer` (reports generation + skipped) | ### Declarative static bring-up From 1af401ac334771e3514b1a4605149f5320d0542c Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:23:29 -0400 Subject: [PATCH 43/87] Address the round-1 Codex findings on the SeqRing record Both confirmed against the source. The .bss claim mis-attributed the 268-byte probe measurement: that row measures an EventBuf static, and no SeqRing static is probed or baseline-gated - the record now says so and states SeqRing RAM as size_of-computable with its N per-slot sequence atomics. And the loss-accounting promise over-reached: it is exact for the ordered poll_* paths only - latest() samples without a count and skip_to_latest() discards without touching the dropped counter (both documented in the source) - now stated in the value statement and disclosed as a risk bullet so mixed-mode consumers do not read dropped_accum as a total ledger. Co-Authored-By: Claude Fable 5 --- docs/records/seq-ring.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md index dd3b3bc..7d64d53 100644 --- a/docs/records/seq-ring.md +++ b/docs/records/seq-ring.md @@ -15,10 +15,15 @@ overwrite ring for high-rate telemetry where the producer must never block and stale data is droppable — but unlike a latest-value snapshot, the consumer drains *in order* (`poll_one`/`poll_up_to`) or samples the -newest (`latest`), and every loss is counted. A consumer that falls more -than `N` behind skips ahead and is told exactly how many items it lost -(`PollStats::dropped`; the saturating `dropped_accum` is the origin of -the crate's observable-loss convention). It refuses to be: a delivery +newest (`latest`), and losses on the ordered path are counted. A consumer +that falls more than `N` behind skips ahead and is told exactly how many +items it lost (`PollStats::dropped`; the saturating `dropped_accum` is the +origin of the crate's observable-loss convention). The exactness promise +belongs to the ordered `poll_*` paths only: `latest()` samples without +advancing the cursor or reporting a gap, and `skip_to_latest()` discards +the backlog *without* adding to the dropped counter — both are deliberate +sampling/fast-forward semantics, disclosed in §2 so they are chosen, not +discovered. It refuses to be: a delivery guarantee (`EventBuf` is), a race-free primitive (see §2 — this is the crate's one deliberate formal-soundness trade), or a latest-value-only channel (`LatestBuf` is, with a race-free ownership argument). @@ -47,6 +52,12 @@ channel (`LatestBuf` is, with a race-free ownership argument). - **Loss is designed behaviour.** Overwrite is the overload policy; the counters report it, nothing prevents it. Consumers that must see every event belong on `EventBuf`. +- **Two consumption modes bypass the loss accounting — by design.** + `latest()` peeks the newest value without advancing the cursor or + producing a skipped count, and `skip_to_latest()` fast-forwards past + the backlog without modifying the dropped counter. A consumer mixing + these with `poll_*` must not read `dropped`/`dropped_accum` as a total + loss ledger — it accounts only what the ordered path drained past. - **Role lifecycle follows the crate doctrine:** sole-role `Send + !Sync` handles, `try_*`-only acquisition (the panicking constructors are gone in 0.3.0), roles held until the handle drops, @@ -61,7 +72,7 @@ channel (`LatestBuf` is, with a race-free ownership argument). | Fence placement is necessary (Release before value write; Acquire before re-check) | Module-doc argument; Loom; ordering discipline enforced by AGENTS' rerun-after-atomic-change rule | Proven | | Recovery cost is constant w.r.t. lag | 0.2.0 measurement: a consumer 2,000 sequences behind recovers in the same 115 instructions as one 16 behind | Measured | | Loss accounting is exact and saturating (`read + dropped` accounts for every sequence; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned | -| Const-constructs into `.bss` — no flash image, no startup copy | 0.2.0 measurement: 268 bytes, zero flash, across 11 targets / 4 ISA families; codesize baseline gated in CI | Measured, gated | +| Const-constructs into `.bss` — no flash image, no startup copy | By construction (`const fn new`); the 0.2.0 codesize probe demonstrates the crate's `.bss` discipline on an `EventBuf` static (268 B) — **no dedicated SeqRing static is probed or baseline-gated**, and SeqRing's RAM adds `N` per-slot sequence atomics over the payload array, `size_of`-computable per shape | By construction; probe coverage is EventBuf's | ## 4. The record From 609f2e058922f5409121f280fff23a633d2287c5 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:36:19 -0400 Subject: [PATCH 44/87] Purge the stale sentinel-CAS wording from the public claim surface Round-4 Codex P2 on PR #33: after the fetch_or(0) no-op-RMW fix, the README, changelog, contract section 8 / evidence map, and record still described the replaced compare-exchange algorithm and its 'at most one contention retry' bound. On RISC-V that describes the opposite boundedness evidence from the shipped code. All surfaces now state the no-op RMW re-read (latest-value-in-modification-order argument, no compare-exchange, no retry loop); the record keeps the two-step fix history explicitly. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 +++++++++++++---------- README.md | 5 +++-- docs/proposals/counted-signal-contract.md | 15 ++++++++------- docs/records/counted-signal.md | 7 +++++-- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc012c8..46327cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,21 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Fixed - `CountedSignal::increment`: replace the load-and-skip-on-`MAX` short-circuit - with a sole-producer–bounded CAS that treats observed `MAX` as maybe-stale - (`MAX → MAX` confirms saturation; failure retries once into the post-take - epoch). A completed `take_count` followed by a later `increment` can no + with a no-op RMW (`fetch_or(0)`) re-read that treats observed `MAX` as + maybe-stale — an RMW observes the latest value in modification order, so a + `MAX` re-read confirms saturation and anything else proceeds into the + post-take epoch, with no compare-exchange and no LR/SC retry loop on + RISC-V. A completed `take_count` followed by a later `increment` can no longer lose the occurrence under Relaxed observation (contract T3/A1). Loom litmus: `counted_signal_post_take_increment_observes_reset_epoch`. ### Documentation -- CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned Cortex-M3 rows (confirmed 8 / 7 post-CAS), and the H closure record. -- CountedSignal Cortex-M3 cycle rows re-confirmed at 8 / 7 after the sentinel-CAS +- CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned Cortex-M3 rows (confirmed 8 / 7 after the sentinel-RMW fix), and the H closure record. +- CountedSignal Cortex-M3 cycle rows re-confirmed at 8 / 7 after the sentinel-RMW fix (`./scripts/verify.sh cycles`, QEMU 10.0.11); pending-remeasure markers cleared. -- CountedSignal contract B1: at most one contention retry from the sole - consumer reset; proposal §3.1 linearization claim corrected; README Sink/Source - claim qualified to payload-buffer handles. +- CountedSignal contract B1: fixed instruction sequence with a no-op RMW + sentinel re-read — no compare-exchange, no retry; proposal §3.1 + linearization claim corrected; README Sink/Source claim qualified to + payload-buffer handles. ### Added - `CountedSignal`: a payload-free SPSC counter with a bounded `increment`, atomic `take_count`, exact `u32` saturation, and observable @@ -24,8 +27,8 @@ All notable changes to this project will be documented in this file. interleaving, and the post-take stale-`MAX` litmus; its frozen contract maps citable clauses to unit, threaded, Loom, Miri, code-size, and QEMU evidence. Exact bounded saturation depends on retaining a sole `Send + !Sync` producer - handle. Cortex-M3 instruction counts remain 8 / 7 after the CAS path - (`./scripts/verify.sh cycles`). + handle. Cortex-M3 instruction counts remain 8 / 7 after the sentinel-RMW + path (`./scripts/verify.sh cycles`). ### Removed - **Breaking:** the panicking `SeqRing::{producer, consumer}` and `EventBuf::{producer, consumer}`, deprecated since 0.2.0 with removal scheduled for 0.3.0. diff --git a/README.md b/README.md index a689aca..3ba4e66 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,9 @@ assert!(producer.push(3).is_ok()); // space freed A saturating count for repeated events whose payload and ordering do not matter. The sole producer is load-bearing: it permits exact saturation with a -bounded compare-exchange that treats observed `u32::MAX` as maybe-stale, with -at most one contention retry from the consumer reset. +fixed instruction sequence that treats observed `u32::MAX` as maybe-stale and +confirms it through a no-op RMW re-read — an RMW observes the latest value in +modification order, so there is no compare-exchange and no retry loop. ```rust use ph_eventing::CountedSignal; diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index 80c463d..9ed34bb 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -119,11 +119,12 @@ The accepted answer shared with EventFlags is H1–H4: sole-role `Send + !Sync` handles with `&self` hot-path operations and state resident in the signal. For CountedSignal, this is a correctness choice rather than an API style preference. The bounded candidate implementation confirms saturation with -a compare-exchange and advances below the sentinel the same way. Sole-producer -ownership means the consumer is the only possible intervening writer and can -only reset the state, so there is at most one contention retry and the RMW -cannot wrap; a second raiser would invalidate the no-wrap proof and the current -evidence for B1. +a no-op RMW re-read of the count — an RMW observes the latest value in +modification order, so a stale `MAX` cannot be mistaken for saturation and no +retry is ever needed. Sole-producer ownership means the consumer is the only +possible intervening writer and can only reset the state, so the follow-up +`fetch_add` cannot wrap; a second raiser would invalidate the no-wrap proof and +the current evidence for B1. The independent LatestBuf A.3 decision currently recommends the same handle shape. This closes H for EventFlags and CountedSignal only. Whether the @@ -138,13 +139,13 @@ a separate maintainer decision on issue #26. | I2–I3, T2, A2–A3 | `saturates_instead_of_wrapping`; Loom's `counted_signal_saturation_boundary_is_linearizable` model | | T1–T3, A1 | Loom's `counted_signal_take_partitions_increments` model; threaded take stress | | T3, A1 (post-take / stale MAX) | Loom's `counted_signal_post_take_increment_observes_reset_epoch` model (seeded at `MAX`; Relaxed gate only) | -| B1–B2 | Source review (≤1 contention retry under H1); eight-target gated code-size rows blessed post-fix (46–76 B `increment`); Cortex-M3 cycles re-measured post-CAS at 8 / 7 (`./scripts/verify.sh cycles`, QEMU 10.0.11) | +| B1–B2 | Source review (fixed sequence under H1 — no compare-exchange, no retry); eight-target gated code-size rows blessed post-fix (46–76 B `increment`); Cortex-M3 cycles re-measured after the sentinel-RMW fix at 8 / 7 (`./scripts/verify.sh cycles`, QEMU 10.0.11) | | B3 | Source review plus normal, Miri, Loom, and embedded-target executions of both hot paths | | H1, H3 | `handles_are_exclusive_and_reusable_after_drop`, including state continuation after reacquisition | | H2 | `handles_are_send`; producer and consumer compile-fail doctests pin `!Sync` | | H4 | `const_new_works_in_static_context` | -The candidate's relaxed atomic orderings and its sole-producer CAS proof are +The candidate's relaxed atomic orderings and its sole-producer no-op-RMW proof are implementation evidence for this abstract contract. They remain in [`counted-signal.md`](counted-signal.md) §3.1 and the module documentation, not in the semantic clauses above. diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index f9ed70a..98bce47 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -126,7 +126,9 @@ in contract §8 and proposal §3.1–§3.2):** **Review history:** issue #30 closed with the lane PROPOSED; PR #33 carries the admission package. Bugbot high finding (stale MAX load drops -increments) agreed and fixed with the CAS path + Loom litmus. Codex +increments) agreed and fixed with a sentinel re-read + Loom litmus — first +as a bounded CAS, then (Codex P1) as the no-op RMW after review showed the +CAS lowers to an unbounded LR/SC loop on RISC-V. Codex P2 (qualify README Sink/Source claim) agreed. Cross-lane handle decision recorded against #29; cycle decision matrix remains #26. Contract clause IDs (I/T/A/B/H/X) are load-bearing for tests and models @@ -135,6 +137,7 @@ Contract clause IDs (I/T/A/B/H/X) are load-bearing for tests and models **Where the numbers live:** proposal §3.1 (eight-target `increment` / `take_count` code-size table; pinned Cortex-M3 instruction regions under rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11 — confirmed 8 / 7 -post-CAS); contract §9 evidence map; tracking: issue #30, PR #33; completion +after the sentinel-RMW fix); contract §9 evidence map; tracking: issue #30, +PR #33; completion commits `0a22ada` (clause-numbered contract + pinned costs) and `f26d4c3` (H closed, promotion finalized). From 0348f7ceea253e9a92cde572a032e3c938593bc8 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:37:18 -0400 Subject: [PATCH 45/87] Correct the record's DMA guidance and measured unit-test count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 Codex P2s on PR #34: the record still recommended making the builder a DMA target, which the public API cannot express (storage is deliberately private, no address or writable-slice API) — it now states the two implementable choices and routes direct-to-granted-slot filling to cycle decision S's registered reopening condition, matching the src/block.rs module docs. The claimed 78 unit tests is corrected to the measured 76 (cargo test --lib; the five Loom models are gated behind --cfg loom and reported separately). Co-Authored-By: Claude Fable 5 --- docs/records/block-buf.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index 2a549be..a3011ad 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -79,10 +79,14 @@ section and contract IDs in parentheses; those texts are normative. requirement, reopens the SlotPool branch from its banked evidence. - **The double-copy hazard is real for DMA integrations (P obligation).** DMA already wrote the bytes once; builder-then-publish - crosses them twice unless the builder is the DMA target or publication - moves to task context. Landed: the `src/block.rs` module docs carry - this guidance (with the RAM, inversion, and rejection-cost - disclosures), so integrators choose rather than discover. + crosses them twice, and the builder cannot be the DMA target — its + storage is deliberately private, with no address or writable-slice + API. The choices are to budget both copies or to move publication to + task context; a direct-to-granted-slot fill API is exactly the + registered reopening condition of cycle decision S. Landed: the + `src/block.rs` module docs carry this guidance (with the RAM, + inversion, and rejection-cost disclosures), so integrators choose + rather than discover. - **Drop-new is an action, not a type (§5.1).** There is no `DropBlockBuf`; discarding a newly completed block is what the caller does with `Err(block)`. Loss after completion is reported by the @@ -103,7 +107,7 @@ section and contract IDs in parentheses; those texts are normative. | No block-layer atomics; transport evidence unchanged (§8) | Block path: unit + Miri; LatestBuf/EventBuf: detector-on Miri and Loom on selected transport | Proven (transport) | | Copy vs SlotPool foundation (P / S) | Matrix complete (`bc54a9a`); read against the #26 integration scoping; per-shape rows adopted as the budget statement | **Closed — P = Copy composition, S = deferred (2026-08-11); nothing blocks promotion but the #34 acceptance review** | -Full CI for the lane: 78 unit tests, 12 doctests, 4 compile-fail, 8 +Full CI for the lane: 76 unit tests, 12 doctests, 4 compile-fail, 8 gated codesize targets, 3 embedded checks, 6 Miri passes, 5 Loom models. From f6587af7bb48113a63ec553753600d941b844ac8 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:39:19 -0400 Subject: [PATCH 46/87] Assert generation monotonicity and skipped conservation in the Loom model Round-4 Codex P2 on PR #35: latest_buf_returns_only_complete_publications asserted only per-item completeness, so a duplicated generation or a wrong skipped count would have passed. The consumer now tracks its last observed generation and asserts strict monotonicity (at-most-once) and skipped == generation - last - 1 (C3/O2 conservation; no wrap at model magnitudes). Mutation-verified: an off-by-one conservation relation fails the model immediately, so the asserts are exercised, not vacuous. All five LatestBuf models pass. Also corrects the record's unit-test count to the measured 76 (cargo test --lib; same stale-78 class Codex flagged on the block-buf record this round). Co-Authored-By: Claude Fable 5 --- docs/records/latest-buf.md | 2 +- src/loom_tests.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/records/latest-buf.md b/docs/records/latest-buf.md index 27a8d94..c7304fc 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -92,7 +92,7 @@ IDs in parentheses; the clauses are the normative statements. | Handles stay `Send + !Sync` (H2) | `compile_fail` doctests on `Producer` and `Consumer` | Pinned | | Cost claims per target | 11-target code-size matrix incl. ESP32-S2/S3; pinned QEMU 10.0.11 cycle regions; 66-region joint block matrix reproduced across two QEMU versions | Measured | -Full CI for the lane: 78 unit tests, 12 doctests, 6 compile-fail, 94.12% +Full CI for the lane: 76 unit tests, 12 doctests, 6 compile-fail, 94.12% line coverage, zero skips. ## 4. The record diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 373455e..66db9af 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -38,10 +38,19 @@ fn latest_buf_returns_only_complete_publications() { let consumer = thread::spawn(move || { let consumer = channel.try_consumer().unwrap(); + // Conservation across the run, not just per-item sanity: each + // observed generation must be strictly newer than the last + // (at-most-once, C-family), and `skipped` must equal exactly the + // generations passed over since the previous take (C3/O2; no wrap + // at these magnitudes, so the formula reduces to the difference). + let mut last_generation = 0u32; for _ in 0..3 { if let Some(item) = consumer.take_latest() { assert!((1..=3).contains(&item.generation)); assert_eq!(item.value, [item.generation; 2]); + assert!(item.generation > last_generation); + assert_eq!(item.skipped, item.generation - last_generation - 1); + last_generation = item.generation; } thread::yield_now(); } From d49a2ab5b19d1ead3ed6dc69f63cf4b40177a2aa Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:41:31 -0400 Subject: [PATCH 47/87] Close three round-4 verification gaps: vacuous filters, section counts, stale API text Round-4 Codex P2s on PR #36: - scripts/loom.sh: cargo test exits 0 when a filter matches nothing, so a misspelled model name earned the success banner after running zero tests. After a passing run the script now lists the matches against the warm build and fails unless at least one model ran; the banner reports the verified count. Verified: bogus filter exits 1 with a zero-ran error, a real filter reports 1, no filter reports 8. - scripts/event-flags-atomic-window.sh: masked_count stopped at the first restore, so a function that grew a second masked critical section would still report the expected window and pass. The awk scan now covers the whole function: exactly one complete section prints its width; multiple sections print multi: and a missing restore prints unterminated, both failing the callers' width checks legibly. Verified against synthetic disassembly for all four cases and against the real thumbv6m probe (4/4, exit 0). - AGENTS.md: the constructor-removal bullet still said the old API was public and that test modules need #![allow(deprecated)]; neither has been true since #25. The bullet now records the removal and forbids reintroducing a panicking acquisition path. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 10 +++++----- scripts/event-flags-atomic-window.sh | 16 ++++++++++++++-- scripts/loom.sh | 10 +++++++++- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 03106ea..59827e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -999,11 +999,11 @@ The project supports these targets (defined in `rust-toolchain.toml`): - `EventFlags`: Producer and Consumer handles are `Send + !Sync`; do not add peek operations or stream-trait implementations that blur destructive-take semantics -- `SeqRing` / `EventBuf`: the panicking `producer()` / `consumer()` are deprecated since 0.2.0 - and removed in 0.3.0. Library code must use `try_producer()` / `try_consumer()`; a panic is a - reset on the targets this crate exists for. Test modules carry `#![allow(deprecated)]` because - the old API is still public and still needs coverage — do **not** move that allow to the crate - root, which would silence the warning where it should bite +- `SeqRing` / `EventBuf`: the panicking `producer()` / `consumer()` were deprecated in 0.2.0 + and are **removed** in 0.3.0 (#25). Library, test, and doc code use `try_producer()` / + `try_consumer()` exclusively — the old methods and the `#![allow(deprecated)]` test + allowances that covered them no longer exist. Do not reintroduce a panicking acquisition + path; a panic is a reset on the targets this crate exists for - `SeqRing`: Producer and Consumer handles are `Send + !Sync` - `SeqRing`: the seqlock data race is **known and documented**, not an oversight. Do not "fix" it by weakening the sequence guards, and do not silence it by disabling Miri's race detector globally — the split-pass structure in `scripts/miri.sh` exists so everything else stays fully checked - `EventBuf`: race-free by construction — producer and consumer never touch the same slot. If a change makes them share one, that is a design break, not a tuning decision diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh index ab4abe5..9e49e16 100755 --- a/scripts/event-flags-atomic-window.sh +++ b/scripts/event-flags-atomic-window.sh @@ -99,6 +99,13 @@ extract_probe_object() { # Count instructions after the disable instruction through the architectural # restore/synchronization instruction. This is the maximum interval for which # a previously-enabled interrupt remains masked by the operation. +# +# The whole function is scanned, not just the first section: the claim is +# exactly ONE masked critical section of the expected width, so a function +# that grows a second disable/restore pair (or never restores) must fail the +# comparison. Prints the width for exactly one complete section; prints +# "multi:" for more than one and "unterminated" for a disable with no +# restore, both of which fail the callers' width checks with a readable value. masked_count() { file="$1" symbol="$2" @@ -108,12 +115,17 @@ masked_count() { $0 ~ "<" symbol ">:" { in_fn = 1; next } in_fn && /^$/ { in_fn = 0 } in_fn && $0 ~ /^[[:space:]]*[0-9a-f]+:/ { - if ($0 ~ start) { masked = 1; next } + if ($0 ~ start) { sections++; masked = 1; count = 0; next } if (masked) { count++ - if ($0 ~ finish) { print count; exit } + if ($0 ~ finish) { masked = 0; width = count } } } + END { + if (masked) { print "unterminated"; exit } + if (sections > 1) { print "multi:" sections; exit } + if (sections == 1) { print width } + } ' "$file" } diff --git a/scripts/loom.sh b/scripts/loom.sh index 6f08063..8999484 100755 --- a/scripts/loom.sh +++ b/scripts/loom.sh @@ -50,7 +50,15 @@ if [ "$#" -gt 0 ]; then fi if RUSTFLAGS='--cfg loom' cargo test --lib "$filter" "$@"; then - printf '\nAll Loom models verified.\n' + # "cargo test" exits 0 when a filter matches nothing, so a misspelled + # model name would otherwise earn the success banner after running zero + # tests. List the matches (the build is already warm) and require one. + matched="$(RUSTFLAGS='--cfg loom' cargo test --lib "$filter" -- --list 2>/dev/null | grep -c ': test$')" + if [ "${matched:-0}" -eq 0 ]; then + printf '\nerror: filter "%s" matched no Loom models -- zero tests ran, nothing was verified.\n' "$filter" >&2 + exit 1 + fi + printf '\nAll %s matching Loom models verified.\n' "$matched" else printf '\nLoom found a failing execution. The output above replays the\n' printf 'exact interleaving -- it is deterministic, so re-running reproduces it.\n' From 2a0b64b10022b668abf0d04bd6da7008af25c829 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 18:43:26 -0400 Subject: [PATCH 48/87] Bound both SeqRing headline guarantees at the sequence span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 Codex P2s on PR #39: sequence arithmetic is modular over the 2^32 - 1 nonzero span, which bounds two claims the record stated unqualified. A poll gap of exactly one whole span aliases to the nothing-new early return and reports zero reads and zero drops, so exact loss accounting holds only for gaps shorter than one span. The same modularity is the classic counter-width seqlock ABA: a consumer preempted mid-copy for one whole span passes both sequence checks against a rewritten slot, so the torn-copy discard argument holds for reads completing within a span. The module docs gain a dedicated known-limitation section with the X6-style disclosure set — span, silent-zero case, reachability arithmetic (~71.6 minutes of poll gap at a sustained 1 MHz push rate, ~5 days at 10 kHz), and the structural escape hatches (poll_*/ skip_to_latest resynchronize, latest does not; EventBuf has no wrap). The record's value statement, risk list, and both claim rows now carry the span qualifier and cite that section. cargo doc is clean; the unit suite passes. Co-Authored-By: Claude Fable 5 --- docs/records/seq-ring.md | 32 +++++++++++++++++++++++++------- src/seq_ring.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md index 7d64d53..7749cc6 100644 --- a/docs/records/seq-ring.md +++ b/docs/records/seq-ring.md @@ -19,11 +19,12 @@ newest (`latest`), and losses on the ordered path are counted. A consumer that falls more than `N` behind skips ahead and is told exactly how many items it lost (`PollStats::dropped`; the saturating `dropped_accum` is the origin of the crate's observable-loss convention). The exactness promise -belongs to the ordered `poll_*` paths only: `latest()` samples without -advancing the cursor or reporting a gap, and `skip_to_latest()` discards -the backlog *without* adding to the dropped counter — both are deliberate -sampling/fast-forward semantics, disclosed in §2 so they are chosen, not -discovered. It refuses to be: a delivery +belongs to the ordered `poll_*` paths only, and is exact for inter-poll +gaps shorter than one sequence span (`2^32 − 1` publications; §2): +`latest()` samples without advancing the cursor or reporting a gap, and +`skip_to_latest()` discards the backlog *without* adding to the dropped +counter — both are deliberate sampling/fast-forward semantics, disclosed +in §2 so they are chosen, not discovered. It refuses to be: a delivery guarantee (`EventBuf` is), a race-free primitive (see §2 — this is the crate's one deliberate formal-soundness trade), or a latest-value-only channel (`LatestBuf` is, with a race-free ownership argument). @@ -49,6 +50,23 @@ channel (`LatestBuf` is, with a race-free ownership argument). the lag alone requires (bounded, `N`-dependent, documented in the module docs). Realignment was analysed and left referential (C2, in the planning record) — reopen it with a real adopter, not by default. +- **Whole-span aliasing bounds both headline guarantees — known + limitation.** Sequence arithmetic is modular over the `2^32 − 1` + nonzero span. A poll gap of exactly one whole span (or any multiple) + aliases to "nothing new" and reports zero reads *and zero drops* — + loss accounting is exact only for gaps shorter than one span, and + silence after an extreme stall is not evidence that nothing was lost. + The same modularity bounds the seqlock re-check: a consumer preempted + mid-copy for one whole span of publications passes both sequence + checks against a rewritten slot (counter-width ABA), so the torn-copy + discard argument holds for reads that complete within a span. One + span is ~4.29 billion publications (~71.6 minutes at a sustained + 1 MHz push rate; ~5 days at 10 kHz). The module docs carry the full + disclosure — span, silent-zero case, reachability arithmetic — and + the structural escape hatches: bound the interval between ordered + polls (`poll_*`/`skip_to_latest` resynchronize; the non-advancing + `latest` does not), bound mid-read preemption, or use `EventBuf`, + which has no sequence wrap. - **Loss is designed behaviour.** Overwrite is the overload policy; the counters report it, nothing prevents it. Consumers that must see every event belong on `EventBuf`. @@ -68,10 +86,10 @@ channel (`LatestBuf` is, with a race-free ownership argument). | Claim | Evidence | Status | |---|---|---| -| No torn value ever materialises as `T` (racy copies discarded before use) | Volatile access + `MaybeUninit` holding + re-check discipline; Loom models; the dedicated seqlock Miri pass | Proven within the documented deviation | +| No torn value ever materialises as `T` (racy copies discarded before use) | Volatile access + `MaybeUninit` holding + re-check discipline; Loom models; the dedicated seqlock Miri pass | Proven within the documented deviation, for reads completing within one sequence span (counter-width ABA bound, §2) | | Fence placement is necessary (Release before value write; Acquire before re-check) | Module-doc argument; Loom; ordering discipline enforced by AGENTS' rerun-after-atomic-change rule | Proven | | Recovery cost is constant w.r.t. lag | 0.2.0 measurement: a consumer 2,000 sequences behind recovers in the same 115 instructions as one 16 behind | Measured | -| Loss accounting is exact and saturating (`read + dropped` accounts for every sequence; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned | +| Loss accounting is exact and saturating (`read + dropped` accounts for every sequence within one span; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned, for inter-poll gaps shorter than one sequence span (§2) | | Const-constructs into `.bss` — no flash image, no startup copy | By construction (`const fn new`); the 0.2.0 codesize probe demonstrates the crate's `.bss` discipline on an `EventBuf` static (268 B) — **no dedicated SeqRing static is probed or baseline-gated**, and SeqRing's RAM adds `N` per-slot sequence atomics over the payload array, `size_of`-computable per shape | By construction; probe coverage is EventBuf's | ## 4. The record diff --git a/src/seq_ring.rs b/src/seq_ring.rs index afaa052..3519d96 100644 --- a/src/seq_ring.rs +++ b/src/seq_ring.rs @@ -112,6 +112,36 @@ //! if a burst of drops at a predictable interval would matter to you. If no loss is acceptable at //! all, [`crate::EventBuf`] applies backpressure instead and has no wrap boundary of this kind. //! +//! # Known limitation: whole-span sequence aliasing +//! +//! Sequence arithmetic is modular. `push` skips the reserved value `0`, so the counter cycles +//! through `2^32 - 1` distinct nonzero values, and every comparison and distance the consumer +//! computes is exact only up to that span. Two consequences follow — both inherent to any +//! fixed-width seqlock at its counter width: +//! +//! - **A whole-span poll gap reports nothing.** If exactly `2^32 - 1` publications (or any whole +//! multiple of that) land between two ordered polls, the published sequence returns to the +//! consumer's resume point and `poll_one`/`poll_up_to` take their nothing-new early return: +//! zero reads and zero drops. Longer gaps report only the remainder modulo the span. The +//! `read + dropped` conservation promise is therefore exact for inter-poll gaps *shorter than +//! one span*, and silence after an extreme stall is not evidence that nothing was lost. +//! - **A whole-span mid-read stall defeats the sequence re-check.** The torn-copy guard compares +//! the slot's sequence before and after the copy. A consumer preempted *inside* that copy for +//! exactly one whole span of publications sees the same sequence value on both sides of a slot +//! that was rewritten in between — counter-width ABA — and a mixed copy would be accepted as +//! `T`. The discard argument for the documented deviation is therefore bounded: it holds for +//! any read that completes in less than one full span of producer publications. +//! +//! Reachability arithmetic, so the bound is a decision rather than a surprise: one span is +//! ~4.29 billion publications. At a sustained 1 MHz push rate a poll gap must exceed ~71.6 +//! minutes — and the mid-read stall must hold the consumer *between two instructions of one +//! copy* for that long — before either case is reachable; at 10 kHz it is ~5 days. The escape +//! hatch is structural: bound the interval between ordered polls (any `poll_*` or +//! [`Consumer::skip_to_latest`] resynchronizes the resume point; the non-advancing +//! [`Consumer::latest`] does not) below one span, and bound consumer preemption during a single +//! read to less than a span of publications. If neither bound can be stated for your system, +//! [`crate::EventBuf`] has no sequence wrap of any kind. +//! //! # Notes //! - `T` is `Copy` to allow returning values by copy without allocation. //! - The `&T` passed to hooks is a reference to a local copy made during the read. From f093364cfcc6636f6c222fb9a775692725e925a0 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:03:07 -0400 Subject: [PATCH 49/87] Carry the span bound to every earlier claim site and fix two precision gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 Codex P2s on PR #39. The overview bullet, the memory-ordering discard sentence, and the deviation section's raced-copy bullet all promised unconditional discard/accounting before the reader reaches the whole-span section — each now carries or links the span bound in place. The resynchronization escape hatch claimed any poll_* advances the resume point, but poll_up_to(0) returns before touching it — the advice now names poll_one / nonzero-budget poll_up_to (either runs the lag-recovery jump to within N of newest) and skip_to_latest, and excludes the zero-budget call. The EventBuf record's .bss row claimed gated status for 11 targets while baseline.tsv gates exactly the eight upstream ones — the status now splits measured (11) from gated (8, Xtensa opt-in and deliberately ungated). Co-Authored-By: Claude Fable 5 --- docs/records/event-buf.md | 2 +- docs/records/seq-ring.md | 8 +++++--- src/seq_ring.rs | 25 ++++++++++++++++--------- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/records/event-buf.md b/docs/records/event-buf.md index 8b37801..9add15e 100644 --- a/docs/records/event-buf.md +++ b/docs/records/event-buf.md @@ -52,7 +52,7 @@ it outright. It refuses to be: lossy (that is `SeqRing`), freshness-first | No silent loss — every failed delivery is observable at the call site | `push` returns `Err(val)`; the type has no overwrite path; unit + threaded stress incl. lossless-and-ordered Loom model | Proven | | Bounded operations, no CAS retry loop | Lamport single-owner cursors: one Relaxed load + one Acquire load + one Release store per side | Proven by construction; cycle rows measured in 0.2.0 | | `len` reads a consistent cursor pair | The bracketed `tail`/`head`/`tail` sampling documented in the module docs | Pinned | -| Const-constructs into `.bss` | 0.2.0 measurement across 11 targets; codesize baseline gated in CI | Measured, gated | +| Const-constructs into `.bss` | 0.2.0 measurement across 11 targets; codesize baseline gated in CI | Measured on 11 targets; regression-gated on the eight upstream baseline targets only — the three Xtensa rows are opt-in (esp-rs toolchain) and deliberately never baseline-gated | | Queued block transport (D3) adds no new concurrency contract | The block layer adds no atomics; existing EventBuf clauses apply with `T = Block<…>`; publication-cost matrix at `bc54a9a` | Measured | ## 4. The record diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md index 7749cc6..2239714 100644 --- a/docs/records/seq-ring.md +++ b/docs/records/seq-ring.md @@ -64,9 +64,11 @@ channel (`LatestBuf` is, with a race-free ownership argument). 1 MHz push rate; ~5 days at 10 kHz). The module docs carry the full disclosure — span, silent-zero case, reachability arithmetic — and the structural escape hatches: bound the interval between ordered - polls (`poll_*`/`skip_to_latest` resynchronize; the non-advancing - `latest` does not), bound mid-read preemption, or use `EventBuf`, - which has no sequence wrap. + polls (`poll_one` or a nonzero-budget `poll_up_to` resynchronizes, + as does `skip_to_latest`; `poll_up_to(0, …)` returns before touching + the resume point and the non-advancing `latest` never moves it), + bound mid-read preemption, or use `EventBuf`, which has no sequence + wrap. - **Loss is designed behaviour.** Overwrite is the overload policy; the counters report it, nothing prevents it. Consumers that must see every event belong on `EventBuf`. diff --git a/src/seq_ring.rs b/src/seq_ring.rs index 3519d96..d6ef6d9 100644 --- a/src/seq_ring.rs +++ b/src/seq_ring.rs @@ -6,8 +6,9 @@ //! - Sequence numbers are monotonically increasing `u32`; `0` is reserved to mean "empty". //! - The consumer can drain in-order (`poll_one`/`poll_up_to`) or sample the newest value (`latest`). //! - If the consumer lags by more than `N`, it skips ahead and reports the number of dropped items. -//! The one exception is the sequence wrap, which can drop a few extra entries depending on `N` — -//! see "Known limitation: extra drops at the sequence wrap" below. +//! Two boundaries qualify that accounting: the sequence wrap can drop a few extra entries +//! depending on `N`, and a gap of one whole sequence span aliases to "nothing new" and reports +//! zero — see the two "Known limitation" sections below. //! //! # Memory ordering //! The producer invalidates the per-slot sequence, writes the value, publishes the new per-slot @@ -24,7 +25,9 @@ //! Slot values are read and written with volatile accesses, and the consumer holds its copy as //! `MaybeUninit` until the re-check passes. A copy that raced with an overwrite is therefore //! discarded as raw bytes and never materialises as a `T` that could violate the type's validity -//! invariants. +//! invariants — for reads that complete within one sequence span; the re-check compares sequence +//! values, so it carries the counter-width ABA bound stated under "Known limitation: whole-span +//! sequence aliasing" below. //! //! # Known deviation: the seqlock data race //! @@ -61,9 +64,11 @@ //! this ring from two threads, you will get a UB report pointing into this crate. That is the //! deviation, not a new bug. `scripts/miri.*` shows the split-pass approach: full checking //! everywhere else, race detector off for this ring alone. -//! - **A raced copy is never returned.** The double sequence check discards it, and it is held as -//! `MaybeUninit` until validated, so it cannot even briefly exist as a `T` that violates the -//! type's validity invariants. +//! - **A raced copy is never returned** — within the span bound. The double sequence check +//! discards it, and it is held as `MaybeUninit` until validated, so it cannot even briefly +//! exist as a `T` that violates the type's validity invariants. The check compares sequence +//! values, so a read preempted for one whole span of publications can pass both checks against +//! a rewritten slot; see "Known limitation: whole-span sequence aliasing". //! //! ## If that is not acceptable //! - [`crate::EventBuf`] is race-free by construction — its producer and consumer never touch the @@ -136,9 +141,11 @@ //! ~4.29 billion publications. At a sustained 1 MHz push rate a poll gap must exceed ~71.6 //! minutes — and the mid-read stall must hold the consumer *between two instructions of one //! copy* for that long — before either case is reachable; at 10 kHz it is ~5 days. The escape -//! hatch is structural: bound the interval between ordered polls (any `poll_*` or -//! [`Consumer::skip_to_latest`] resynchronizes the resume point; the non-advancing -//! [`Consumer::latest`] does not) below one span, and bound consumer preemption during a single +//! hatch is structural: bound the interval between ordered polls (`poll_one` or a +//! nonzero-budget `poll_up_to` resynchronizes the resume point — even one item drained runs the +//! lag-recovery jump to within `N` of newest — as does [`Consumer::skip_to_latest`]; +//! `poll_up_to(0, …)` returns before touching the resume point, and the non-advancing +//! [`Consumer::latest`] never moves it) below one span, and bound consumer preemption during a single //! read to less than a span of publications. If neither bound can be stated for your system, //! [`crate::EventBuf`] has no sequence wrap of any kind. //! From 520a6cad0d20aeb99112d0ece1f0ab6445fca058 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:03:43 -0400 Subject: [PATCH 50/87] Purge the impossible DMA-target advice from the proposal and changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 Codex P2 on PR #34: the proposal's promotion obligation and the changelog's P-closure entry still instructed DMA integrations to make the builder the DMA target, which the public API cannot express. Both now state the implementable choices — budget both copies or publish from task context — and route direct-to-granted-slot filling to cycle decision S's registered reopening condition, matching the module docs, README, and engineering record corrected in rounds 3-4. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +++- docs/proposals/block-buf.md | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 590034c..360b837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,9 @@ All notable changes to this project will be documented in this file. foundation is **Copy composition** for the supported shapes — no library-wide threshold is claimed; the per-shape measured rows are the budget statement, the nine-shape matrix rides into the release baselines at promotion, and the docs owe integrators the double-copy - hazard guidance (make the builder the DMA target, or publish from task context). **S**: + hazard guidance (the builder's storage is deliberately private — budget both copies, or + publish from task context; direct-to-granted-slot filling is decision S's registered + reopening condition). **S**: SlotPool is **deferred**, not rejected — full evaluation evidence banked on its branch, draft PR closed, and an adopter-gated reopening trigger registered (a measured budget breach, a direct-to-granted-slot requirement, or a standalone zero-copy adopter). Every diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index 70a78e3..4551bd3 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -303,8 +303,11 @@ no block-specific evidence; Loom remains required for the selected transport. at promotion via the deliberate, reviewed `--bless` (mechanics rule 8); the pinned instruction regions stay committed. One documentation obligation rides to promotion with them: the **double-copy hazard** - guidance for DMA integrations — make the builder the DMA target, or - publish from task context — stated in the block-payload docs, not left - for integrators to discover. **Landed:** the `src/block.rs` module + guidance for DMA integrations — the builder cannot be the DMA target + (its storage is deliberately private, with no address or writable-slice + API), so the choices are budgeting both copies or publishing from task + context, with a direct-to-granted-slot fill API routed to decision S's + registered reopening condition — stated in the block-payload docs, not + left for integrators to discover. **Landed:** the `src/block.rs` module docs now carry all four bound disclosures (per-shape RAM, small-`N` inversion, rejection cost, double-copy DMA guidance). From 0b33b11090c722a1a16bf12f8fc6586cde6b0338 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:15:59 -0400 Subject: [PATCH 51/87] Measure the saturated sentinel arm and finish the RMW claim sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 Codex P2s on PR #33: - The QEMU cycles probe measured only the below-MAX increment path, so the saturated arm's cost was a source-review claim. The arm is unreachable through the public API in bounded time (u32::MAX increments under a per-instruction trace), so a hidden _cycles-probe feature now exposes a #[doc(hidden)] seeding constructor — the same pattern the Loom models use via with_count_for_model — and the probe gains a 'cs increment saturated' region: 9 retired instructions on Cortex-M3 (QEMU 10.0.11 reference image), vs 8 for the hot path. The region lives in its own never-inlined frame with the signal reference escaped through black_box: sharing the hot-path frame measurably perturbed 'cs increment' to 10, and the isolated layout leaves every pre-existing row byte-identical (full-output diff against the pristine tip shows only the added row). The stale-MAX third arm needs a racing consumer a deterministic single-hart trace cannot express; it is documented as the saturated arm plus one fetch_add by construction. - AGENTS.md's memory-ordering strategy and safety guardrails still specified compare_exchange(MAX, MAX) with a contention retry — the algorithm review rejected. Both now describe the no-op fetch_or(0) re-read and forbid reintroducing a compare-exchange. - The proposal's code-size table still carried the interim-CAS bytes on four rows; corrected to the blessed baseline values (44/44/64/26), and the contract's stale 46-76 B range corrected to 26-64 B. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 24 +++++++++++++--------- CHANGELOG.md | 6 ++++++ Cargo.toml | 5 +++++ docs/proposals/counted-signal-contract.md | 2 +- docs/proposals/counted-signal.md | 25 +++++++++++++++-------- docs/records/counted-signal.md | 10 +++++---- scripts/cycles/Cargo.toml | 4 +++- scripts/cycles/src/main.rs | 24 ++++++++++++++++++++++ src/counted_signal.rs | 19 +++++++++++++++++ 9 files changed, 95 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e5fda35..dd39bc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,11 +187,14 @@ The `SeqRing` implementation uses careful atomic ordering for thread safety: - The semantic contract and stable clause IDs live in `docs/proposals/counted-signal-contract.md`. - The sole producer loads the counter (Relaxed). Below `u32::MAX` it - `fetch_add`s once (Relaxed). Observed `u32::MAX` is maybe-stale: a - `compare_exchange(MAX, MAX)` success is a saturated no-op; failure means a - completed take reset the epoch and the producer `fetch_add`s once into it. - Under sole-producer ownership that is at most one contention retry and the - RMW cannot wrap. + `fetch_add`s once (Relaxed). Observed `u32::MAX` is maybe-stale: the + producer re-reads through a no-op `fetch_or(0)` RMW — an RMW, unlike a load + or a failed compare-exchange, observes the latest value in modification + order. A `MAX` re-read confirms saturation; anything else means a completed + take reset the epoch and the producer `fetch_add`s once into it. No + compare-exchange, no retry: on RISC-V the sentinel is a single `amoor.w`, + where a strong CAS lowers to an unbounded LR/SC loop. Under sole-producer + ownership the follow-up `fetch_add` cannot wrap. - The consumer performs a Relaxed `swap(0)`. - Relaxed is sufficient because there is no payload publication; the atomic's modification order alone partitions increments between take epochs. @@ -925,11 +928,12 @@ two pin the CountedSignal handles' `!Sync` contract. - `CountedSignal` handles are `Send + !Sync`; the producer's `!Sync` property is part of the saturation proof, not merely API uniformity - `CountedSignal`: `increment` uses `fetch_add` below `MAX` and confirms the - sentinel with `compare_exchange(MAX, MAX)` (or one follow-up `fetch_add` after - a take reset) — never a plain load-and-skip on `MAX`, which drops post-take - occurrences under Relaxed observation (T3/A1) -- `CountedSignal`: under sole producer, at most one contention retry from the - consumer's `swap(0)`; the counter never wraps + sentinel with a no-op `fetch_or(0)` re-read (plus one follow-up `fetch_add` + after a take reset) — never a plain load-and-skip on `MAX`, which drops + post-take occurrences under Relaxed observation (T3/A1), and never a + compare-exchange, which lowers to an unbounded LR/SC retry loop on RISC-V +- `CountedSignal`: under sole producer the instruction sequence is fixed — + no source-level operation retries; the counter never wraps - `CountedSignal`: `take_count` is a single Relaxed `swap(0)` that partitions every increment into exactly one take epoch - No panics in hot paths. All three `new()` reject `N == 0` with a **const** diff --git a/CHANGELOG.md b/CHANGELOG.md index 46327cb..0dd1034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ All notable changes to this project will be documented in this file. litmus: `counted_signal_post_take_increment_observes_reset_epoch`. ### Documentation - CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned Cortex-M3 rows (confirmed 8 / 7 after the sentinel-RMW fix), and the H closure record. +- CountedSignal saturated sentinel arm is now its own measured QEMU region + (`cs increment saturated`, 9 retired instructions on Cortex-M3), seeded via a + hidden `_cycles-probe` feature and a `#[doc(hidden)]` constructor — the arm is + unreachable through the public API in bounded time, and was previously a + source-review claim only. Hot-path rows are byte-identical with the region + isolated in its own frame. - CountedSignal Cortex-M3 cycle rows re-confirmed at 8 / 7 after the sentinel-RMW fix (`./scripts/verify.sh cycles`, QEMU 10.0.11); pending-remeasure markers cleared. - CountedSignal contract B1: fixed instruction sequence with a no-op RMW diff --git a/Cargo.toml b/Cargo.toml index 04222ba..126cf60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,11 @@ portable-atomic = { version = "1.13", optional = true, default-features = false, # crate. Test combinations individually — `scripts/ci.sh` does. [features] default = [] +# Internal, underscore-prefixed, and not part of the public contract: lets the +# QEMU cycle probe (scripts/cycles) construct a saturated CountedSignal so the +# sentinel arm of `increment` is a measured region rather than a source-review +# claim. Enables one #[doc(hidden)] constructor; nothing else. +_cycles-probe = [] portable-atomic = ["dep:portable-atomic"] portable-atomic-unsafe-assume-single-core = [ "portable-atomic", diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index 9ed34bb..28a98b2 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -139,7 +139,7 @@ a separate maintainer decision on issue #26. | I2–I3, T2, A2–A3 | `saturates_instead_of_wrapping`; Loom's `counted_signal_saturation_boundary_is_linearizable` model | | T1–T3, A1 | Loom's `counted_signal_take_partitions_increments` model; threaded take stress | | T3, A1 (post-take / stale MAX) | Loom's `counted_signal_post_take_increment_observes_reset_epoch` model (seeded at `MAX`; Relaxed gate only) | -| B1–B2 | Source review (fixed sequence under H1 — no compare-exchange, no retry); eight-target gated code-size rows blessed post-fix (46–76 B `increment`); Cortex-M3 cycles re-measured after the sentinel-RMW fix at 8 / 7 (`./scripts/verify.sh cycles`, QEMU 10.0.11) | +| B1–B2 | Source review (fixed sequence under H1 — no compare-exchange, no retry); eight-target gated code-size rows blessed post-fix (26–64 B `increment`); Cortex-M3 cycles measured at 8 (hot path) / 9 (saturated sentinel arm, seeded via the hidden `_cycles-probe` feature) / 7 (take) — `./scripts/verify.sh cycles`, QEMU 10.0.11 | | B3 | Source review plus normal, Miri, Loom, and embedded-target executions of both hot paths | | H1, H3 | `handles_are_exclusive_and_reusable_after_drop`, including state continuation after reacquisition | | H2 | `handles_are_send`; producer and consumer compile-fail doctests pin `!Sync` | diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index 8f259c3..089c575 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -146,10 +146,10 @@ The isolated release-mode code-size rows on the pinned Rust 1.92.0 toolchain | Target family | `increment` | `take_count` | |---|---:|---:| | Cortex-M0 (`thumbv6m`) | 46 B | 24 B | -| Cortex-M23 (`thumbv8m.base`) | 56 B | 22 B | -| Cortex-M3/M4/M33 | 54 B | 22 B | -| Armv7-R / Armv7-A | 76 B | 28 B | -| RV32IMAC | 34 B | 8 B | +| Cortex-M23 (`thumbv8m.base`) | 44 B | 22 B | +| Cortex-M3/M4/M33 | 44 B | 22 B | +| Armv7-R / Armv7-A | 64 B | 28 B | +| RV32IMAC | 26 B | 8 B | The M0/M23 rows use the existing portable-atomic single-core probe backend. Versus the pre-fix load-and-skip path this remains a deliberate growth on @@ -161,14 +161,23 @@ single `amoor.w` with no `lr.w`/`sc.w` pair in the disassembly. Re-measured in the pinned reference environment via `./scripts/verify.sh cycles` after the sentinel fix. The probe brackets the common below-`MAX` path -(`load` + `fetch_add`) and a `swap(0)` take — the sentinel re-read only runs when -the producer observes `MAX`, so these rows stay the hot-path cost: +(`load` + `fetch_add`), a `swap(0)` take, and — via the hidden `_cycles-probe` +seeding feature, since `u32::MAX` increments cannot be replayed under a +per-instruction trace — the saturated sentinel arm (`load` observing `MAX`, +no-op `fetch_or(0)` confirm, return): -| Cortex-M3 hot path | Retired guest instructions | +| Cortex-M3 path | Retired guest instructions | |---|---:| -| `increment` | 8 | +| `increment` (below `MAX`, the hot path) | 8 | +| `increment` (saturated sentinel arm) | 9 | | `take_count` | 7 | +The third arm — a stale `MAX` re-read below `MAX` after a completed take — +needs a racing consumer a single-hart deterministic trace cannot express; it +is the saturated arm plus one `fetch_add` by construction and is bounded by +the gated whole-function code size. All three measured rows are uncontended +single-pass counts. + Environment stamp: rustc 1.92.0 (`ded5c06cf`), LLVM 21.1.3, QEMU 10.0.11 (Debian trixie), release `opt-level = "z"` with LTO. The runner subtracts its marker overhead and uses one guest instruction per translation block with diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index 98bce47..616b0c8 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -7,7 +7,8 @@ - **Normative sources:** [contract](../proposals/counted-signal-contract.md) (clause IDs cited below) · [proposal](../proposals/counted-signal.md) · measurements inline in proposal §3.1 (eight-target code size; pinned - Cortex-M3 cycles via `./scripts/verify.sh cycles`, confirmed 8 / 7 post-fix). + Cortex-M3 cycles via `./scripts/verify.sh cycles`, confirmed 8 / 7 + post-fix; saturated sentinel arm measured at 9). ## 1. Value statement @@ -74,7 +75,7 @@ IDs in parentheses; the clauses are the normative statements. | Saturates at `u32::MAX`, never wraps; sticky until take (I2–I3, T2, A2–A3) | `saturates_instead_of_wrapping`; Loom `counted_signal_saturation_boundary_is_linearizable` | Proven | | Concurrent increment belongs to exactly one take interval (T1–T3, A1) | Loom `counted_signal_take_partitions_increments`; threaded take stress | Proven | | Post-take increment after saturated take is not dropped by a stale MAX observe (T3, A1) | Loom `counted_signal_post_take_increment_observes_reset_epoch` (Relaxed gate only) | Proven | -| Wait-free bounded hot paths: load + `fetch_add`; sentinel no-op RMW re-read + ≤1 follow-up `fetch_add`; `swap(0)` take (B1–B2) | Source review; riscv32imac disassembly shows lw/amoor.w/amoadd.w with **no lr.w/sc.w**; eight-target code size re-blessed post-RMW; Cortex-M3 cycles 8 / 7 (common path untouched by the sentinel change) | Measured | +| Wait-free bounded hot paths: load + `fetch_add`; sentinel no-op RMW re-read + ≤1 follow-up `fetch_add`; `swap(0)` take (B1–B2) | Source review; riscv32imac disassembly shows lw/amoor.w/amoadd.w with **no lr.w/sc.w**; eight-target code size re-blessed post-RMW; Cortex-M3 cycles 8 (hot) / 9 (saturated arm, probe-seeded region) / 7 (take); the stale-MAX third arm is the saturated arm + one `fetch_add` by construction | Measured | | No panic reachable from hot paths (B3) | Source review; normal, Miri, Loom, and embedded-target executions | Proven | | Sole-role exclusive handles; reacquisition continues state (H1, H3) | `handles_are_exclusive_and_reusable_after_drop` | Proven | | Handles are `Send + !Sync` (H2) | `handles_are_send`; compile-fail doctests pin `!Sync` on both roles | Pinned | @@ -85,7 +86,8 @@ Full CI for the lane (`0a22ada` admission package; `f26d4c3` H finalization): complete pinned `./scripts/verify.sh` matrix with zero skips — unit tests, 11 doctests, 5 compile-fail, coverage, Miri host and proxy targets, Loom models, eight-target code size, embedded -checks, QEMU cycles. Cycles alone re-confirmed post-fix at 8 / 7; +checks, QEMU cycles. Cycles re-confirmed post-fix at 8 / 7, with the +saturated sentinel arm added as its own probe-seeded region at 9; full matrix not re-run for this cycles-doc follow-up. ## 4. The record @@ -136,7 +138,7 @@ Contract clause IDs (I/T/A/B/H/X) are load-bearing for tests and models **Where the numbers live:** proposal §3.1 (eight-target `increment` / `take_count` code-size table; pinned Cortex-M3 instruction regions under -rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11 — confirmed 8 / 7 +rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11 — confirmed 8 / 9 / 7 after the sentinel-RMW fix); contract §9 evidence map; tracking: issue #30, PR #33; completion commits `0a22ada` (clause-numbered contract + pinned costs) and diff --git a/scripts/cycles/Cargo.toml b/scripts/cycles/Cargo.toml index 56e76cb..a07412a 100644 --- a/scripts/cycles/Cargo.toml +++ b/scripts/cycles/Cargo.toml @@ -10,7 +10,9 @@ edition = "2024" publish = false [dependencies] -ph-eventing = { path = "../.." } +# _cycles-probe: hidden probe-only feature exposing the seeding constructor so +# the saturated `increment` arm is a measured region (see src/main.rs). +ph-eventing = { path = "../..", features = ["_cycles-probe"] } cortex-m = "0.7" cortex-m-rt = "0.7" cortex-m-semihosting = "0.5" diff --git a/scripts/cycles/src/main.rs b/scripts/cycles/src/main.rs index 59f0bdd..268d9c9 100644 --- a/scripts/cycles/src/main.rs +++ b/scripts/cycles/src/main.rs @@ -86,6 +86,7 @@ markers! { // CountedSignal -- payload-free SPSC 20 => m_cs_increment, 21 => m_cs_take_count, + 22 => m_cs_increment_saturated, } fn counted_signal_costs() { @@ -102,6 +103,28 @@ fn counted_signal_costs() { m_end(); } +/// The sentinel arm only runs when the load observes `u32::MAX`, which is +/// unreachable through the public API in bounded time. The hidden +/// `_cycles-probe` feature seeds it directly so the saturated worst case is a +/// measured region, not a source-review claim. The third arm (stale `MAX` +/// re-read below `MAX` after a take) needs a racing consumer and is bounded by +/// this region plus one `fetch_add` by construction. +/// +/// Kept in its own never-inlined frame with the signal reference escaped +/// through `black_box`: sharing `counted_signal_costs`'s frame was measured to +/// perturb the hot-path region (`cs increment` 8 -> 10) by changing its +/// codegen, and an unescaped local signal would let the optimiser fold the +/// seeded `MAX` into the branch being measured. +#[inline(never)] +fn counted_signal_saturated_costs() { + let saturated = CountedSignal::with_count_for_probe(u32::MAX); + let tx = black_box(&saturated).try_producer().expect("producer"); + + m_cs_increment_saturated(); + tx.increment(); + m_end(); +} + fn event_buf_costs() { let buf = EventBuf::::new(); let tx = buf.try_producer().expect("producer"); @@ -225,6 +248,7 @@ fn main() -> ! { seq_ring_costs(); ring_buf_costs(); counted_signal_costs(); + counted_signal_saturated_costs(); debug::exit(debug::EXIT_SUCCESS); loop {} diff --git a/src/counted_signal.rs b/src/counted_signal.rs index d2ce067..546baa7 100644 --- a/src/counted_signal.rs +++ b/src/counted_signal.rs @@ -73,6 +73,25 @@ impl CountedSignal { } } + /// Probe-only seeding constructor. Not part of the public contract. + /// + /// The saturated `increment` arm is unreachable through the public API in + /// bounded time — it needs the counter at `u32::MAX`, which is + /// `u32::MAX` increments away — yet its cost is a measured claim + /// (contract B1/B2). The QEMU cycle probe enables the hidden + /// `_cycles-probe` feature to construct a saturated signal directly, the + /// same way the Loom models seed epochs via `with_count_for_model`. + #[cfg(feature = "_cycles-probe")] + #[doc(hidden)] + #[must_use] + pub const fn with_count_for_probe(count: u32) -> Self { + Self { + count: AtomicU32::new(count), + producer_taken: AtomicBool::new(false), + consumer_taken: AtomicBool::new(false), + } + } + /// Try to acquire the sole producer handle. /// /// Returns `None` while another producer handle is active. From c21836bc34332dfc3e40bdf4f5f50829751f7b61 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:16:59 -0400 Subject: [PATCH 52/87] Make the slot-reuse Loom model require the reuse it exists to check Round-5 Codex P2 on PR #35: the bounded three-poll handshake loops could exit without publishing generations 2 or 3, so executions that exercised no consumer->exchange->producer slot travel still passed. The handshakes now spin with yields until the phase advances, every take retries until the publication arrives through the exchange (the Relaxed phase marker deliberately supplies no visibility), and each observation asserts exact generation, payload, and skipped == 0. Generation 3 is taken after the joins through a reacquired handle, pinning the channel-resident continuation on the same path. Every terminating execution now completes the full reuse cycle. Mutation-verified: a wrong expected generation on the final take fails immediately; all five LatestBuf models pass. Co-Authored-By: Claude Fable 5 --- src/loom_tests.rs | 68 ++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 66db9af..24d1943 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -64,6 +64,12 @@ fn latest_buf_returns_only_complete_publications() { /// Forces a slot to travel producer -> consumer -> exchange -> producer. /// Relaxed phase markers influence scheduling without supplying the /// happens-before edges that the exchange itself must provide. +/// +/// The handshakes spin (with yields) instead of giving up after a bounded +/// number of polls: every terminating execution therefore completes the +/// full reuse cycle — publish 1, take 1, publish 2, take 2, publish 3 — +/// and the final take is asserted after the joins, so the model cannot +/// pass without exercising the reuse path it exists to check. #[test] fn latest_buf_reused_slot_keeps_exclusive_ownership() { loom::model(|| { @@ -76,50 +82,58 @@ fn latest_buf_reused_slot_keeps_exclusive_ownership() { let producer = producer_channel.try_producer().unwrap(); let _ = producer.publish([1, 1]); producer_phase.store(1, Ordering::Relaxed); - for _ in 0..3 { - if producer_phase.load(Ordering::Relaxed) >= 2 { - let _ = producer.publish([2, 2]); - producer_phase.store(3, Ordering::Relaxed); - break; - } + while producer_phase.load(Ordering::Relaxed) < 2 { thread::yield_now(); } - for _ in 0..3 { - if producer_phase.load(Ordering::Relaxed) >= 4 { - let _ = producer.publish([3, 3]); - producer_phase.store(5, Ordering::Relaxed); - break; - } + let _ = producer.publish([2, 2]); + producer_phase.store(3, Ordering::Relaxed); + while producer_phase.load(Ordering::Relaxed) < 4 { thread::yield_now(); } + let _ = producer.publish([3, 3]); }); + let consumer_channel = Arc::clone(&channel); + let consumer_phase = Arc::clone(&phase); let consumer = thread::spawn(move || { - let consumer = channel.try_consumer().unwrap(); - for _ in 0..3 { - if phase.load(Ordering::Relaxed) >= 1 { - if let Some(item) = consumer.take_latest() { - assert_eq!(item.value, [item.generation; 2]); - phase.store(2, Ordering::Relaxed); - } + let consumer = consumer_channel.try_consumer().unwrap(); + // The phase marker orders scheduling only; visibility of the + // publication itself must arrive through the exchange, so the + // take retries until it does. + let mut take_spinning = |expected: u32| loop { + if let Some(item) = consumer.take_latest() { + assert_eq!(item.generation, expected); + assert_eq!(item.value, [expected; 2]); + assert_eq!(item.skipped, 0); break; } thread::yield_now(); + }; + while consumer_phase.load(Ordering::Relaxed) < 1 { + thread::yield_now(); } - for _ in 0..3 { - if phase.load(Ordering::Relaxed) >= 3 { - if let Some(item) = consumer.take_latest() { - assert_eq!(item.value, [item.generation; 2]); - phase.store(4, Ordering::Relaxed); - } - break; - } + take_spinning(1); + consumer_phase.store(2, Ordering::Relaxed); + while consumer_phase.load(Ordering::Relaxed) < 3 { thread::yield_now(); } + take_spinning(2); + consumer_phase.store(4, Ordering::Relaxed); }); producer.join().unwrap(); consumer.join().unwrap(); + + // Generation 3 was published after the in-thread consumer handle + // dropped; a reacquired handle must find it pending with exact + // accounting (channel-resident continuation, A.3). + let consumer = channel.try_consumer().unwrap(); + let item = consumer + .take_latest() + .expect("generation 3 pending after the producer joined"); + assert_eq!(item.generation, 3); + assert_eq!(item.value, [3, 3]); + assert_eq!(item.skipped, 0); }); } From 91aef80a52a94ab24c5d1df00b53c61fa04e9af3 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:18:57 -0400 Subject: [PATCH 53/87] Scope the loop-free claim to source level; count what the harness actually ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 Codex P2s on PR #36: - raise()'s rustdoc claimed the operation 'never loops, waits' — untrue at the ISA level on exclusive-monitor ARM, where the single fetch_or is an LDREX/STREX pair that repeats when the reservation is lost to an intervening interrupt or the concurrent take. The claim is now scoped honestly: exactly one source-level atomic RMW with no algorithmic retry, realised per-ISA as a lone amoor.w (RISC-V), the gated PRIMASK critical section (Cortex-M0), or a contention-bounded LDREX/STREX pair (v7-M+), with the uncontended cost as the measured row. Mirrors the wording precedent set by CountedSignal B1. - The round-4 loom.sh guard re-listed matches without "$@", so './scripts/loom.sh -- --ignored' ran zero models and still printed the success banner. The script now captures the run (not a pipe — that would report tee's status, not cargo's) and requires a nonzero passed-count from the harness's own 'test result:' summary, which reflects every selector the run actually honoured. Verified: bogus filter and '-- --ignored' both exit 1 with a nothing-verified error; a real filter reports 1; the no-filter run reports 8. Co-Authored-By: Claude Fable 5 --- scripts/loom.sh | 24 ++++++++++++++++-------- src/event_flags.rs | 14 +++++++++++--- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/scripts/loom.sh b/scripts/loom.sh index 8999484..166f155 100755 --- a/scripts/loom.sh +++ b/scripts/loom.sh @@ -49,17 +49,25 @@ if [ "$#" -gt 0 ]; then esac fi -if RUSTFLAGS='--cfg loom' cargo test --lib "$filter" "$@"; then - # "cargo test" exits 0 when a filter matches nothing, so a misspelled - # model name would otherwise earn the success banner after running zero - # tests. List the matches (the build is already warm) and require one. - matched="$(RUSTFLAGS='--cfg loom' cargo test --lib "$filter" -- --list 2>/dev/null | grep -c ': test$')" - if [ "${matched:-0}" -eq 0 ]; then - printf '\nerror: filter "%s" matched no Loom models -- zero tests ran, nothing was verified.\n' "$filter" >&2 +# Capture the run and decide from the harness's own summary. Cargo exits 0 +# when a filter matches nothing, and test-binary selectors passed after `--` +# (--ignored, --skip, --exact) shrink the selection further -- an earlier +# guard that re-listed without "$@" vouched for models the actual run never +# executed. Parsing the run's own "test result:" line counts exactly what +# ran. Not a pipe: a pipeline would report tee's exit status, not cargo's. +log="$(mktemp)" +trap 'rm -f "$log"' EXIT +if RUSTFLAGS='--cfg loom' cargo test --lib "$filter" "$@" >"$log" 2>&1; then + cat "$log" + ran="$(sed -n 's/^test result: ok\. \([0-9][0-9]*\) passed.*/\1/p' "$log" | tail -n 1)" + if [ "${ran:-0}" -eq 0 ]; then + printf '\nerror: the invocation ran no Loom models -- nothing was verified.\n' >&2 + printf 'Check the filter and any selectors after "--" (--ignored, --skip, --exact).\n' >&2 exit 1 fi - printf '\nAll %s matching Loom models verified.\n' "$matched" + printf '\nAll %s Loom models that matched were verified.\n' "$ran" else + cat "$log" printf '\nLoom found a failing execution. The output above replays the\n' printf 'exact interleaving -- it is deterministic, so re-running reproduces it.\n' exit 1 diff --git a/src/event_flags.rs b/src/event_flags.rs index 79a0db1..f232d2f 100644 --- a/src/event_flags.rs +++ b/src/event_flags.rs @@ -219,9 +219,17 @@ pub struct Producer<'a> { impl Producer<'_> { /// Raise every condition in `mask`. /// - /// The operation is exactly one atomic `fetch_or`: it never loops, waits, - /// allocates, calls user code, or panics. A concurrent take observes this - /// raise in its own snapshot or leaves it pending for the following take. + /// The operation is exactly one source-level atomic `fetch_or`: no + /// algorithmic retry loop, and it never waits, allocates, calls user + /// code, or panics. How the single RMW is realised is per-ISA — a lone + /// `amoor.w` on RISC-V, a gated four-instruction PRIMASK critical + /// section on Cortex-M0, and an LDREX/STREX pair on exclusive-monitor + /// ARM, where a lost reservation (an intervening interrupt, or the + /// concurrent take's `swap`) repeats the pair. That hardware retry is + /// bounded by contention on the one shared word, not by anything this + /// code does; the uncontended cost is the measured row. A concurrent + /// take observes this raise in its own snapshot or leaves it pending + /// for the following take. #[inline] pub fn raise(&self, mask: EventMask) { self.flags.pending.fetch_or(mask.bits(), Ordering::Release); From d04f6daebc23cedcbf65adaa73b29d3af5684a09 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:36:42 -0400 Subject: [PATCH 54/87] State what the seqlock evidence actually proves, and where .bss coverage ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 Codex P2s on PR #39. The torn-value row claimed Proven while neither cited checker validates the absolute: Loom deliberately leaves the slot cells untracked and the dedicated Miri pass runs with the race detector off, because the copy is a formal data race by design. The row now reads protocol-validated — discipline argued in the module docs, widely deployed, no observed failure — rather than proven, keeping the span bound. The .bss row promised no-flash/no-startup-copy placement for SeqRing on the strength of const fn plus an EventBuf-only probe; const-ness proves constructibility, not section placement, so the claim is narrowed to what is enforced and measured, with a dedicated SeqRing static row noted as a promotion-time gate addition. The wrap section's accounting paragraph — the one unqualified claim site left — now carries the span bound like the rest. Co-Authored-By: Claude Fable 5 --- docs/records/seq-ring.md | 4 ++-- src/seq_ring.rs | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md index 2239714..595910f 100644 --- a/docs/records/seq-ring.md +++ b/docs/records/seq-ring.md @@ -88,11 +88,11 @@ channel (`LatestBuf` is, with a race-free ownership argument). | Claim | Evidence | Status | |---|---|---| -| No torn value ever materialises as `T` (racy copies discarded before use) | Volatile access + `MaybeUninit` holding + re-check discipline; Loom models; the dedicated seqlock Miri pass | Proven within the documented deviation, for reads completing within one sequence span (counter-width ABA bound, §2) | +| No torn value materialises as `T` (racy copies discarded before use) | Volatile access + `MaybeUninit` holding + re-check discipline; Loom models the sequence protocol (slot cells deliberately untracked — see the module's sync note); the dedicated Miri pass runs with the race detector **off** for this ring | Protocol-validated, not formally proven: the slot copy is a Rust data race (UB), so no checker validates the absolute claim — the discipline is argued in the module docs, widely deployed (Linux `seqlock_t`), and has no observed failure; span-bounded (counter-width ABA, §2) | | Fence placement is necessary (Release before value write; Acquire before re-check) | Module-doc argument; Loom; ordering discipline enforced by AGENTS' rerun-after-atomic-change rule | Proven | | Recovery cost is constant w.r.t. lag | 0.2.0 measurement: a consumer 2,000 sequences behind recovers in the same 115 instructions as one 16 behind | Measured | | Loss accounting is exact and saturating (`read + dropped` accounts for every sequence within one span; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned, for inter-poll gaps shorter than one sequence span (§2) | -| Const-constructs into `.bss` — no flash image, no startup copy | By construction (`const fn new`); the 0.2.0 codesize probe demonstrates the crate's `.bss` discipline on an `EventBuf` static (268 B) — **no dedicated SeqRing static is probed or baseline-gated**, and SeqRing's RAM adds `N` per-slot sequence atomics over the payload array, `size_of`-computable per shape | By construction; probe coverage is EventBuf's | +| Const-constructible in a `static`; RAM is `size_of`-computable per shape (payload array + `N` per-slot sequence atomics) | `const fn new` (constructibility is compiler-enforced); the 0.2.0 codesize probe demonstrates the crate's `.bss` discipline on an `EventBuf` static (268 B) — **no dedicated SeqRing static is probed or baseline-gated**, so section placement for SeqRing specifically is expected from the same all-zero layout, not measured | Constructibility by construction; `.bss` placement demonstrated for EventBuf only — a dedicated SeqRing static row is a reasonable promotion-time gate addition | ## 4. The record diff --git a/src/seq_ring.rs b/src/seq_ring.rs index d6ef6d9..5ed8a39 100644 --- a/src/seq_ring.rs +++ b/src/seq_ring.rs @@ -105,8 +105,10 @@ //! //! **This is a data-loss bound, not a soundness problem.** The affected read fails its sequence //! check and is counted in [`PollStats::dropped`], so `read + dropped` still accounts for every -//! published item and no stale or torn value is ever returned. It is indistinguishable from the -//! ordinary lag-induced drops the consumer already reports. +//! published item and no stale or torn value is returned — both within the span bound of the +//! "Known limitation: whole-span sequence aliasing" section below, which is where each of those +//! guarantees runs out. It is indistinguishable from the ordinary lag-induced drops the consumer +//! already reports. //! //! The same misalignment makes the lag-recovery jump resume up to one sequence later than it //! strictly needs to. That is bounded by the table above and reported identically. From 2bbccd554276c2eb044a51071de129f8cbf371ea Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:37:31 -0400 Subject: [PATCH 55/87] =?UTF-8?q?Keep=20EventFlags=20Debug=20opaque=20?= =?UTF-8?q?=E2=80=94=20no=20non-clearing=20peek=20through=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 Codex P2 on PR #36: Debug printed the pending mask via a Relaxed load, which is the advisory peek the frozen API rejects (destructive take_all is the only read) minus take_all's Acquire publication guarantee. Debug now reports the type opaquely, matching the handle implementations, with the rationale in a comment so the field is not innocently reintroduced. Co-Authored-By: Claude Fable 5 --- src/event_flags.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/event_flags.rs b/src/event_flags.rs index f232d2f..43e4171 100644 --- a/src/event_flags.rs +++ b/src/event_flags.rs @@ -189,14 +189,14 @@ impl Default for EventFlags { } } +// Deliberately opaque: printing `pending` would be a non-clearing peek — +// exactly the advisory observation the frozen API rejects (destructive +// `take_all` is the only read) — and a Relaxed load carries none of +// `take_all`'s Acquire publication guarantee. Debug is required by +// convention; it reports the type, not the state. impl core::fmt::Debug for EventFlags { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("EventFlags") - .field( - "pending", - &EventMask::from_bits(self.pending.load(Ordering::Relaxed)), - ) - .finish_non_exhaustive() + f.debug_struct("EventFlags").finish_non_exhaustive() } } From 803f523e84100169d5b66fb55b930998a7cc6e6b Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:39:39 -0400 Subject: [PATCH 56/87] Pin the producer-side loss ledger in the Loom models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 Codex P2 on PR #35: both models discarded every PublishReport, so replaced_unread — the producer-side half of the loss evidence — was covered only by the sequential no-consumer unit test. The concurrent model now preserves each report and asserts bucket conservation after the joins: every publication is exactly one of taken, displaced-unread (the report), or pending at the end, which the exchange's linearization makes hold in every interleaving. The deterministic handshake model asserts replaced_unread == false on all three publishes, since each handshake guarantees the predecessor was taken. Mutation-verified both ways: a wrong conservation total and a flipped report expectation each fail immediately; all five models pass. Co-Authored-By: Claude Fable 5 --- src/loom_tests.rs | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 24d1943..1ce4b3f 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -31,19 +31,28 @@ fn latest_buf_returns_only_complete_publications() { let producer_channel = Arc::clone(&channel); let producer = thread::spawn(move || { let producer = producer_channel.try_producer().unwrap(); - let _ = producer.publish([1, 1]); - let _ = producer.publish([2, 2]); - let _ = producer.publish([3, 3]); + // Each publication's report is preserved: `replaced_unread` is + // the producer-side half of the loss ledger, and the conservation + // assert after the joins checks it against the consumer's takes. + let mut replaced = 0u32; + for value in 1..=3u32 { + if producer.publish([value, value]).replaced_unread { + replaced += 1; + } + } + replaced }); + let consumer_channel = Arc::clone(&channel); let consumer = thread::spawn(move || { - let consumer = channel.try_consumer().unwrap(); + let consumer = consumer_channel.try_consumer().unwrap(); // Conservation across the run, not just per-item sanity: each // observed generation must be strictly newer than the last // (at-most-once, C-family), and `skipped` must equal exactly the // generations passed over since the previous take (C3/O2; no wrap // at these magnitudes, so the formula reduces to the difference). let mut last_generation = 0u32; + let mut taken = 0u32; for _ in 0..3 { if let Some(item) = consumer.take_latest() { assert!((1..=3).contains(&item.generation)); @@ -51,13 +60,22 @@ fn latest_buf_returns_only_complete_publications() { assert!(item.generation > last_generation); assert_eq!(item.skipped, item.generation - last_generation - 1); last_generation = item.generation; + taken += 1; } thread::yield_now(); } + taken }); - producer.join().unwrap(); - consumer.join().unwrap(); + let replaced = producer.join().unwrap(); + let taken = consumer.join().unwrap(); + + // Every publication ends in exactly one bucket — taken by the + // consumer, displaced while unread (the producer's report), or still + // pending at the end. The exchange's linearization makes this hold in + // every interleaving; a report derived from stale state breaks it. + let pending = u32::from(channel.try_consumer().unwrap().take_latest().is_some()); + assert_eq!(replaced + taken + pending, 3); }); } @@ -80,17 +98,20 @@ fn latest_buf_reused_slot_keeps_exclusive_ownership() { let producer_phase = Arc::clone(&phase); let producer = thread::spawn(move || { let producer = producer_channel.try_producer().unwrap(); - let _ = producer.publish([1, 1]); + // Each handshake guarantees the prior publication was taken + // before the next publish, so no publish here may ever report a + // displaced-unread predecessor. + assert!(!producer.publish([1, 1]).replaced_unread); producer_phase.store(1, Ordering::Relaxed); while producer_phase.load(Ordering::Relaxed) < 2 { thread::yield_now(); } - let _ = producer.publish([2, 2]); + assert!(!producer.publish([2, 2]).replaced_unread); producer_phase.store(3, Ordering::Relaxed); while producer_phase.load(Ordering::Relaxed) < 4 { thread::yield_now(); } - let _ = producer.publish([3, 3]); + assert!(!producer.publish([3, 3]).replaced_unread); }); let consumer_channel = Arc::clone(&channel); From 320d3774c157268b821b46f56549f4f4c16a090b Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:41:25 -0400 Subject: [PATCH 57/87] Gate the block-payload baseline from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 Codex P2 on PR #34: ci.sh invoked only the default codesize run, which reads baseline.tsv — after the directed promotion bless creates baseline-block.tsv, nothing would ever compare the block rows, so a post-promotion block regression could pass CI while the normal matrix stayed green. ci.sh now also runs ./scripts/codesize.sh block-matrix under the same SKIP_EMBEDDED branch. Verified live: with no block baseline the run exits 2 and run_check records a loud SKIP (not a pass, per the existing convention); once the baseline is committed at promotion the same line becomes the regression gate. Co-Authored-By: Claude Fable 5 --- scripts/ci.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/ci.sh b/scripts/ci.sh index 39c8dbe..ab258c4 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -138,6 +138,14 @@ if [ "${SKIP_EMBEDDED:-0}" = "0" ]; then # embedded toolchains. run_check 'codesize (baseline gate)' ./scripts/codesize.sh + # The block-payload matrix gates against its own mode-specific baseline + # (baseline-block.tsv), which the default run above never reads — without + # this line a post-promotion block-row regression would pass CI while the + # normal matrix stayed green. Before that baseline is blessed (a directed, + # reviewed step at promotion) codesize.sh exits 2 and run_check records a + # loud SKIP — visible, and not a pass. + run_check 'codesize (block matrix gate)' ./scripts/codesize.sh block-matrix + run_check 'thumbv6m-none-eabi' \ cargo check --target thumbv6m-none-eabi \ --features portable-atomic-unsafe-assume-single-core From 3c4608dc3878a8fcdbcee9722ae3ed93e914a926 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:43:54 -0400 Subject: [PATCH 58/87] Scope B1 to source level with the per-ISA realisation disclosed (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 Codex P1 on PR #33: B1 claimed a statically bounded, fixed instruction sequence on every gated ISA, independent of consumer activity — untrue on exclusive-monitor Arm, where fetch_add, the sentinel fetch_or, and the consumer's swap each lower to an LDREX/STREX pair that repeats when the reservation is lost to an intervening interrupt or the competing role's access. The arbitration applies the same resolution the EventFlags raise() claim received in round 5, crate-uniformly: the algorithm is a fixed sequence of at most three source-level atomics with no algorithmic retry (that part survives unchanged — it is what distinguishes this design from the rejected CAS loop), and the contract now discloses the per-ISA realisation — single AMO on RV32IMAC, portable-atomic path on M0-class, contention-bounded LDREX/STREX on v7-M+ where every repeat requires an actual intervening event on the one shared word, so it cannot livelock on the single-core gated targets, but is not a static instruction count. B2 gets the same note for the swap. All claim surfaces reworded: contract, module doc, Producer/increment rustdoc, README, proposal (both sites), record. The measured rows are explicitly the uncontended realisations. Also round-6 P2: the record's decision-history bullet still enshrined compare_exchange(MAX, MAX) as the accepted algorithm — it now records the no-op RMW with the CAS-to-RMW history explicit. Self-caught in the same round: CountedSignal's Debug printed the live count — the same non-clearing advisory peek Codex flagged on EventFlags' Debug this round. Now opaque with the rationale in a comment, matching the EventFlags fix. Co-Authored-By: Claude Fable 5 --- README.md | 5 ++-- docs/proposals/counted-signal-contract.md | 25 ++++++++++++-------- docs/proposals/counted-signal.md | 7 +++--- docs/records/counted-signal.md | 18 +++++++++++---- src/counted_signal.rs | 28 +++++++++++++++-------- 5 files changed, 55 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3ba4e66..4a3de2c 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,10 @@ assert!(producer.push(3).is_ok()); // space freed A saturating count for repeated events whose payload and ordering do not matter. The sole producer is load-bearing: it permits exact saturation with a -fixed instruction sequence that treats observed `u32::MAX` as maybe-stale and +fixed source-level sequence that treats observed `u32::MAX` as maybe-stale and confirms it through a no-op RMW re-read — an RMW observes the latest value in -modification order, so there is no compare-exchange and no retry loop. +modification order, so there is no compare-exchange and no algorithmic retry; +the contract discloses how each single RMW is realised per ISA. ```rust use ph_eventing::CountedSignal; diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index 28a98b2..b90904f 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -77,15 +77,22 @@ the call was invoked. Algorithmic bounds live here; instruction counts remain measured claims tied to a target, toolchain, and reference environment. -- **B1.** `increment` performs a statically bounded amount of work independent - of signal history and consumer activity: a fixed instruction sequence on - every gated ISA — one load, at most one no-op RMW re-read on the saturation - sentinel, and at most one `fetch_add`; no compare-exchange (and therefore no - LR/SC retry loop), no dynamic allocation, no user code, and no wait for the - consumer. -- **B2.** `take_count` performs a statically bounded amount of work independent - of the number of increments in the interval and producer activity: no retry - loop, no dynamic allocation, no user code, and no wait for the producer. +- **B1.** `increment` performs a bounded amount of work independent of signal + history: at most three source-level atomic operations — one load, at most + one no-op RMW re-read on the saturation sentinel, and at most one + `fetch_add` — with no algorithmic retry of any of them, no compare-exchange, + no dynamic allocation, no user code, and no wait for the consumer. How each + single RMW is realised is per-ISA: one AMO instruction on RV32IMAC, a + portable-atomic path on the M0-class targets, and an LDREX/STREX pair on + exclusive-monitor Arm, where a reservation lost to an intervening interrupt + or the consumer's `swap` repeats that pair. That hardware retry is bounded + by contention on the one shared word — every repeat requires an actual + intervening event, so it cannot livelock on the single-core targets this + crate gates — but it is not a static instruction count. The measured rows + (code size, cycles) are the uncontended realisations. +- **B2.** `take_count` is one source-level atomic `swap`, with the same + per-ISA realisation bound as B1's RMWs: no algorithmic retry, no dynamic + allocation, no user code, and no wait for the producer. - **B3.** No panic is reachable from either hot-path operation. ## 6. Handle clauses (H) diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index 089c575..869109e 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -66,8 +66,8 @@ should remain statically sized and avoid a general dynamic registry. - Saturation uses the sole-producer algorithm in §3.1: a Relaxed load, with an observed `u32::MAX` re-read through a no-op RMW (`fetch_or(0)`) that is guaranteed to see the latest value in modification order — a fixed - instruction sequence on every gated ISA, with no compare-exchange and - therefore no LR/SC retry loop on RISC-V. + source-level sequence with no compare-exchange and no algorithmic retry + (per-ISA realisation disclosed in contract B1; a single AMO on RISC-V). - `u32::MAX` is the observable saturation sentinel, wrapped in `CountSnapshot`; exact excess beyond the sentinel is intentionally absent. - `take_count` is a `swap(0)` and reports one take interval, not a running @@ -100,7 +100,8 @@ intervals (violating T3/A1). The sentinel path therefore re-reads through a no-op RMW — an RMW, unlike a load or a failed compare-exchange, observes the latest value in modification order, so `MAX` proves current saturation (absorbed no-op) and anything else falls through to `fetch_add` into the -post-take epoch. Every path is a fixed instruction sequence under B1; the +post-take epoch. Every path is a fixed source-level sequence under B1, which +also discloses the per-ISA realisation of each single RMW; the original sentinel used a strong compare-exchange, which was replaced when review showed it lowers to an unbounded LR/SC retry loop on RISC-V. diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index 616b0c8..66a43a0 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -57,8 +57,11 @@ IDs in parentheses; the clauses are the normative statements. destructors, or a forgotten handle, leaves the role held. There is deliberately no out-of-band forced release. - **Algorithmic bounds are not wall-clock claims (B1–B3, X5).** - `increment` is a fixed instruction sequence on every gated ISA - (no retry of any kind); `take_count` has no retry loop. Neither waits or + `increment` is a fixed sequence of source-level atomics with no + algorithmic retry; `take_count` is one source-level `swap`. On + exclusive-monitor Arm each single RMW is an LDREX/STREX pair that can + repeat only when an intervening event claims the word (B1's per-ISA + disclosure); the measured rows are uncontended. Neither waits or panics on the hot path. Instruction and latency numbers are environment-specific measurements; cite them with the pinned toolchain and QEMU stamp, never as universal cycle truths. @@ -97,9 +100,14 @@ in contract §8 and proposal §3.1–§3.2):** - **Bounded exact saturation: sole-producer `fetch_add` with stale-MAX confirmation.** Below `MAX`, one Relaxed load and one Relaxed - `fetch_add`. Observed `MAX` is confirmed with `compare_exchange(MAX, - MAX)`; success is a saturated no-op, failure falls through to one - `fetch_add` into the post-take epoch (fixed sequence, no retry). + `fetch_add`. Observed `MAX` is confirmed with a no-op RMW re-read + (`fetch_or(0)`) — an RMW observes the latest value in modification + order, so `MAX` proves saturation and anything else falls through to + one `fetch_add` into the post-take epoch (fixed source-level + sequence, no algorithmic retry). The first accepted form confirmed + with a bounded `compare_exchange(MAX, MAX)`; review showed a strong + CAS lowers to an unbounded LR/SC loop on RISC-V, and the no-op RMW + replaced it. Rejected for this type: a load-and-skip-on-`MAX` short-circuit (fails T3/A1 after a completed take under Relaxed observation), an unbounded CAS loop (fails B1), silent wrap (fails A2/I3 and the crate's diff --git a/src/counted_signal.rs b/src/counted_signal.rs index 546baa7..fae2fc1 100644 --- a/src/counted_signal.rs +++ b/src/counted_signal.rs @@ -12,8 +12,11 @@ //! RMW (`fetch_or(0)`), which — unlike a load — observes the latest value in //! modification order: `MAX` confirms true saturation (skip), anything else //! means a completed take reset the counter and the producer `fetch_add`s into -//! the new epoch. Every path is a fixed instruction sequence on every gated -//! ISA — no compare-exchange, so no LR/SC retry loop on RISC-V. Multiple +//! the new epoch. Every path is a fixed sequence of at most three source-level +//! atomic operations — no compare-exchange and no algorithmic retry. On +//! exclusive-monitor Arm each single RMW is realised as an LDREX/STREX pair +//! that repeats only when an intervening event claims the word; contract B1 +//! carries the full per-ISA disclosure. Multiple //! producers would invalidate the proof. //! //! The counter carries no payload-publication semantics. These Relaxed @@ -129,19 +132,22 @@ impl Default for CountedSignal { } } +// Deliberately opaque: printing `count` would be a non-clearing peek — +// the advisory observation the API deliberately lacks (destructive +// `take_count` is the only read) — without the take's snapshot semantics. +// Debug is required by convention; it reports the type, not the state. +// Same rationale as EventFlags' opaque Debug. impl core::fmt::Debug for CountedSignal { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("CountedSignal") - .field("count", &self.count.load(Ordering::Relaxed)) - .finish_non_exhaustive() + f.debug_struct("CountedSignal").finish_non_exhaustive() } } /// The sole incrementing handle for a [`CountedSignal`]. /// /// This handle is `Send + !Sync`. Its exclusivity is what keeps exact -/// saturation wrap-free with a fixed instruction sequence on every path — -/// the sentinel re-read is a no-op RMW, never a retry loop. +/// saturation wrap-free with a fixed source-level sequence on every path — +/// the sentinel re-read is a no-op RMW, never an algorithmic retry loop. /// /// The load-bearing `!Sync` property is pinned at compile time: /// @@ -164,8 +170,12 @@ impl Producer<'_> { /// `MAX` confirms saturation (skip), anything else is a post-take epoch /// and one `fetch_add` records the occurrence. Under sole-producer /// ownership the consumer's `swap(0)` is the only competing write, so - /// every path is a fixed instruction sequence — no retry loop on any - /// gated ISA — and the counter never wraps. + /// every path is a fixed sequence of source-level atomics with no + /// algorithmic retry, and the counter never wraps. On exclusive-monitor + /// Arm each single RMW is an LDREX/STREX pair that repeats only if an + /// intervening event (an interrupt, or that `swap`) claims the word — + /// contention-bounded hardware retry, disclosed in contract B1; the + /// measured rows are the uncontended realisations. #[inline] pub fn increment(&self) { // Only this handle may increase `count`; the consumer can only reset it From 48d872cb089306374eabbed9530c38af65f3d625 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:50:40 -0400 Subject: [PATCH 59/87] Make the EventBuf record's numbers and citations verifiable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 Codex P2s on PR #39. The block RAM formula omitted EventBuf's own metadata — the probe static itself shows the undercount (268 measured vs 256 bytes of payload slots) — so the budgeting guidance now names size_of::, Q>>() and says why the naive product is short. The len row claimed a consistent cursor pair unconditionally; the guaranteed property is bounded and wait-free, with consistency holding for a successful t1 == t2 bracket and a clamped estimate on the fallback path — the row now says exactly that. The D3 evidence row cited only commit bc54a9a, unreachable from master; it now cites docs/proposals/block-buf-measurements.md, which rides PR #34 to master and makes the row verifiable from the repository after promotion. Co-Authored-By: Claude Fable 5 --- docs/records/event-buf.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/records/event-buf.md b/docs/records/event-buf.md index 9add15e..e32fd8d 100644 --- a/docs/records/event-buf.md +++ b/docs/records/event-buf.md @@ -36,8 +36,13 @@ it outright. It refuses to be: lossy (that is `SeqRing`), freshness-first cannot make; too small converts load spikes into rejection storms. - **Fixed RAM, `N` slots always** — const-constructed into `.bss` (no flash image, no startup copy, measured in the 0.2.0 268-byte/11-target - result). With block payloads that is `Q × size_of::>()` - on top of the fill-side builder — state the per-shape number. + result). With block payloads budget + `size_of::, Q>>()` on top of the fill-side + builder — not `Q × size_of::>()`, which omits the two + cursors, the two taken flags, and layout padding; the probe static + itself shows the undercount (an `EventBuf` measures 268 + bytes against 256 bytes of payload slots). State the `size_of` + number for your shape. - **Role lifecycle follows the crate doctrine:** sole-role `Send + !Sync` handles, `try_*`-only acquisition since 0.3.0, roles held until the handle drops, deliberately no out-of-band reset (the @@ -51,9 +56,9 @@ it outright. It refuses to be: lossy (that is `SeqRing`), freshness-first | Race-free — no data race on slots or cursors, no deviation | Miri with the race detector on (full pass, no accommodation); Loom models incl. the full-buffer backpressure path | Proven | | No silent loss — every failed delivery is observable at the call site | `push` returns `Err(val)`; the type has no overwrite path; unit + threaded stress incl. lossless-and-ordered Loom model | Proven | | Bounded operations, no CAS retry loop | Lamport single-owner cursors: one Relaxed load + one Acquire load + one Release store per side | Proven by construction; cycle rows measured in 0.2.0 | -| `len` reads a consistent cursor pair | The bracketed `tail`/`head`/`tail` sampling documented in the module docs | Pinned | +| `len` is bounded and wait-free; a successful bracketed sample is a consistent snapshot | The `tail`/`head`/`tail` bracket documented in the module docs: a `t1 == t2` attempt is a consistent pair; if the consumer moves during both bounded attempts, `len` returns a clamped estimate instead of retrying further | Pinned — consistency holds for successful brackets, boundedness always | | Const-constructs into `.bss` | 0.2.0 measurement across 11 targets; codesize baseline gated in CI | Measured on 11 targets; regression-gated on the eight upstream baseline targets only — the three Xtensa rows are opt-in (esp-rs toolchain) and deliberately never baseline-gated | -| Queued block transport (D3) adds no new concurrency contract | The block layer adds no atomics; existing EventBuf clauses apply with `T = Block<…>`; publication-cost matrix at `bc54a9a` | Measured | +| Queued block transport (D3) adds no new concurrency contract | The block layer adds no atomics; existing EventBuf clauses apply with `T = Block<…>`; publication-cost matrix in `docs/proposals/block-buf-measurements.md` (developed at `bc54a9a` on `candidate/block-buf`; the document rides PR #34 to `master`, which is what makes this row verifiable from the repository after promotion) | Measured | ## 4. The record From 96647627b5162abd849e0ace2ad3e2cd7d814465 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:51:30 -0400 Subject: [PATCH 60/87] Retire the last two CAS references and the wait-free label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 Codex P2s on PR #33: the AGENTS core-type table still called the sentinel CAS-confirmed and the safety invariant still said 'the CAS path exists' — the two guardrails a maintainer reads first; both now name the no-op fetch_or(0) re-read and forbid the compare-exchange regression explicitly. The record's claim row still said wait-free, which LDREX/STREX realisation under contention does not satisfy; it now says bounded source-level with no algorithmic retry and points at B1's per-ISA disclosure so no consumer-independent-latency inference survives. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 7 ++++--- docs/records/counted-signal.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd39bc2..f26767a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,7 +164,7 @@ ph-eventing/ | `event_buf::Producer<'a, T, N>` | EventBuf write handle; `push(T) -> Result<(), T>` | | `event_buf::Consumer<'a, T, N>` | EventBuf read handle; `pop() -> Option`, `peek()`, `drain()` | | `CountedSignal` | Saturating count for payload-free SPSC events | -| `counted_signal::Producer<'a>` | Sole incrementing handle; `fetch_add` below MAX, CAS-confirmed sentinel | +| `counted_signal::Producer<'a>` | Sole incrementing handle; `fetch_add` below MAX, no-op-RMW-confirmed sentinel | | `counted_signal::Consumer<'a>` | Sole taking handle; `swap(0)` partitions count epochs | | `Sink` | Trait — accept events via `try_push(&mut self, T) -> Result<(), Error>` | | `Source` | Trait — yield events via `try_pop(&mut self) -> Option` | @@ -990,8 +990,9 @@ The project supports these targets (defined in `rust-toolchain.toml`): - `CountedSignal`: Producer and Consumer handles are `Send + !Sync`; producer exclusivity is load-bearing for wrap-free saturation (B1) - `CountedSignal`: never restore a load-and-skip-on-`MAX` short-circuit; the - CAS path exists so a post-take increment cannot vanish under Relaxed - observation of a stale sentinel + no-op `fetch_or(0)` re-read exists so a post-take increment cannot vanish + under Relaxed observation of a stale sentinel (and never becomes a + compare-exchange, which lowers to an unbounded LR/SC loop on RISC-V) - `SeqRing` / `EventBuf`: the panicking `producer()` / `consumer()` were deprecated in 0.2.0 and **removed in 0.3.0**. `try_producer()` / `try_consumer()` are the only handle-acquisition API; a panic is a reset on the targets this crate exists for. Do not reintroduce a panicking diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index 66a43a0..ead1c90 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -78,7 +78,7 @@ IDs in parentheses; the clauses are the normative statements. | Saturates at `u32::MAX`, never wraps; sticky until take (I2–I3, T2, A2–A3) | `saturates_instead_of_wrapping`; Loom `counted_signal_saturation_boundary_is_linearizable` | Proven | | Concurrent increment belongs to exactly one take interval (T1–T3, A1) | Loom `counted_signal_take_partitions_increments`; threaded take stress | Proven | | Post-take increment after saturated take is not dropped by a stale MAX observe (T3, A1) | Loom `counted_signal_post_take_increment_observes_reset_epoch` (Relaxed gate only) | Proven | -| Wait-free bounded hot paths: load + `fetch_add`; sentinel no-op RMW re-read + ≤1 follow-up `fetch_add`; `swap(0)` take (B1–B2) | Source review; riscv32imac disassembly shows lw/amoor.w/amoadd.w with **no lr.w/sc.w**; eight-target code size re-blessed post-RMW; Cortex-M3 cycles 8 (hot) / 9 (saturated arm, probe-seeded region) / 7 (take); the stale-MAX third arm is the saturated arm + one `fetch_add` by construction | Measured | +| Bounded source-level hot paths, no algorithmic retry: load + `fetch_add`; sentinel no-op RMW re-read + ≤1 follow-up `fetch_add`; `swap(0)` take — per-ISA realisation per B1 (LDREX/STREX pairs on exclusive-monitor Arm may repeat under contention, so this is not a wait-free or consumer-independent-latency claim) (B1–B2) | Source review; riscv32imac disassembly shows lw/amoor.w/amoadd.w with **no lr.w/sc.w**; eight-target code size re-blessed post-RMW; Cortex-M3 cycles 8 (hot) / 9 (saturated arm, probe-seeded region) / 7 (take); the stale-MAX third arm is the saturated arm + one `fetch_add` by construction | Measured | | No panic reachable from hot paths (B3) | Source review; normal, Miri, Loom, and embedded-target executions | Proven | | Sole-role exclusive handles; reacquisition continues state (H1, H3) | `handles_are_exclusive_and_reusable_after_drop` | Proven | | Handles are `Send + !Sync` (H2) | `handles_are_send`; compile-fail doctests pin `!Sync` on both roles | Pinned | From bbc682365585519e7562d5db82ae7e5264443967 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 19:54:07 -0400 Subject: [PATCH 61/87] Fail the cycle run when EventFlags state costs diverge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 Codex P2 on PR #36: the four EventFlags regions printed their counts but nothing compared them, so a change that made raise or take_all state-dependent would still print a full matrix under a success footer — the state-independence claim was a table, not a gate. cycles.sh now asserts the two state pairs are equal (raise clear == raise already-set; take nonempty == take empty) and fails on divergence or a missing row. Verified in the reference image: the real pairs pass (12/12 and 8/8), and a mutated cross-pair comparison exits 1 with the divergent values named. Co-Authored-By: Claude Fable 5 --- scripts/cycles.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/cycles.sh b/scripts/cycles.sh index ca04f40..fee9de9 100755 --- a/scripts/cycles.sh +++ b/scripts/cycles.sh @@ -222,6 +222,25 @@ fi printf '\n%s\n' "$report" +# The EventFlags state pairs are a claim, not just a table: raise costs the +# same on a clear flag as on an already-set one, and take_all the same +# nonempty as empty. Printing divergent counts under a success footer would +# un-pin exactly the property those four regions exist to hold, so a +# divergence (or a missing row) fails the run. +check_state_pair() { + op="$1"; a="$2"; b="$3" + va="$(printf '%s\n' "$report" | awk -v n="$a" '$0 ~ ("^ " n " +[0-9]+$") { print $NF; exit }')" + vb="$(printf '%s\n' "$report" | awk -v n="$b" '$0 ~ ("^ " n " +[0-9]+$") { print $NF; exit }')" + if [ -z "$va" ] || [ -z "$vb" ] || [ "$va" -ne "$vb" ]; then + printf '\nerror: EventFlags %s cost diverges by state: "%s" = %s, "%s" = %s.\n' \ + "$op" "$a" "${va:--}" "$b" "${vb:--}" >&2 + printf 'State-independence is a documented claim; divergence is a regression, not noise.\n' >&2 + exit 1 + fi +} +check_state_pair raise 'ef raise clear' 'ef raise already set' +check_state_pair take_all 'ef take nonempty' 'ef take empty' + printf '\n' printf 'Instructions retired on the guest, marker overhead subtracted.\n' printf 'Deterministic per environment: -icount shift=0 pins one instruction to\n' From 7ea27030b81fd2a381a22b3752854e3d0d4075c8 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 20:03:35 -0400 Subject: [PATCH 62/87] Bump version to 0.3.0 on the release branch First commit of release/0.3.0 per RELEASING.md step 1: the bump makes the branch differ from master so the draft merge-back PR can open, and it is the version every lane PR in this release assembles under. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6b0b02..4261247 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ph-eventing" -version = "0.2.0" +version = "0.3.0" dependencies = [ "loom", "portable-atomic", diff --git a/Cargo.toml b/Cargo.toml index 04222ba..76e020e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ph-eventing" -version = "0.2.0" +version = "0.3.0" edition = "2024" rust-version = "1.92.0" # no-std and zero-allocation are the entry fee, not the value -- plenty of From c35ab25d919fc41ddddceeccf7f0a77f9cbef973 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 20:13:32 -0400 Subject: [PATCH 63/87] Bless the block-payload code-size baseline at promotion The directed, reviewed --bless from the P closure (mechanics rule 8): 72 gated block rows (9 shapes x 8 upstream targets, Xtensa excluded as in the default mode) measured on the assembled release branch under the pinned rustc ded5c06cf. With this file committed, the ci.sh block-matrix line flips from its loud pre-promotion SKIP to the regression gate; verified passing against the fresh baseline (exit 0). The default 8-target baseline was re-checked on the same tree first: no row grew (exit 0), so no default re-bless was needed. Co-Authored-By: Claude Fable 5 --- scripts/codesize/baseline-block.tsv | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 scripts/codesize/baseline-block.tsv diff --git a/scripts/codesize/baseline-block.tsv b/scripts/codesize/baseline-block.tsv new file mode 100644 index 0000000..e853a01 --- /dev/null +++ b/scripts/codesize/baseline-block.tsv @@ -0,0 +1,81 @@ +# ph-eventing code-size baseline. Regenerate: ./scripts/codesize.sh block-matrix --bless +# rustc-commit: ded5c06cf21d2b93bffd5d884aa6e96934ee4234 +# +# Host-independent: byte-identical on x86_64-pc-windows-msvc and +# x86_64-unknown-linux-gnu for the same pinned rustc, verified across all +# eight gated targets. That is what makes committing it sound. +# +# Xtensa is deliberately absent -- it needs the esp-rs fork, and gating it +# would make that fork mandatory for every contributor. +armv7a-none-eabi block_w16_n128 300 +armv7a-none-eabi block_w16_n32 288 +armv7a-none-eabi block_w16_n8 296 +armv7a-none-eabi block_w2_n128 252 +armv7a-none-eabi block_w2_n32 248 +armv7a-none-eabi block_w2_n8 212 +armv7a-none-eabi block_w8_n128 312 +armv7a-none-eabi block_w8_n32 312 +armv7a-none-eabi block_w8_n8 312 +armv7r-none-eabi block_w16_n128 300 +armv7r-none-eabi block_w16_n32 288 +armv7r-none-eabi block_w16_n8 296 +armv7r-none-eabi block_w2_n128 252 +armv7r-none-eabi block_w2_n32 248 +armv7r-none-eabi block_w2_n8 212 +armv7r-none-eabi block_w8_n128 312 +armv7r-none-eabi block_w8_n32 312 +armv7r-none-eabi block_w8_n8 312 +riscv32imac-unknown-none-elf block_w16_n128 300 +riscv32imac-unknown-none-elf block_w16_n32 248 +riscv32imac-unknown-none-elf block_w16_n8 250 +riscv32imac-unknown-none-elf block_w2_n128 210 +riscv32imac-unknown-none-elf block_w2_n32 190 +riscv32imac-unknown-none-elf block_w2_n8 150 +riscv32imac-unknown-none-elf block_w8_n128 252 +riscv32imac-unknown-none-elf block_w8_n32 222 +riscv32imac-unknown-none-elf block_w8_n8 202 +thumbv6m-none-eabi block_w16_n128 268 +thumbv6m-none-eabi block_w16_n32 254 +thumbv6m-none-eabi block_w16_n8 242 +thumbv6m-none-eabi block_w2_n128 200 +thumbv6m-none-eabi block_w2_n32 168 +thumbv6m-none-eabi block_w2_n8 138 +thumbv6m-none-eabi block_w8_n128 240 +thumbv6m-none-eabi block_w8_n32 224 +thumbv6m-none-eabi block_w8_n8 192 +thumbv7em-none-eabi block_w16_n128 216 +thumbv7em-none-eabi block_w16_n32 208 +thumbv7em-none-eabi block_w16_n8 200 +thumbv7em-none-eabi block_w2_n128 130 +thumbv7em-none-eabi block_w2_n32 132 +thumbv7em-none-eabi block_w2_n8 136 +thumbv7em-none-eabi block_w8_n128 178 +thumbv7em-none-eabi block_w8_n32 118 +thumbv7em-none-eabi block_w8_n8 120 +thumbv7m-none-eabi block_w16_n128 216 +thumbv7m-none-eabi block_w16_n32 208 +thumbv7m-none-eabi block_w16_n8 200 +thumbv7m-none-eabi block_w2_n128 130 +thumbv7m-none-eabi block_w2_n32 132 +thumbv7m-none-eabi block_w2_n8 136 +thumbv7m-none-eabi block_w8_n128 178 +thumbv7m-none-eabi block_w8_n32 118 +thumbv7m-none-eabi block_w8_n8 120 +thumbv8m.base-none-eabi block_w16_n128 264 +thumbv8m.base-none-eabi block_w16_n32 250 +thumbv8m.base-none-eabi block_w16_n8 242 +thumbv8m.base-none-eabi block_w2_n128 190 +thumbv8m.base-none-eabi block_w2_n32 168 +thumbv8m.base-none-eabi block_w2_n8 136 +thumbv8m.base-none-eabi block_w8_n128 244 +thumbv8m.base-none-eabi block_w8_n32 228 +thumbv8m.base-none-eabi block_w8_n8 192 +thumbv8m.main-none-eabi block_w16_n128 216 +thumbv8m.main-none-eabi block_w16_n32 212 +thumbv8m.main-none-eabi block_w16_n8 204 +thumbv8m.main-none-eabi block_w2_n128 122 +thumbv8m.main-none-eabi block_w2_n32 126 +thumbv8m.main-none-eabi block_w2_n8 138 +thumbv8m.main-none-eabi block_w8_n128 182 +thumbv8m.main-none-eabi block_w8_n32 110 +thumbv8m.main-none-eabi block_w8_n8 114 From a07ead34ebb114bd2e0850aea5332f3ac8f1db6b Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 21:02:23 -0400 Subject: [PATCH 64/87] Bound the aliasing escape hatch at the resume cursor; carry the span into the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-8 Codex P2s on PR #39. The escape hatch claimed any nonzero poll resynchronizes, but a poll with lag at most N never runs the lag-recovery jump — a partial drain leaves residual backlog, and a subsequent burst of span-minus-backlog publications aliases with strictly fewer than one span between calls. The bound is now stated from the resume cursor: a nonzero ordered poll (poll_one is poll_up_to(1)) always leaves the cursor at most N - 1 behind the newest it observed, so publications between consecutive nonzero polls must stay below one span minus N - 1; skip_to_latest zeroes the distance. The record's bullet matches. The crates.io README also still promised every-drop-reported and no-torn-value unqualified in three places — a stronger contract than the rustdoc — and now carries the same span bounds with a pointer to the whole-span section. Co-Authored-By: Claude Fable 5 --- README.md | 18 ++++++++++++------ docs/records/seq-ring.md | 15 +++++++++------ src/seq_ring.rs | 15 ++++++++++----- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a53d29c..5f1497a 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ All types are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Co - **Predictability first.** No unbounded loops, no hidden allocation, and no panic reachable from a hot path. For the two SPSC types, no data loss that - cannot be observed either — every drop is reported (`SeqRing`) or prevented - (`EventBuf`). `RingBuf` is the deliberate exception: it is a single-owner + cannot be observed either — every drop is reported (`SeqRing`, exact within + one sequence span; see its section) or prevented (`EventBuf`). `RingBuf` is the deliberate exception: it is a single-owner window that overwrites silently, with no drop counter and no backpressure. Reach for it when losing the oldest entry is the point, not when delivery matters. @@ -286,8 +286,13 @@ them is a runtime step and always will be. - If the consumer lags by more than `N`, it skips ahead and reports drops via `PollStats`. - Once every `2^32 - 1` pushes the sequence counter wraps and a few extra entries are dropped — exactly one for a power-of-two `N`, none if `N` divides `2^32 - 1`, up to `N - 1` otherwise. They - are reported as ordinary drops; no stale or torn value is ever returned. See - [Choosing `N`](#choosing-n). + are reported as ordinary drops; no stale or torn value is returned (within the span bound + below). See [Choosing `N`](#choosing-n). +- Sequence arithmetic is modular over that `2^32 - 1` span, and both headline guarantees carry + its bound: a whole-span gap from the consumer's resume cursor aliases to "nothing new" and + reports **zero** drops, and the torn-copy re-check shares the same counter-width ABA limit for + a consumer stalled mid-read. Reachability arithmetic and the structural escape hatches are in + the rustdoc ("Known limitation: whole-span sequence aliasing"). ### EventBuf - FIFO order: `pop` always returns the oldest item. @@ -363,8 +368,9 @@ in a task loop. That works, with three things to know: | A divisor of `2^32 - 1` (3, 5, 15, 17, 51, 85, 255, 257, 65537, …) | 0 | | Anything else | Up to `N - 1` — `N = 48` drops 15, `N = 96` drops 33, `N = 121` drops 58 | - These are reported through `PollStats` like any other drop, and no stale or torn value is ever - returned — it is a data-loss bound, not a correctness one. One lost entry per `2^32` pushes is + These are reported through `PollStats` like any other drop, and no stale or torn value is + returned (within the whole-span bound stated in the SeqRing section and rustdoc) — it is a + data-loss bound, not a correctness one. One lost entry per `2^32` pushes is beneath the noise floor for anything that already tolerates overwrite, so a power of two is almost always the right call. `EventBuf` has no wrap boundary of this kind. diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md index 595910f..107a436 100644 --- a/docs/records/seq-ring.md +++ b/docs/records/seq-ring.md @@ -63,12 +63,15 @@ channel (`LatestBuf` is, with a race-free ownership argument). span is ~4.29 billion publications (~71.6 minutes at a sustained 1 MHz push rate; ~5 days at 10 kHz). The module docs carry the full disclosure — span, silent-zero case, reachability arithmetic — and - the structural escape hatches: bound the interval between ordered - polls (`poll_one` or a nonzero-budget `poll_up_to` resynchronizes, - as does `skip_to_latest`; `poll_up_to(0, …)` returns before touching - the resume point and the non-advancing `latest` never moves it), - bound mid-read preemption, or use `EventBuf`, which has no sequence - wrap. + the structural escape hatches, with the bound measured from the + *resume cursor*, not call cadence (a partial drain leaves residual + backlog that counts against the span): a nonzero ordered poll leaves + the cursor at most `N − 1` behind the newest it observed, so + publications between consecutive nonzero polls must stay below one + span minus `N − 1`; `skip_to_latest` zeroes the distance; + `poll_up_to(0, …)` and the non-advancing `latest` never move the + cursor. Bound mid-read preemption separately, or use `EventBuf`, + which has no sequence wrap. - **Loss is designed behaviour.** Overwrite is the overload policy; the counters report it, nothing prevents it. Consumers that must see every event belong on `EventBuf`. diff --git a/src/seq_ring.rs b/src/seq_ring.rs index 5ed8a39..4a8fb0b 100644 --- a/src/seq_ring.rs +++ b/src/seq_ring.rs @@ -143,11 +143,16 @@ //! ~4.29 billion publications. At a sustained 1 MHz push rate a poll gap must exceed ~71.6 //! minutes — and the mid-read stall must hold the consumer *between two instructions of one //! copy* for that long — before either case is reachable; at 10 kHz it is ~5 days. The escape -//! hatch is structural: bound the interval between ordered polls (`poll_one` or a -//! nonzero-budget `poll_up_to` resynchronizes the resume point — even one item drained runs the -//! lag-recovery jump to within `N` of newest — as does [`Consumer::skip_to_latest`]; -//! `poll_up_to(0, …)` returns before touching the resume point, and the non-advancing -//! [`Consumer::latest`] never moves it) below one span, and bound consumer preemption during a single +//! hatch is structural, and the bound is measured from the **resume cursor**, not from call +//! cadence: aliasing needs the distance from the resume cursor to the newest publication to +//! reach one whole span, and a partial drain leaves residual backlog that counts against it. A +//! nonzero ordered poll (`poll_one`, or `poll_up_to` with a nonzero budget) always leaves the +//! cursor at most `N - 1` behind the newest publication it observed — the lag-recovery jump +//! handles a lag over `N`, and draining even one item brings a lag of at most `N` below that — +//! so keeping the publications between consecutive nonzero polls below one span *minus* +//! `N - 1` suffices. [`Consumer::skip_to_latest`] zeroes the distance outright. +//! `poll_up_to(0, …)` returns before touching the resume cursor, and the non-advancing +//! [`Consumer::latest`] never moves it. Separately, bound consumer preemption during a single //! read to less than a span of publications. If neither bound can be stated for your system, //! [`crate::EventBuf`] has no sequence wrap of any kind. //! From 7db7a9e470f75b619cf231156f355484296cd997 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 21:03:22 -0400 Subject: [PATCH 65/87] Qualify the README's 8-instruction increment row as the below-MAX path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-8 Codex P2 on PR #33: the public summary presented 8 as the cost of increment without limiting it to the hot path, while the probe and proposal record the saturated sentinel arm at 9 — an integrator budgeting ISR latency could mistake the common-path measurement for the bound. The README now states both measured arms, derives the stale-MAX third arm (saturated plus one fetch_add, by construction), and labels all rows uncontended with a pointer to the contract's per-ISA realisation disclosure — matching the proposal, contract, and record wording. Co-Authored-By: Claude Fable 5 --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a4bb6da..4f9c2b8 100644 --- a/README.md +++ b/README.md @@ -329,8 +329,12 @@ them is a runtime step and always will be. - The sole `Send + !Sync` producer handle is part of the correctness contract. - Count operations do not publish unrelated application memory; payload data needs a separate synchronization mechanism. -- The reference Cortex-M3 probe measures 8 retired instructions for - `increment` and 7 for `take_count` (rustc 1.92.0, QEMU 10.0.11). +- The reference Cortex-M3 probe measures `increment` at 8 retired + instructions on the below-`MAX` hot path and 9 on the saturated sentinel + arm, and `take_count` at 7 (rustc 1.92.0, QEMU 10.0.11). The third arm — a + stale `MAX` re-read below `MAX` after a take — is the saturated arm plus + one `fetch_add` by construction; all rows are uncontended single-pass + counts (the contract discloses the per-ISA RMW realisation). ## Safety and Concurrency - `RingBuf` has no atomics and no interior mutability — standard Rust borrow rules apply. It stores slots as `MaybeUninit` and reads only live entries, so it does contain `unsafe`. From 1e8e362aaf1e5df2a8111a89da7b4571c41c2a63 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 21:10:23 -0400 Subject: [PATCH 66/87] State both increment arms in the canonical guide; fix the doctest inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-9 Codex P2/P3 on PR #33: the AGENTS cycle table and its summary still presented 8 instructions as increment's fixed count while the probe measures the saturated sentinel arm at 9 — both now name the two measured arms, derive the stale-MAX third, and carry the uncontended qualification with the per-ISA pointer, matching every other claim surface. The ordinary-doctest list also counted src/counted_signal.rs, which ships only its two compile_fail pins; the inventory now agrees with the source and its stated 13 total. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5baf7b5..26aa1f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -743,7 +743,7 @@ Run the default probe as well when changing shared measurement infrastructure. | `SeqRing::latest_value` | — | 30 | | | `RingBuf::push` | 20 | **20** (overwriting) | | | `RingBuf::get` / `latest` | 22 / 16 | | | -| `CountedSignal::increment` / `take_count` | 8 / 7 | | | +| `CountedSignal::increment` (below `MAX` / saturated arm) / `take_count` | 8, 9 / 7 | | | LatestBuf uses a separate payload matrix (`./scripts/verify.sh cycles latest-matrix`) so the larger stack shapes do not disturb the standing probe: @@ -774,9 +774,14 @@ Three results carry the argument: the second would be two orders of magnitude larger. It is a jump, and now that is measured rather than asserted. 3. **CountedSignal's SPSC hot paths are small and bounded.** `increment` - retires 8 instructions and `take_count` retires 7 in the reference - Cortex-M3 environment. The producer region contains no CAS retry loop; its - fixed count is the measured counterpart to the sole-producer no-wrap proof. + retires 8 instructions on the below-`MAX` common path and 9 on the + probe-seeded saturated sentinel arm; `take_count` retires 7 — all in the + reference Cortex-M3 environment, all uncontended single-pass counts (the + contract discloses the per-ISA RMW realisation and its hardware retry + bound). The producer region contains no CAS retry loop; the fixed + source-level sequence is the measured counterpart to the sole-producer + no-wrap proof. The stale-`MAX` third arm is the saturated arm plus one + `fetch_add` by construction. The rejected push is *cheaper* than an accepted one — backpressure is an early return, not extra work. @@ -985,7 +990,8 @@ cargo test **Doctests:** Six in `src/lib.rs` (the buffer types, `forward`, and the `try_*` bring-up), two in `src/macros.rs` (`static_spsc!` for `EventBuf` and `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, -`src/latest_buf.rs`, `src/block.rs`, `src/counted_signal.rs`, and `src/traits.rs`. +`src/latest_buf.rs`, `src/block.rs`, and `src/traits.rs` (`src/counted_signal.rs` +carries only its two `compile_fail` pins, no ordinary example). Total: 91 unit tests + 13 doctests, plus 9 `compile_fail` doctests: the `N == 0` rejection (`E0080`) on the three ring types and `BlockBuilder`, the deliberately absent `Source` impl on `LatestBuf`'s consumer (`E0277`, From f0e6994ce547ed61bc40d9bc91ac21236fef911a Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 21:10:48 -0400 Subject: [PATCH 67/87] Scope the state-pair gate to the default probe; match Xtensa narrow branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-9 Codex findings on PR #36. P1 (mine to own): the round-7 state-pair check ran unconditionally, but the three matrix modes deliberately compile the EventFlags regions out, so every documented matrix cycles command failed on missing ef rows after collecting all its measurements. The checks now run only for the default probe (PROBE_FEATURES empty). Verified in the reference image both ways: cycles latest-matrix exits 0 again, and the default run still enforces the pairs (12/12, 8/8). P2: Xtensa objdump prints density-encoded branches as beqz.n/bnez.n, which the straight-line detector's b[a-z]* missed before the required whitespace — a narrow branch outside the masked window could have passed the S2 gate. Both detector regexes now accept an optional .n suffix on branch and jump mnemonics; regex verified against synthetic beqz.n (match), bnez (still matches), and s32i.n (correctly ignored). Co-Authored-By: Claude Fable 5 --- scripts/cycles.sh | 32 ++++++++++++++++------------ scripts/event-flags-atomic-window.sh | 4 ++-- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/scripts/cycles.sh b/scripts/cycles.sh index 52cd22f..8159b04 100755 --- a/scripts/cycles.sh +++ b/scripts/cycles.sh @@ -265,20 +265,24 @@ printf '\n%s\n' "$report" # same on a clear flag as on an already-set one, and take_all the same # nonempty as empty. Printing divergent counts under a success footer would # un-pin exactly the property those four regions exist to hold, so a -# divergence (or a missing row) fails the run. -check_state_pair() { - op="$1"; a="$2"; b="$3" - va="$(printf '%s\n' "$report" | awk -v n="$a" '$0 ~ ("^ " n " +[0-9]+$") { print $NF; exit }')" - vb="$(printf '%s\n' "$report" | awk -v n="$b" '$0 ~ ("^ " n " +[0-9]+$") { print $NF; exit }')" - if [ -z "$va" ] || [ -z "$vb" ] || [ "$va" -ne "$vb" ]; then - printf '\nerror: EventFlags %s cost diverges by state: "%s" = %s, "%s" = %s.\n' \ - "$op" "$a" "${va:--}" "$b" "${vb:--}" >&2 - printf 'State-independence is a documented claim; divergence is a regression, not noise.\n' >&2 - exit 1 - fi -} -check_state_pair raise 'ef raise clear' 'ef raise already set' -check_state_pair take_all 'ef take nonempty' 'ef take empty' +# divergence (or a missing row) fails the run. Default probe only: the three +# matrix modes deliberately compile the EventFlags regions out, so there the +# rows are absent by design, not by regression. +if [ -z "$PROBE_FEATURES" ]; then + check_state_pair() { + op="$1"; a="$2"; b="$3" + va="$(printf '%s\n' "$report" | awk -v n="$a" '$0 ~ ("^ " n " +[0-9]+$") { print $NF; exit }')" + vb="$(printf '%s\n' "$report" | awk -v n="$b" '$0 ~ ("^ " n " +[0-9]+$") { print $NF; exit }')" + if [ -z "$va" ] || [ -z "$vb" ] || [ "$va" -ne "$vb" ]; then + printf '\nerror: EventFlags %s cost diverges by state: "%s" = %s, "%s" = %s.\n' \ + "$op" "$a" "${va:--}" "$b" "${vb:--}" >&2 + printf 'State-independence is a documented claim; divergence is a regression, not noise.\n' >&2 + exit 1 + fi + } + check_state_pair raise 'ef raise clear' 'ef raise already set' + check_state_pair take_all 'ef take nonempty' 'ef take empty' +fi printf '\n' printf 'Instructions retired on the guest, marker overhead subtracted.\n' diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh index 9e49e16..7c1ccdd 100755 --- a/scripts/event-flags-atomic-window.sh +++ b/scripts/event-flags-atomic-window.sh @@ -145,7 +145,7 @@ fi # Both portable paths must remain straight-line. Any branch in a hot function # means the claimed single critical section has acquired a retry/dispatch path. if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/thumbv6m.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|c(b|bn)z|call[0-9x]*|jx?|loop[a-z]*)[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*(\.n)?|c(b|bn)z|call[0-9x]*|jx?(\.n)?|loop[a-z]*)[[:space:]]'; then printf 'error: thumbv6m EventFlags hot path contains a branch.\n' >&2 exit 1 fi @@ -169,7 +169,7 @@ if [ "$want_esp" -eq 1 ]; then exit 1 fi if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/esp32s2.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*|call[0-9x]*|jx?|loop[a-z]*)[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*(\.n)?|call[0-9x]*|jx?(\.n)?|loop[a-z]*)[[:space:]]'; then printf 'error: ESP32-S2 EventFlags hot path contains a branch.\n' >&2 exit 1 fi From 065c9e2446a963d75be3e019cfce7d6268308ae6 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 21:11:52 -0400 Subject: [PATCH 68/87] State the aliasing bound at the resume cursor everywhere; fix skip_to_latest's allowance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-9 Codex P2s on PR #39, both correct refinements of my own earlier wording. The whole-span bullet still said 'exact for inter-poll gaps shorter than one span' one paragraph above the residual-backlog disclosure — with N = 4, a partial poll leaves the cursor three behind and 2^32 - 4 further publications alias with strictly less than one span between calls. Every remaining claim site (module doc bullet, record value statement, record risk bullet, record claim row, README pitch) now states exactness as the resume cursor staying within one span of the newest publication, with the call-cadence bound (span minus N - 1) as the sufficient condition. And skip_to_latest does not zero the distance: it sets last_seq = newest - 1 so the next poll yields the newest item, leaving exactly one — its post-call allowance is one span minus one, now stated in the module doc and record. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- docs/records/seq-ring.md | 14 +++++++++----- src/seq_ring.rs | 19 ++++++++++++------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5f1497a..777fd14 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ All types are fixed-size, `#![no_std]`, zero-allocation, and generic over `T: Co - **Predictability first.** No unbounded loops, no hidden allocation, and no panic reachable from a hot path. For the two SPSC types, no data loss that - cannot be observed either — every drop is reported (`SeqRing`, exact within - one sequence span; see its section) or prevented (`EventBuf`). `RingBuf` is the deliberate exception: it is a single-owner + cannot be observed either — every drop is reported (`SeqRing`, exact while the consumer's resume + cursor stays within one sequence span of the newest entry; see its section) or prevented (`EventBuf`). `RingBuf` is the deliberate exception: it is a single-owner window that overwrites silently, with no drop counter and no backpressure. Reach for it when losing the oldest entry is the point, not when delivery matters. diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md index 107a436..abf9385 100644 --- a/docs/records/seq-ring.md +++ b/docs/records/seq-ring.md @@ -19,8 +19,9 @@ newest (`latest`), and losses on the ordered path are counted. A consumer that falls more than `N` behind skips ahead and is told exactly how many items it lost (`PollStats::dropped`; the saturating `dropped_accum` is the origin of the crate's observable-loss convention). The exactness promise -belongs to the ordered `poll_*` paths only, and is exact for inter-poll -gaps shorter than one sequence span (`2^32 − 1` publications; §2): +belongs to the ordered `poll_*` paths only, and is exact while the +resume cursor stays within one sequence span (`2^32 − 1`) of the newest +publication (§2): `latest()` samples without advancing the cursor or reporting a gap, and `skip_to_latest()` discards the backlog *without* adding to the dropped counter — both are deliberate sampling/fast-forward semantics, disclosed @@ -54,7 +55,9 @@ channel (`LatestBuf` is, with a race-free ownership argument). limitation.** Sequence arithmetic is modular over the `2^32 − 1` nonzero span. A poll gap of exactly one whole span (or any multiple) aliases to "nothing new" and reports zero reads *and zero drops* — - loss accounting is exact only for gaps shorter than one span, and + loss accounting is exact only while the resume cursor stays within + one span of the newest publication (residual backlog from a partial + drain counts against that distance), and silence after an extreme stall is not evidence that nothing was lost. The same modularity bounds the seqlock re-check: a consumer preempted mid-copy for one whole span of publications passes both sequence @@ -68,7 +71,8 @@ channel (`LatestBuf` is, with a race-free ownership argument). backlog that counts against the span): a nonzero ordered poll leaves the cursor at most `N − 1` behind the newest it observed, so publications between consecutive nonzero polls must stay below one - span minus `N − 1`; `skip_to_latest` zeroes the distance; + span minus `N − 1`; `skip_to_latest` leaves the cursor exactly one + behind the newest it observed (allowance: one span minus one); `poll_up_to(0, …)` and the non-advancing `latest` never move the cursor. Bound mid-read preemption separately, or use `EventBuf`, which has no sequence wrap. @@ -94,7 +98,7 @@ channel (`LatestBuf` is, with a race-free ownership argument). | No torn value materialises as `T` (racy copies discarded before use) | Volatile access + `MaybeUninit` holding + re-check discipline; Loom models the sequence protocol (slot cells deliberately untracked — see the module's sync note); the dedicated Miri pass runs with the race detector **off** for this ring | Protocol-validated, not formally proven: the slot copy is a Rust data race (UB), so no checker validates the absolute claim — the discipline is argued in the module docs, widely deployed (Linux `seqlock_t`), and has no observed failure; span-bounded (counter-width ABA, §2) | | Fence placement is necessary (Release before value write; Acquire before re-check) | Module-doc argument; Loom; ordering discipline enforced by AGENTS' rerun-after-atomic-change rule | Proven | | Recovery cost is constant w.r.t. lag | 0.2.0 measurement: a consumer 2,000 sequences behind recovers in the same 115 instructions as one 16 behind | Measured | -| Loss accounting is exact and saturating (`read + dropped` accounts for every sequence within one span; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned, for inter-poll gaps shorter than one sequence span (§2) | +| Loss accounting is exact and saturating (`read + dropped` accounts for every sequence within one span; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned, while the resume cursor stays within one span of the newest publication (§2; sufficient: publications between consecutive nonzero polls below one span minus `N − 1`) | | Const-constructible in a `static`; RAM is `size_of`-computable per shape (payload array + `N` per-slot sequence atomics) | `const fn new` (constructibility is compiler-enforced); the 0.2.0 codesize probe demonstrates the crate's `.bss` discipline on an `EventBuf` static (268 B) — **no dedicated SeqRing static is probed or baseline-gated**, so section placement for SeqRing specifically is expected from the same all-zero layout, not measured | Constructibility by construction; `.bss` placement demonstrated for EventBuf only — a dedicated SeqRing static row is a reasonable promotion-time gate addition | ## 4. The record diff --git a/src/seq_ring.rs b/src/seq_ring.rs index 4a8fb0b..b836d64 100644 --- a/src/seq_ring.rs +++ b/src/seq_ring.rs @@ -126,12 +126,15 @@ //! computes is exact only up to that span. Two consequences follow — both inherent to any //! fixed-width seqlock at its counter width: //! -//! - **A whole-span poll gap reports nothing.** If exactly `2^32 - 1` publications (or any whole -//! multiple of that) land between two ordered polls, the published sequence returns to the -//! consumer's resume point and `poll_one`/`poll_up_to` take their nothing-new early return: -//! zero reads and zero drops. Longer gaps report only the remainder modulo the span. The -//! `read + dropped` conservation promise is therefore exact for inter-poll gaps *shorter than -//! one span*, and silence after an extreme stall is not evidence that nothing was lost. +//! - **A whole-span gap from the resume cursor reports nothing.** If the distance from the +//! consumer's resume cursor to the newest publication reaches exactly `2^32 - 1` (or any whole +//! multiple), the published sequence aliases the cursor and `poll_one`/`poll_up_to` take their +//! nothing-new early return: zero reads and zero drops. Larger distances report only the +//! remainder modulo the span. The `read + dropped` conservation promise is therefore exact +//! while the resume cursor stays within one span of the newest publication — residual backlog +//! from a partial drain counts against that distance, so this is *not* simply "fewer than one +//! span of publications between calls" (the sufficient call-cadence bound is below) — and +//! silence after an extreme stall is not evidence that nothing was lost. //! - **A whole-span mid-read stall defeats the sequence re-check.** The torn-copy guard compares //! the slot's sequence before and after the copy. A consumer preempted *inside* that copy for //! exactly one whole span of publications sees the same sequence value on both sides of a slot @@ -150,7 +153,9 @@ //! cursor at most `N - 1` behind the newest publication it observed — the lag-recovery jump //! handles a lag over `N`, and draining even one item brings a lag of at most `N` below that — //! so keeping the publications between consecutive nonzero polls below one span *minus* -//! `N - 1` suffices. [`Consumer::skip_to_latest`] zeroes the distance outright. +//! `N - 1` suffices. [`Consumer::skip_to_latest`] leaves the cursor exactly **one** behind the +//! newest it observed (so the next poll yields that newest item); its post-call allowance is +//! therefore one span minus one, not a full span. //! `poll_up_to(0, …)` returns before touching the resume cursor, and the non-advancing //! [`Consumer::latest`] never moves it. Separately, bound consumer preemption during a single //! read to less than a span of publications. If neither bound can be stated for your system, From 49cf88af503cdb5bbfbbdc3b2be9a9250cb9c4a5 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Tue, 11 Aug 2026 21:31:49 -0400 Subject: [PATCH 69/87] Integration pass: coherent 0.3.0 changelog, assembled-tree counts, SeqRing .bss gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five lanes' parallel Unreleased entries are rewritten as one coherent 0.3.0 changelog: Added describes the four shipped types and the measurement infrastructure as a user reads them (LatestBuf's D2 is stated as the closed decision it is, not a deferred assumption); Changed carries the opaque Debug policy for the destructive-take types; Fixed keeps only what was broken in 0.2.0 (loom.sh filter scoping and vacuous-run guard, the RingBuf doc sentence); Removed keeps the breaking constructor entry; Documentation carries the SeqRing whole-span disclosure, the cycle-decision closures, the crate-wide source-level boundedness framing, and the EventBuf record corrections. Pre-release fix history of never-released types folds into their Added entries. Count reconciliation against the assembled tree: the merged probe binary measures both destructive takes two instructions higher than the per-lane trees (take_count 9, take_all 10; raises and increment arms unchanged) — every documented row now carries the assembled-tree number with the context noted. The four lane records' per-lane CI totals are reframed as at-acceptance history superseded by the assembled matrix. SeqRing .bss placement is now measured, not inferred: a dedicated SeqRing probe static (524 B) joins the default codesize mode as the seq_bss row, baseline-gated on all eight targets — the promotion-time gate addition the record flagged; the deliberate bless adds exactly the eight new rows and the gate passes against it. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 8 +- CHANGELOG.md | 297 ++++++++++------------ README.md | 3 +- docs/proposals/counted-signal-contract.md | 2 +- docs/proposals/counted-signal.md | 7 +- docs/proposals/event-flags.md | 9 +- docs/records/block-buf.md | 2 +- docs/records/counted-signal.md | 14 +- docs/records/event-flags.md | 2 +- docs/records/latest-buf.md | 2 +- docs/records/seq-ring.md | 2 +- scripts/codesize.sh | 14 +- scripts/codesize/baseline.tsv | 8 + scripts/codesize/src/lib.rs | 9 +- 14 files changed, 194 insertions(+), 185 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d900988..5d8451f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -774,9 +774,9 @@ Run the default probe as well when changing shared measurement infrastructure. | `SeqRing::latest_value` | — | 30 | | | `RingBuf::push` | 20 | **20** (overwriting) | | | `RingBuf::get` / `latest` | 22 / 16 | | | -| `CountedSignal::increment` (below `MAX` / saturated arm) / `take_count` | 8, 9 / 7 | | | +| `CountedSignal::increment` (below `MAX` / saturated arm) / `take_count` | 8, 9 / 9 | | | | `EventFlags::raise` | 12 (clear) | **12** (already set) | | -| `EventFlags::take_all` | — | 8 (non-empty) | 8 (empty) | +| `EventFlags::take_all` | — | 10 (non-empty) | 10 (empty) | LatestBuf uses a separate payload matrix (`./scripts/verify.sh cycles latest-matrix`) so the larger stack shapes do not disturb the standing probe: @@ -808,7 +808,7 @@ Four results carry the argument: that is measured rather than asserted. 3. **CountedSignal's SPSC hot paths are small and bounded.** `increment` retires 8 instructions on the below-`MAX` common path and 9 on the - probe-seeded saturated sentinel arm; `take_count` retires 7 — all in the + probe-seeded saturated sentinel arm; `take_count` retires 9 — all in the reference Cortex-M3 environment, all uncontended single-pass counts (the contract discloses the per-ISA RMW realisation and its hardware retry bound). The producer region contains no CAS retry loop; the fixed @@ -816,7 +816,7 @@ Four results carry the argument: no-wrap proof. The stale-`MAX` third arm is the saturated arm plus one `fetch_add` by construction. 4. **EventFlags is constant across condition state.** Raise is 12 instructions - whether the bit is clear or already set; take is 8 whether non-empty or + whether the bit is clear or already set; take is 10 whether non-empty or empty. `./scripts/verify.sh atomic-window` (and the default verify matrix) additionally pins the thumbv6m straight-line masked window at 4 instructions. ESP32-S2 (5) and ESP32-S3 (0 under native `S32C1I`) remain diff --git a/CHANGELOG.md b/CHANGELOG.md index 648f902..3439bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,65 +3,113 @@ All notable changes to this project will be documented in this file. ## Unreleased -### Documentation (engineering records) -- Engineering records for the 0.2.0 types touched by the constructor removal: - [`records/seq-ring.md`](docs/records/seq-ring.md) — with the seqlock deviation, the wrap - extra-drop limitation, and the 115-instruction constant-recovery claim stated in the - briefing layer — and [`records/event-buf.md`](docs/records/event-buf.md) — the fully - race-free backpressure arm, now also the queued transport of the D3 block composition. - `RingBuf` (doc-touched only this cycle) receives its record at its next material touch. -### Fixed -- `CountedSignal::increment`: replace the load-and-skip-on-`MAX` short-circuit - with a no-op RMW (`fetch_or(0)`) re-read that treats observed `MAX` as - maybe-stale — an RMW observes the latest value in modification order, so a - `MAX` re-read confirms saturation and anything else proceeds into the - post-take epoch, with no compare-exchange and no LR/SC retry loop on - RISC-V. A completed `take_count` followed by a later `increment` can no - longer lose the occurrence under Relaxed observation (contract T3/A1). Loom - litmus: `counted_signal_post_take_increment_observes_reset_epoch`. -### Documentation -- CountedSignal engineering record (`docs/records/counted-signal.md`) — Track 1 acceptance package: value statement, integrator risks (saturation, sole-producer exclusivity as load-bearing for no-wrap), claims×evidence including the pinned Cortex-M3 rows (confirmed 8 / 7 after the sentinel-RMW fix), and the H closure record. -- CountedSignal saturated sentinel arm is now its own measured QEMU region - (`cs increment saturated`, 9 retired instructions on Cortex-M3), seeded via a - hidden `_cycles-probe` feature and a `#[doc(hidden)]` constructor — the arm is - unreachable through the public API in bounded time, and was previously a - source-review claim only. Hot-path rows are byte-identical with the region - isolated in its own frame. -- CountedSignal Cortex-M3 cycle rows re-confirmed at 8 / 7 after the sentinel-RMW - fix (`./scripts/verify.sh cycles`, QEMU 10.0.11); pending-remeasure markers cleared. -- CountedSignal contract B1: fixed instruction sequence with a no-op RMW - sentinel re-read — no compare-exchange, no retry; proposal §3.1 - linearization claim corrected; README Sink/Source claim qualified to - payload-buffer handles. ### Added -- `CountedSignal`: a payload-free SPSC counter with a bounded - `increment`, atomic `take_count`, exact `u32` saturation, and observable - saturation. Loom models pin take partitioning, the saturation-boundary - interleaving, and the post-take stale-`MAX` litmus; its frozen contract maps - citable clauses to unit, threaded, Loom, Miri, code-size, and QEMU evidence. - Exact bounded saturation depends on retaining a sole `Send + !Sync` producer - handle. Cortex-M3 instruction counts remain 8 / 7 after the sentinel-RMW - path (`./scripts/verify.sh cycles`). -- Evaluable `LatestBuf` prototype: a three-slot, freshness-first SPSC - snapshot channel with bounded single-swap publication and at-most-one-swap - take operations, - replacement and skipped-generation evidence, and channel-resident endpoint - state so handle reacquisition continues rather than restarting. Deferred - assumptions are exact accounting within one non-zero `u32` wrap (approximate - beyond it), no `Source` implementation, and generic payloads supporting - samples or complete blocks. -- LatestBuf target/payload measurement mode for all 11 embedded targets plus - pinned QEMU instruction regions. The measured A.1 Acquire-load fast path - removes the atomic RMW from empty polls for +6-16 bytes of `take_latest` - flash, including +8 bytes on ESP32-S2. Private role indices now have an - all-zero encoding, moving const-initialized channels from `.data` to `.bss` - and removing 48-420 bytes of flash/startup copy in the measured payloads. -- Joint LatestBuf/BlockBuf D3 measurement mode over 2/8/16-byte samples and - `N = 8/32/128`, covering all 11 targets plus pinned QEMU regions. It records - sample scheduling, final block completion/publication, consumer cost, and - 136-8,280 bytes of combined channel/builder RAM without stacking candidate - branches. +- `LatestBuf` — a three-slot, freshness-first SPSC snapshot channel for + state where the newest value beats FIFO delivery. Publication is one bounded + slot swap and never rejects; it returns a `PublishReport` whose + `replaced_unread` flag is the producer-side loss evidence. Taking is at most + one swap behind an Acquire-load empty-poll fast path (measured decision + A.1), and returns the newest complete value with its generation and an exact + `skipped` count while the consumer's resume generation stays within one + non-zero `u32` span of the newest publication — the span boundary and its + silent-zero case are contract non-promise X6. Endpoint state is + channel-resident, so dropping a handle in one context and reacquiring in + another *continues* the generation and accounting sequence rather than + restarting it (decision A.3; the role-recovery boundary is non-promise X8). + The consumer deliberately does not implement `Source`: `try_pop` cannot + report displacement, and displacement is this channel's designed overload + behaviour, so `LatestSink`/`LatestSource` are the contract surface (decision + D2, non-promise X7) and a compile-fail doctest keeps a convenience impl from + arriving silently. Payloads are generic, including complete blocks + (`LatestBuf>` — decision D3 confirmed composition over a + dedicated `LatestBlockBuf`). Evidence: five Loom models (ownership transfer, + slot reuse, empty-poll preservation, both cross-context role handoffs, with + at-most-once, `skipped`, and `replaced_unread` conservation asserted + in-model and mutation-verified), race-detector-**on** Miri as the headline + soundness claim, an 11-target code-size matrix, and pinned QEMU instruction + regions. +- `Block` and `BlockBuilder` — complete, contiguous sample + windows without another queue policy. The builder accumulates sequenced + samples privately, rejects gaps explicitly (`FillError` returns the sample + to the caller without disturbing the partial block), skips reserved + sequence zero at wrap, and yields a `Block` only when all `N` samples are + present; clearing or dropping a partial builder publishes nothing. Compose + with the overload policy you need: `EventBuf, Q>` queues and + rejects when full, `LatestBuf>` retains the newest. Publication + copies the complete block (decision P: Copy composition) — the measured + per-shape rows are the budget statement (159–8,658 reference instructions + across the 2/8/16-byte × N = 8/32/128 grid; rejection within 5–31 + instructions of acceptance; per-shape RAM disclosed), the small-`N` cost + inversion is documented, and the double-copy DMA hazard is stated in the + module docs: the builder's storage is deliberately private, so budget both + copies or publish from task context. Both `Block` and the fill error are + `#[must_use]`. +- `EventFlags` — a coalescing SPSC condition set for ISR-to-task + notification. Exactly 32 payload-free conditions in a transparent + `EventMask(u32)`; the producer raises with one Release `fetch_or`, the + consumer returns-and-clears with one Acquire `swap(0)`. Duplicate raises + may coalesce; a raise racing a take is never lost between windows; a take + that observes a raise also observes memory writes sequenced before it. + There is deliberately no non-clearing peek (`Debug` is opaque for the same + reason) and no stream-trait implementation. Evidence: three Loom models + including a publication litmus whose Release and Acquire mutation checks + both fail as intended, detector-on Miri, eight gated plus three opt-in + Xtensa code-size rows, four QEMU regions with the state pairs enforced as + a gate, and interrupt-window gates: the thumbv6m masked window (4 + instructions) is checked by `./scripts/verify.sh atomic-window`, with + ESP32-S2 (5) and ESP32-S3 (0 under native `s32c1i`) opt-in via `ESP=1`. +- `CountedSignal` — a saturating, payload-free SPSC counter for events whose + multiplicity matters but whose payload and ordering do not. `increment` is + a Relaxed load plus one `fetch_add` below `u32::MAX`; an observed `MAX` is + maybe-stale and is confirmed through a no-op `fetch_or(0)` re-read — an + RMW observes the latest value in modification order — so saturation is + exact, the counter never wraps, and no path contains a compare-exchange or + algorithmic retry (contract B1 discloses the per-ISA realisation of each + single RMW, including the contention-bounded LDREX/STREX pairs on + exclusive-monitor Arm). `take_count` is one Relaxed `swap(0)` returning a + `CountSnapshot` with an observable saturation flag. Exactness rests on the + sole `Send + !Sync` producer handle — the exclusivity is the no-wrap + proof, not API style — and `Debug` is opaque (a printed count would be a + non-clearing peek). Evidence: three Loom models including the post-take + stale-`MAX` litmus, detector-on Miri, gated code-size rows, and measured + QEMU regions for all reachable arms (8 below-`MAX`, 9 saturated via a + probe-only seeding hook, 9 take, on the assembled 0.3.0 tree). +- Engineering records (`docs/records/`, one per shipped type): a value + statement, integrator-facing risks, technical claims mapped to their + validating evidence, then the working record — the enduring briefing layer + over the contracts and proposals. Records ship for `LatestBuf`, `Block`/ + `BlockBuilder`, `EventFlags`, `CountedSignal`, `SeqRing`, and `EventBuf` + (`RingBuf`, doc-touched only, receives its record at its next material + touch). A candidate lane owes its record as part of its acceptance package. +- Measurement and verification infrastructure for the above: `latest-matrix`, + `latest-block-matrix`, and `block-matrix` modes in `codesize.sh` and + `cycles.sh` (each isolated behind its own probe feature); the block-payload + code-size baseline (`baseline-block.tsv`) blessed at promotion and gated + from `ci.sh`; a probe-seeded region for CountedSignal's saturated arm + (hidden `_cycles-probe` feature — the arm is unreachable through the public + API in bounded time); a cycles gate that fails when the EventFlags state + pairs diverge; and the EventFlags interrupt-window gate wired into + `verify.sh`. + +### Changed +- `Debug` for the two destructive-take signal types (`EventFlags`, + `CountedSignal`) prints the type opaquely: exposing the pending mask or + live count through formatting would be the advisory peek both frozen APIs + reject, without the take's ordering guarantees. + +### Fixed +- `scripts/loom.sh`: a bare name filter is scoped to `loom_tests::` + instead of being passed as a second positional Cargo filter (leading `-` + flags still pass through), and a run is only reported verified when the + harness's own summary shows at least one model actually ran — a misspelled + filter or a selector like `-- --ignored` that matches nothing now fails + instead of printing the success banner. +- `RingBuf::new`'s docs no longer mention a `pop` method the type does not + have — `pop` was deliberately rejected (its data loss would be unreportable + under overwrite; see the worked rejection in AGENTS.md). Found just after + `0.2.0` published, so the docs.rs 0.2.0 pages carry the sentence. + ### Removed - **Breaking:** the panicking `SeqRing::{producer, consumer}` and `EventBuf::{producer, consumer}`, deprecated since 0.2.0 with removal scheduled for 0.3.0. @@ -75,106 +123,41 @@ All notable changes to this project will be documented in this file. shipped orderings. ### Documentation -- EventFlags engineering record (`docs/records/event-flags.md`) — Track 1 acceptance package: value statement, integrator risks (coalescing, sole-role H, no peek/traits, measured portable-atomic windows), claims×evidence, and the closed decision set (H/D2–D5). -### Added -- `EventFlags` — a coalescing SPSC condition set for ISR-to-task notification. Exactly 32 - payload-free conditions are represented by a transparent `EventMask(u32)`; the producer raises - with one Release `fetch_or`, and the consumer atomically returns and clears the set with one - Acquire `swap(0)`. Duplicate raises may coalesce, but a raise racing a take is never lost between - windows. Handles follow the accepted signal-lane doctrine: sole-role `Send + !Sync` values with - `&self` hot-path operations and fallible, non-panicking acquisition. -- EventFlags admission evidence: three Loom models (including a publication litmus whose Release - and Acquire mutation checks both fail as intended), detector-on Miri coverage, eight gated and - three opt-in Xtensa code-size rows, four Cortex-M3 instruction regions, and a reproducible - portable-atomic disassembly check. The thumbv6m masked window (4 instructions) is gated by - `./scripts/verify.sh atomic-window`; ESP32-S2 (5) and ESP32-S3 (0 under native `s32c1i`) stay - opt-in via `ESP=1` because the reference Docker image does not ship esp-rs. - -### Fixed -- `scripts/loom.sh ` now scopes a bare name filter to `loom_tests::` instead of - passing a second positional test filter to Cargo. Leading Cargo/test flags (arguments that - start with `-`) pass through unchanged, so forms like `./scripts/loom.sh -- --nocapture` are - not rewritten into a no-op `loom_tests::--` filter. -- `EventFlags` object-size claim corrected from 12 B to the measured 8 B (`size_of` unit assert); - AGENTS.md role-claim wording aligned with the AcqRel `swap` implementation. -- ESP32-S3 opt-in atomic-window gate now requires native `s32c1i` inside - `event_flags_raise` and `event_flags_take` specifically; a whole-object count - could pass on `bringup_two_calls` / `event_flags_acquire_roles` alone. -- BlockBuf engineering record (`docs/records/block-buf.md`) — Track 1 acceptance package: composition identity under closed D3, measured publication costs (`bc54a9a`), joint composition rows. Its status header initially recorded promotion as waiting on decision P; P has since closed as Copy composition and the record reads DECISION-COMPLETE. -### Added -- `Block` and `BlockBuilder` provide complete, contiguous sample - windows without introducing another queue policy. The builder rejects gaps - explicitly, skips reserved sequence zero at wrap, and yields a public block - only after all `N` samples are initialized. Compose blocks with - `EventBuf, Q>` today or the proposed `LatestBuf>` for - freshness-first handoff. - -### Documentation -- Cycle decisions **P** and **S** are closed (2026-08-11). **P**: BlockBuf's publication - foundation is **Copy composition** for the supported shapes — no library-wide threshold is - claimed; the per-shape measured rows are the budget statement, the nine-shape matrix rides - into the release baselines at promotion, and the docs owe integrators the double-copy - hazard guidance (the builder's storage is deliberately private — budget both copies, or - publish from task context; direct-to-granted-slot filling is decision S's registered - reopening condition). **S**: - SlotPool is **deferred**, not rejected — full evaluation evidence banked on its branch, - draft PR closed, and an adopter-gated reopening trigger registered (a measured budget - breach, a direct-to-granted-slot requirement, or a standalone zero-copy adopter). Every - decision in the 0.3.0 cycle is now settled. -- **Engineering records established** (`docs/records/`, maintainer decision 2026-08-11): one - enduring document per shipped type — a short value statement, risks and integration - concerns in integrator terms, technical claims mapped to their validating evidence, then - the working record (decisions, measurements, rejected alternatives). Not rustdoc - duplication: rustdoc says how to use a type; the record says why it is trustworthy and - what it cost. A candidate lane owes its record as part of its acceptance package; - [`records/latest-buf.md`](docs/records/latest-buf.md) is the exemplar. The 0.2.0 types - receive theirs when next materially touched. -- `LatestBuf` contract: decision **D1** (wrap-ambiguity policy) is closed as options - (a) + (c) — `skipped` is one formula (wrap-aware distance minus one, saturated at zero): - exact within one wrap span, a documented under-count beyond it, and callers whose - requirement is the count itself are pointed at a wider producer-assigned payload sequence - or a saturating counter. The wrap family (C3, C5, O2) now carries its beyond-span text, - and a new non-promise **X6** states the limitation for adopters: the boundary is a - rate × take-interval property crossed exactly when a full span of publications separates - two takes, the channel deliberately does not detect the crossing, and a full-cycle gap - reports zero. Option (b) — an explicit - "wrapped/unknown" state — is rejected on the record: it prices wrap detection into every - hot-path operation and still cannot recover the lost count. -- `LatestBuf` contract: decision **D2** (`Source` policy) is closed as the proposal's - option 1 — the consumer does not implement `Source`; `LatestSink`/`LatestSource` were - designed as the type's contract surface, solving what the existing traits structurally - could not (`try_pop` cannot report displacement, and displacement is the type's *designed* - overload behaviour, making the `RingBuf::pop` rejection apply with more force). New - non-promise **X7** states the substitution limitation honestly: no generic pipeline - composition without a caller-written adapter — discarding loss evidence is an application - decision, never the transport's. A convenience `Source` impl remains an additive, - adopter-evidence-gated future decision, and a compile-fail pin keeps it from arriving - silently. -- `LatestBuf` contract: decision **D3** (first deliverable form) is closed as the convergent - answer from both coupled lanes — payload-agnostic `LatestBuf`, with a complete block as - a payload (`LatestBuf>` latest / `EventBuf, Q>` queued / caller - policy for drop-new) and no separate `LatestBlockBuf`. The joint composition matrix is the - acceptance measurement set for both shapes; the closure binds a documentation obligation - on the block-payload surfaces (per-shape RAM, the small-`N` cost inversion, and the - no-partial-block limitation) and registers the only reopening condition: a separate block - transport must enforce a guarantee composition cannot, behind cycle decisions P/S. All - three contract decision points are now closed. -- `LatestBuf` proposal: review caveat **A.3** (handle-state continuation) is closed as - channel-resident role state with stateless handles — the crate's stateless-handle - precedent and the sole-role doctrine of decision H, validated by drop-and-reacquire - continuation tests, both cross-context role-handoff Loom models, detector-on Miri, and - all four role-handoff ordering-mutation detections. Persist-on-drop is considered and - not selected (Drop-time state copy is a permanent failure surface; its L3 model does not - isolate the taken-flag handoff; register residency unmeasured), and narrowing H2's - registered condition was not met. New contract non-promise **X8** states the - role-recovery boundary for integrators: reacquisition requires the previous handle's - drop, handle lifetime is an application property, and there is deliberately no - out-of-band role reset because a forced release would break the exclusivity soundness - rests on — the facts of the exchange, informing downstream design without prescribing it. -- `RingBuf::new`'s docs no longer mention a `pop` method the type does not have — `pop` was - deliberately rejected (its data loss would be unreportable under overwrite; see the worked - rejection in AGENTS.md). Found by review on the release PR just after `0.2.0` published, so - the `0.2.0` docs on docs.rs carry the sentence; the fix rides out with the next publish. +- `SeqRing`'s two headline guarantees now carry their whole-span bound in the + rustdoc, README, and record: sequence arithmetic is modular over the + `2^32 − 1` non-zero span, so loss accounting is exact while the consumer's + resume cursor stays within one span of the newest publication (a nonzero + ordered poll leaves the cursor at most `N − 1` behind the newest it + observed; `skip_to_latest` leaves exactly one), and the seqlock torn-copy + discard argument shares the same counter-width ABA bound for a consumer + stalled mid-read. The module docs state the reachability arithmetic and + structural escape hatches; the record's torn-value claim is stated as + protocol-validated rather than proven, since the deliberate formal race is + exactly what Loom's untracked cells and the detector-off Miri pass cannot + check. +- The 0.3.0 cycle decisions are closed on the record with their canonical + text in the contracts and planning documents: **D1** (wrap policy: + one formula, exact within a span, X6 beyond), **D2** (no `Source`; + `LatestSink`/`LatestSource` are the designed surface, X7), **D3** + (composition, no `LatestBlockBuf`), **A.1** (Acquire-load empty poll, + measured), **A.3** (channel-resident role state, X8), **H** (sole-role + `Send + !Sync` handle doctrine, crate-uniform), **P** (Copy composition; + the per-shape measured rows are the budget statement), and **S** (SlotPool + deferred, not rejected — evidence banked, adopter-gated reopening trigger + registered: a measured budget breach, a direct-to-granted-slot requirement, + or a standalone zero-copy adopter). +- Boundedness claims are stated at source level crate-wide with the per-ISA + realisation disclosed: one source-level atomic RMW has no algorithmic + retry, and on exclusive-monitor Arm it is an LDREX/STREX pair that repeats + only when an intervening event claims the word — contention-bounded, not a + static instruction count; the measured rows are the uncontended + realisations. +- `EventBuf`'s record corrects its block-RAM budgeting formula to + `size_of::, Q>>()` (the naive `Q × block` product + omits cursors, flags, and padding — the probe static itself shows 268 + measured against 256 payload bytes) and scopes `len`'s consistency claim to + a successful bracketed sample, with the bounded clamped-estimate fallback + named. ## 0.2.0 - 2026-08-10 diff --git a/README.md b/README.md index 5a137f2..28a95cd 100644 --- a/README.md +++ b/README.md @@ -371,7 +371,8 @@ them is a runtime step and always will be. needs a separate synchronization mechanism. - The reference Cortex-M3 probe measures `increment` at 8 retired instructions on the below-`MAX` hot path and 9 on the saturated sentinel - arm, and `take_count` at 7 (rustc 1.92.0, QEMU 10.0.11). The third arm — a + arm, and `take_count` at 9 (rustc 1.92.0, QEMU 10.0.11, measured on the + assembled 0.3.0 tree). The third arm — a stale `MAX` re-read below `MAX` after a take — is the saturated arm plus one `fetch_add` by construction; all rows are uncontended single-pass counts (the contract discloses the per-ISA RMW realisation). diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index b90904f..06c9823 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -146,7 +146,7 @@ a separate maintainer decision on issue #26. | I2–I3, T2, A2–A3 | `saturates_instead_of_wrapping`; Loom's `counted_signal_saturation_boundary_is_linearizable` model | | T1–T3, A1 | Loom's `counted_signal_take_partitions_increments` model; threaded take stress | | T3, A1 (post-take / stale MAX) | Loom's `counted_signal_post_take_increment_observes_reset_epoch` model (seeded at `MAX`; Relaxed gate only) | -| B1–B2 | Source review (fixed sequence under H1 — no compare-exchange, no retry); eight-target gated code-size rows blessed post-fix (26–64 B `increment`); Cortex-M3 cycles measured at 8 (hot path) / 9 (saturated sentinel arm, seeded via the hidden `_cycles-probe` feature) / 7 (take) — `./scripts/verify.sh cycles`, QEMU 10.0.11 | +| B1–B2 | Source review (fixed sequence under H1 — no compare-exchange, no retry); eight-target gated code-size rows blessed post-fix (26–64 B `increment`); Cortex-M3 cycles measured at 8 (hot path) / 9 (saturated sentinel arm, seeded via the hidden `_cycles-probe` feature) / 9 (take; assembled-0.3.0-tree measurement, 7 on the per-lane tree) — `./scripts/verify.sh cycles`, QEMU 10.0.11 | | B3 | Source review plus normal, Miri, Loom, and embedded-target executions of both hot paths | | H1, H3 | `handles_are_exclusive_and_reusable_after_drop`, including state continuation after reacquisition | | H2 | `handles_are_send`; producer and consumer compile-fail doctests pin `!Sync` | diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index 869109e..6aba555 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -171,9 +171,12 @@ no-op `fetch_or(0)` confirm, return): |---|---:| | `increment` (below `MAX`, the hot path) | 8 | | `increment` (saturated sentinel arm) | 9 | -| `take_count` | 7 | +| `take_count` | 9 | -The third arm — a stale `MAX` re-read below `MAX` after a completed take — +Rows re-measured on the assembled 0.3.0 release branch; the merged probe +binary carries `take_count` two instructions higher than the per-lane tree's +7 (codegen context, not an algorithm change), while both `increment` arms +are unchanged. The third arm — a stale `MAX` re-read below `MAX` after a completed take — needs a racing consumer a single-hart deterministic trace cannot express; it is the saturated arm plus one `fetch_add` by construction and is bounded by the gated whole-function code size. All three measured rows are uncontended diff --git a/docs/proposals/event-flags.md b/docs/proposals/event-flags.md index 04db119..b64b016 100644 --- a/docs/proposals/event-flags.md +++ b/docs/proposals/event-flags.md @@ -148,11 +148,14 @@ Measured by `./scripts/verify.sh cycles` in the pinned reference image: rustc |---|---|---:| | `raise` | condition clear | 12 | | `raise` | condition already set | 12 | -| `take_all` | non-empty | 8 | -| `take_all` | empty | 8 | +| `take_all` | non-empty | 10 | +| `take_all` | empty | 10 | The equal state pairs pin the intended constant hot path: neither occupancy -nor number of set bits changes the executed work. +nor number of set bits changes the executed work. Rows re-measured on the +assembled 0.3.0 release branch; the merged probe binary carries `take_all` +two instructions higher than the per-lane tree's 8 (codegen context, not an +algorithm change), while both `raise` rows are unchanged. ### 4.4 Portable-atomic interrupt window diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index a3011ad..a5ac5c9 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -107,7 +107,7 @@ section and contract IDs in parentheses; those texts are normative. | No block-layer atomics; transport evidence unchanged (§8) | Block path: unit + Miri; LatestBuf/EventBuf: detector-on Miri and Loom on selected transport | Proven (transport) | | Copy vs SlotPool foundation (P / S) | Matrix complete (`bc54a9a`); read against the #26 integration scoping; per-shape rows adopted as the budget statement | **Closed — P = Copy composition, S = deferred (2026-08-11); nothing blocks promotion but the #34 acceptance review** | -Full CI for the lane: 76 unit tests, 12 doctests, 4 compile-fail, 8 +Full CI at lane acceptance (per-lane tree; the assembled 0.3.0 release matrix supersedes these totals): 76 unit tests, 12 doctests, 4 compile-fail, 8 gated codesize targets, 3 embedded checks, 6 Miri passes, 5 Loom models. diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index ead1c90..ffbdc31 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -7,8 +7,10 @@ - **Normative sources:** [contract](../proposals/counted-signal-contract.md) (clause IDs cited below) · [proposal](../proposals/counted-signal.md) · measurements inline in proposal §3.1 (eight-target code size; pinned - Cortex-M3 cycles via `./scripts/verify.sh cycles`, confirmed 8 / 7 - post-fix; saturated sentinel arm measured at 9). + Cortex-M3 cycles via `./scripts/verify.sh cycles`: 8 hot path, 9 + saturated sentinel arm, 9 take on the assembled 0.3.0 tree — the + merged probe binary carries the take two instructions higher than the + per-lane tree's 7; the increment arms are unchanged). ## 1. Value statement @@ -78,18 +80,18 @@ IDs in parentheses; the clauses are the normative statements. | Saturates at `u32::MAX`, never wraps; sticky until take (I2–I3, T2, A2–A3) | `saturates_instead_of_wrapping`; Loom `counted_signal_saturation_boundary_is_linearizable` | Proven | | Concurrent increment belongs to exactly one take interval (T1–T3, A1) | Loom `counted_signal_take_partitions_increments`; threaded take stress | Proven | | Post-take increment after saturated take is not dropped by a stale MAX observe (T3, A1) | Loom `counted_signal_post_take_increment_observes_reset_epoch` (Relaxed gate only) | Proven | -| Bounded source-level hot paths, no algorithmic retry: load + `fetch_add`; sentinel no-op RMW re-read + ≤1 follow-up `fetch_add`; `swap(0)` take — per-ISA realisation per B1 (LDREX/STREX pairs on exclusive-monitor Arm may repeat under contention, so this is not a wait-free or consumer-independent-latency claim) (B1–B2) | Source review; riscv32imac disassembly shows lw/amoor.w/amoadd.w with **no lr.w/sc.w**; eight-target code size re-blessed post-RMW; Cortex-M3 cycles 8 (hot) / 9 (saturated arm, probe-seeded region) / 7 (take); the stale-MAX third arm is the saturated arm + one `fetch_add` by construction | Measured | +| Bounded source-level hot paths, no algorithmic retry: load + `fetch_add`; sentinel no-op RMW re-read + ≤1 follow-up `fetch_add`; `swap(0)` take — per-ISA realisation per B1 (LDREX/STREX pairs on exclusive-monitor Arm may repeat under contention, so this is not a wait-free or consumer-independent-latency claim) (B1–B2) | Source review; riscv32imac disassembly shows lw/amoor.w/amoadd.w with **no lr.w/sc.w**; eight-target code size re-blessed post-RMW; Cortex-M3 cycles 8 (hot) / 9 (saturated arm, probe-seeded region) / 9 (take, assembled 0.3.0 tree; 7 on the per-lane tree — merged-binary codegen, not an algorithm change); the stale-MAX third arm is the saturated arm + one `fetch_add` by construction | Measured | | No panic reachable from hot paths (B3) | Source review; normal, Miri, Loom, and embedded-target executions | Proven | | Sole-role exclusive handles; reacquisition continues state (H1, H3) | `handles_are_exclusive_and_reusable_after_drop` | Proven | | Handles are `Send + !Sync` (H2) | `handles_are_send`; compile-fail doctests pin `!Sync` on both roles | Pinned | | Constructible in static storage (H4) | `const_new_works_in_static_context` | Proven | | Cost claims per target | Eight-target gated code-size rows (proposal §3.1), re-blessed after the sentinel-RMW change | Measured | -Full CI for the lane (`0a22ada` admission package; `f26d4c3` H +Full CI at lane acceptance (per-lane tree; the assembled 0.3.0 release matrix supersedes these totals) (`0a22ada` admission package; `f26d4c3` H finalization): complete pinned `./scripts/verify.sh` matrix with zero skips — unit tests, 11 doctests, 5 compile-fail, coverage, Miri host and proxy targets, Loom models, eight-target code size, embedded -checks, QEMU cycles. Cycles re-confirmed post-fix at 8 / 7, with the +checks, QEMU cycles. Cycles on the assembled 0.3.0 tree: 8 / 9, with the saturated sentinel arm added as its own probe-seeded region at 9; full matrix not re-run for this cycles-doc follow-up. @@ -146,7 +148,7 @@ Contract clause IDs (I/T/A/B/H/X) are load-bearing for tests and models **Where the numbers live:** proposal §3.1 (eight-target `increment` / `take_count` code-size table; pinned Cortex-M3 instruction regions under -rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11 — confirmed 8 / 9 / 7 +rustc 1.92.0 `ded5c06cf`, LLVM 21.1.3, QEMU 10.0.11 — 8 / 9 / 9 on the assembled 0.3.0 tree after the sentinel-RMW fix); contract §9 evidence map; tracking: issue #30, PR #33; completion commits `0a22ada` (clause-numbered contract + pinned costs) and diff --git a/docs/records/event-flags.md b/docs/records/event-flags.md index 471a00b..703fdcf 100644 --- a/docs/records/event-flags.md +++ b/docs/records/event-flags.md @@ -94,7 +94,7 @@ IDs in parentheses; the clauses are the normative statements. | No silent `Source`/`Sink` or peek surface (D4, D5, X5) | Absent from the public API; contract non-promises X5; stream/signal traits deferred on the record | Pinned | | Cost claims per target | Eight gated code-size rows + three opt-in Xtensa (raise/take 24/24 B thumbv6m, 23/22 B ESP32-S2, 37/36 B ESP32-S3); object is 8 B (`size_of` unit assert); `EventBuf` static remains in `.bss` (codesize `bss` row) | Measured | -Full CI for the lane (`./scripts/verify.sh`, zero skips): 77 unit tests, +Full CI at lane acceptance (per-lane tree; the assembled 0.3.0 release matrix supersedes these totals) (`./scripts/verify.sh`, zero skips): 77 unit tests, 11 doctests, 5 compile-fail contracts, 93.60% line coverage, all 8 Loom models plus ordering-mutation checks, detector-on Miri host and i686/armv7/s390x proxies, feature/embedded builds, supply-chain checks, diff --git a/docs/records/latest-buf.md b/docs/records/latest-buf.md index c7304fc..5f8cb91 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -92,7 +92,7 @@ IDs in parentheses; the clauses are the normative statements. | Handles stay `Send + !Sync` (H2) | `compile_fail` doctests on `Producer` and `Consumer` | Pinned | | Cost claims per target | 11-target code-size matrix incl. ESP32-S2/S3; pinned QEMU 10.0.11 cycle regions; 66-region joint block matrix reproduced across two QEMU versions | Measured | -Full CI for the lane: 76 unit tests, 12 doctests, 6 compile-fail, 94.12% +Full CI at lane acceptance (per-lane tree; the assembled 0.3.0 release matrix supersedes these totals): 76 unit tests, 12 doctests, 6 compile-fail, 94.12% line coverage, zero skips. ## 4. The record diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md index abf9385..89ffe4f 100644 --- a/docs/records/seq-ring.md +++ b/docs/records/seq-ring.md @@ -99,7 +99,7 @@ channel (`LatestBuf` is, with a race-free ownership argument). | Fence placement is necessary (Release before value write; Acquire before re-check) | Module-doc argument; Loom; ordering discipline enforced by AGENTS' rerun-after-atomic-change rule | Proven | | Recovery cost is constant w.r.t. lag | 0.2.0 measurement: a consumer 2,000 sequences behind recovers in the same 115 instructions as one 16 behind | Measured | | Loss accounting is exact and saturating (`read + dropped` accounts for every sequence within one span; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned, while the resume cursor stays within one span of the newest publication (§2; sufficient: publications between consecutive nonzero polls below one span minus `N − 1`) | -| Const-constructible in a `static`; RAM is `size_of`-computable per shape (payload array + `N` per-slot sequence atomics) | `const fn new` (constructibility is compiler-enforced); the 0.2.0 codesize probe demonstrates the crate's `.bss` discipline on an `EventBuf` static (268 B) — **no dedicated SeqRing static is probed or baseline-gated**, so section placement for SeqRing specifically is expected from the same all-zero layout, not measured | Constructibility by construction; `.bss` placement demonstrated for EventBuf only — a dedicated SeqRing static row is a reasonable promotion-time gate addition | +| Const-constructs into `.bss` — no flash image, no startup copy; RAM is `size_of`-computable per shape (payload array + `N` per-slot sequence atomics) | `const fn new` (constructibility is compiler-enforced); a dedicated `SeqRing` probe static measures 524 B in `.bss` (`seq_bss` row, added at 0.3.0 promotion) and is baseline-gated on all eight upstream targets alongside the EventBuf static (268 B) | Measured, gated (the promotion-time gate addition this row previously flagged) | ## 4. The record diff --git a/scripts/codesize.sh b/scripts/codesize.sh index d254965..9a7a0d2 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -166,10 +166,10 @@ elif [ "$LATEST_MATRIX" = "1" ]; then printf '%-30s %-16s %10s %10s %10s %6s\n' \ '------------------------------' '----------------' '---------' '------' '---------' '----' else - printf '%-30s %10s %8s %8s %8s %8s %8s %8s %8s %6s\n' \ - TARGET two_calls cs_incr cs_take flags_acq flags_raise flags_take split bss data - printf '%-30s %10s %8s %8s %8s %8s %8s %8s %8s %6s\n' \ - '------------------------------' '---------' '-------' '-------' '---------' '-----------' '----------' '-----' '---' '----' + printf '%-30s %10s %8s %8s %8s %8s %8s %8s %8s %8s %6s\n' \ + TARGET two_calls cs_incr cs_take flags_acq flags_raise flags_take split bss seq_bss data + printf '%-30s %10s %8s %8s %8s %8s %8s %8s %8s %8s %6s\n' \ + '------------------------------' '---------' '-------' '-------' '---------' '-----------' '----------' '-----' '---' '-------' '----' fi skipped=0 @@ -346,11 +346,12 @@ for entry in $TARGETS; do flags_raise="$(fn_size "$ar" event_flags_raise)" flags_take="$(fn_size "$ar" event_flags_take)" bss="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.bss\..*3BUF/ { print $2; exit }')" + seq_bss="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 == ".bss.SEQ_BUF" { print $2; exit }')" dat="$("$SIZE" -A "$ar" 2>/dev/null | awk '$1 ~ /^\.data\./ { s += $2 } END { print s + 0 }')" - printf '%-30s %10s %8s %8s %8s %8s %8s %8s %8s %6s\n' \ + printf '%-30s %10s %8s %8s %8s %8s %8s %8s %8s %8s %6s\n' \ "$target" "${two:--}" "${cs_inc:--}" "${cs_take:--}" "${flags_acq:--}" \ - "${flags_raise:--}" "${flags_take:--}" "${spl:--}" "${bss:--}" "${dat:-0}" + "${flags_raise:--}" "${flags_take:--}" "${spl:--}" "${bss:--}" "${seq_bss:--}" "${dat:-0}" # Xtensa is never gated: it needs a toolchain fork, so making it a hard gate # would make that fork mandatory for every contributor. @@ -362,6 +363,7 @@ for entry in $TARGETS; do [ -n "$cs_inc" ] && printf '%s\tcounted_increment\t%s\n' "$target" "$cs_inc" >> "$RESULTS" [ -n "$cs_take" ] && printf '%s\tcounted_take\t%s\n' "$target" "$cs_take" >> "$RESULTS" [ -n "$bss" ] && printf '%s\tbss\t%s\n' "$target" "$bss" >> "$RESULTS" + [ -n "$seq_bss" ] && printf '%s\tseq_bss\t%s\n' "$target" "$seq_bss" >> "$RESULTS" printf '%s\tdata\t%s\n' "$target" "${dat:-0}" >> "$RESULTS" done diff --git a/scripts/codesize/baseline.tsv b/scripts/codesize/baseline.tsv index 0190b1c..646ff3d 100644 --- a/scripts/codesize/baseline.tsv +++ b/scripts/codesize/baseline.tsv @@ -14,6 +14,7 @@ armv7a-none-eabi data 0 armv7a-none-eabi event_flags_acquire 116 armv7a-none-eabi event_flags_raise 32 armv7a-none-eabi event_flags_take 32 +armv7a-none-eabi seq_bss 524 armv7a-none-eabi two_calls 220 armv7r-none-eabi bss 268 armv7r-none-eabi counted_increment 64 @@ -22,6 +23,7 @@ armv7r-none-eabi data 0 armv7r-none-eabi event_flags_acquire 116 armv7r-none-eabi event_flags_raise 32 armv7r-none-eabi event_flags_take 32 +armv7r-none-eabi seq_bss 524 armv7r-none-eabi two_calls 220 riscv32imac-unknown-none-elf bss 268 riscv32imac-unknown-none-elf counted_increment 26 @@ -30,6 +32,7 @@ riscv32imac-unknown-none-elf data 0 riscv32imac-unknown-none-elf event_flags_acquire 66 riscv32imac-unknown-none-elf event_flags_raise 8 riscv32imac-unknown-none-elf event_flags_take 8 +riscv32imac-unknown-none-elf seq_bss 524 riscv32imac-unknown-none-elf two_calls 152 thumbv6m-none-eabi bss 268 thumbv6m-none-eabi counted_increment 46 @@ -38,6 +41,7 @@ thumbv6m-none-eabi data 0 thumbv6m-none-eabi event_flags_acquire 68 thumbv6m-none-eabi event_flags_raise 24 thumbv6m-none-eabi event_flags_take 24 +thumbv6m-none-eabi seq_bss 524 thumbv6m-none-eabi two_calls 156 thumbv7em-none-eabi bss 268 thumbv7em-none-eabi counted_increment 44 @@ -46,6 +50,7 @@ thumbv7em-none-eabi data 0 thumbv7em-none-eabi event_flags_acquire 72 thumbv7em-none-eabi event_flags_raise 26 thumbv7em-none-eabi event_flags_take 26 +thumbv7em-none-eabi seq_bss 524 thumbv7em-none-eabi two_calls 172 thumbv7m-none-eabi bss 268 thumbv7m-none-eabi counted_increment 44 @@ -54,6 +59,7 @@ thumbv7m-none-eabi data 0 thumbv7m-none-eabi event_flags_acquire 72 thumbv7m-none-eabi event_flags_raise 26 thumbv7m-none-eabi event_flags_take 26 +thumbv7m-none-eabi seq_bss 524 thumbv7m-none-eabi two_calls 172 thumbv8m.base-none-eabi bss 268 thumbv8m.base-none-eabi counted_increment 44 @@ -62,6 +68,7 @@ thumbv8m.base-none-eabi data 0 thumbv8m.base-none-eabi event_flags_acquire 64 thumbv8m.base-none-eabi event_flags_raise 22 thumbv8m.base-none-eabi event_flags_take 22 +thumbv8m.base-none-eabi seq_bss 524 thumbv8m.base-none-eabi two_calls 156 thumbv8m.main-none-eabi bss 268 thumbv8m.main-none-eabi counted_increment 44 @@ -70,4 +77,5 @@ thumbv8m.main-none-eabi data 0 thumbv8m.main-none-eabi event_flags_acquire 64 thumbv8m.main-none-eabi event_flags_raise 22 thumbv8m.main-none-eabi event_flags_take 22 +thumbv8m.main-none-eabi seq_bss 524 thumbv8m.main-none-eabi two_calls 152 diff --git a/scripts/codesize/src/lib.rs b/scripts/codesize/src/lib.rs index e759b48..2ce8a49 100644 --- a/scripts/codesize/src/lib.rs +++ b/scripts/codesize/src/lib.rs @@ -12,7 +12,7 @@ use ph_eventing::counted_signal::{Consumer as CountConsumer, Producer as CountPr use ph_eventing::event_flags::{Consumer as FlagsConsumer, Producer as FlagsProducer}; #[cfg(feature = "block-matrix")] use ph_eventing::{Block, BlockBuilder, event_buf::Producer}; -use ph_eventing::{EventBuf, EventFlags, EventMask}; +use ph_eventing::{EventBuf, EventFlags, EventMask, SeqRing}; #[cfg(any(feature = "latest-matrix", feature = "latest-block-matrix"))] use ph_eventing::{ LatestBuf, LatestItem, PublishReport, @@ -40,6 +40,13 @@ static BUF: EventBuf = EventBuf::new(); /// EventFlags is one AtomicU32 plus two packed AtomicBool role claims (8 B). static FLAGS: EventFlags = EventFlags::new(); +/// SeqRing's `.bss` placement, measured directly instead of inferred from the +/// EventBuf static (the 0.3.0 review flagged that inference): its layout adds +/// `N` per-slot sequence atomics over the payload array. `no_mangle` gives the +/// section the stable name `.bss.SEQ_BUF` the runner extracts. +#[unsafe(no_mangle)] +pub static SEQ_BUF: SeqRing = SeqRing::new(); + #[cfg(feature = "split")] static SPLIT_BUF: EventBuf = EventBuf::new(); From f72c6ad06556fd19e988bfd0412f517c3862eb0b Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 02:15:01 -0400 Subject: [PATCH 70/87] Close 0.3.0 changelog and fix adversarial packaging/doc pins. Root-anchor README include so docs/records does not ship; pin CountedSignal !Sync compile_fail to E0277; retire stale DMA-target planning copy; carry SeqRing/LatestBuf known issues into the dated 0.3.0 section. Co-authored-by: Cursor --- CHANGELOG.md | 20 +++++++++++++++++++- Cargo.toml | 4 +++- docs/0.3.0-candidates.md | 18 +++++++++--------- src/counted_signal.rs | 4 ++-- src/loom_tests.rs | 2 +- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3439bf4..b75568d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 0.3.0 - 2026-08-12 ### Added - `LatestBuf` — a three-slot, freshness-first SPSC snapshot channel for @@ -159,6 +159,24 @@ All notable changes to this project will be documented in this file. a successful bracketed sample, with the bounded clamped-estimate fallback named. +### Known issues +- `SeqRing` is a seqlock and has a formal data race that Miri reports as + undefined behaviour. **This affects downstream tooling:** running + `cargo miri test` over a test that drives the ring from two threads reports + UB inside this crate. It is a deliberate trade — a ring restricted to a + word-sized payload could hold it in an atomic and be race-free; accepting + any `T: Copy` is what rules that out. The `seq_ring` module docs give the + alternatives and why each was rejected. `EventBuf` is unaffected and passes + Miri with the detector on, but applies backpressure rather than overwriting, + so it is not a drop-in replacement. Unchanged from 0.2.0. +- `SeqRing` exact loss accounting and the torn-copy discard argument are both + bounded at the `2^32 − 1` sequence span measured from the consumer's resume + cursor (see the module's whole-span section). A whole-span gap can silent- + zero; that is a documented limitation, not a new 0.3.0 regression. +- `LatestBuf::skipped` is exact only while the consumer's resume generation + stays within one non-zero `u32` span of the newest publication (contract + non-promise X6); a full-cycle gap can also report `skipped = 0`. + ## 0.2.0 - 2026-08-10 **What this release delivers.** 0.1.x was correct: verified lock-free buffers with no way to diff --git a/Cargo.toml b/Cargo.toml index bc563b8..beb873d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,9 @@ include = [ "build.rs", "Cargo.toml", "LICENSE", - "README.md", + # Root-anchored: a bare `README.md` is gitignore-style and matches every + # README.md in the tree (e.g. docs/records/README.md), which must not ship. + "/README.md", ] [dependencies] diff --git a/docs/0.3.0-candidates.md b/docs/0.3.0-candidates.md index a210abe..c3177bb 100644 --- a/docs/0.3.0-candidates.md +++ b/docs/0.3.0-candidates.md @@ -24,10 +24,10 @@ Version class: 0.3.0 is the **breaking slot** under pre-1.0 semver (the minor position is the breaking one below 1.0 — see RELEASING.md). Item 1 alone justifies the bump; everything else must be additive or breaking-with-cause. -No `release/0.3.0` branch exists yet, deliberately. Per RELEASING.md the -release branch is cut from master when the release is actually assembled; -cutting it at planning time would only create a long-lived branch to keep in -sync. This document rides an ordinary feature branch. +`release/0.3.0` is cut and assembled (draft merge-back PR #40). Per +RELEASING.md the release branch was cut when the accepted lanes were ready +to combine; this document remains the cycle's planning/decision record and +should be read as history for the decisions that closed the set. --- @@ -326,11 +326,11 @@ promotion the full nine-shape matrix rides into the release baselines via rule 8's deliberate `--bless`, and the pinned instruction regions stay committed. Two documentation obligations bind the block-payload surfaces: the per-shape RAM and small-`N` inversion guidance (already bound by D3), -and the **double-copy hazard** — a DMA integration must either make the -builder the DMA target or publish from task context, and the docs must -say so rather than let integrators discover the second copy. Closing P as -Copy keeps the crate's all-`Copy`, no-drop-obligation design space intact -for 0.3.0. **Reopening runs through S's trigger, below.** +and the **double-copy hazard** — the builder's storage is deliberately +private, so a DMA integration must either budget both copies or publish +from task context (direct-to-granted-slot filling reopens through S). +Closing P as Copy keeps the crate's all-`Copy`, no-drop-obligation design +space intact for 0.3.0. **Reopening runs through S's trigger, below.** **S — SlotPool admission path: DEFERRED** — a first-class outcome, not a rejection. With P closed as Copy, SlotPool has no in-release consumer, diff --git a/src/counted_signal.rs b/src/counted_signal.rs index fae2fc1..f25347a 100644 --- a/src/counted_signal.rs +++ b/src/counted_signal.rs @@ -151,7 +151,7 @@ impl core::fmt::Debug for CountedSignal { /// /// The load-bearing `!Sync` property is pinned at compile time: /// -/// ```compile_fail +/// ```compile_fail,E0277 /// use ph_eventing::counted_signal::Producer; /// /// fn assert_sync() {} @@ -212,7 +212,7 @@ impl core::fmt::Debug for Producer<'_> { /// This handle is `Send + !Sync` and may atomically take counts while its /// paired producer increments from another context. /// -/// ```compile_fail +/// ```compile_fail,E0277 /// use ph_eventing::counted_signal::Consumer; /// /// fn assert_sync() {} diff --git a/src/loom_tests.rs b/src/loom_tests.rs index a28d98c..fee41e8 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -212,7 +212,7 @@ fn latest_buf_reused_slot_keeps_exclusive_ownership() { // The phase marker orders scheduling only; visibility of the // publication itself must arrive through the exchange, so the // take retries until it does. - let mut take_spinning = |expected: u32| loop { + let take_spinning = |expected: u32| loop { if let Some(item) = consumer.take_latest() { assert_eq!(item.generation, expected); assert_eq!(item.value, [expected; 2]); From cf77ead8d5df959c5ecbb8418ac5881e39ac4dde Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 02:27:51 -0400 Subject: [PATCH 71/87] Gate LatestBuf codesize matrices and close Bugbot findings. Persist/compare latest and latest-block flash rows like block-matrix, bless their baselines into CI, drive the X6 full-cycle pin through publish/take, and retire stale proposed/exploratory/Safety wording for shipped types. Co-authored-by: Cursor --- README.md | 12 +- scripts/ci.sh | 8 + scripts/codesize.sh | 50 ++++- scripts/codesize/baseline-latest-block.tsv | 201 +++++++++++++++++++++ scripts/codesize/baseline-latest.tsv | 73 ++++++++ src/block.rs | 4 +- src/counted_signal.rs | 2 +- src/latest_buf.rs | 26 ++- 8 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 scripts/codesize/baseline-latest-block.tsv create mode 100644 scripts/codesize/baseline-latest.tsv diff --git a/README.md b/README.md index 28a95cd..2f30c32 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ use a timestamped type for `T` when required. `Block` is deliberately not another queue. Compose it with the overload policy you need: `EventBuf, Q>` queues complete blocks and rejects the -newest when full; the proposed `LatestBuf>` will retain only the +newest when full; `LatestBuf>` retains only the latest complete block. **Budget the composition before choosing it.** Publication copies the @@ -387,13 +387,15 @@ them is a runtime step and always will be. ## Safety and Concurrency - `RingBuf` has no atomics and no interior mutability — standard Rust borrow rules apply. It stores slots as `MaybeUninit` and reads only live entries, so it does contain `unsafe`. -- `SeqRing`, `EventBuf`, `EventFlags`, and `CountedSignal` are SPSC by design: exactly one producer and one consumer may be +- `SeqRing`, `EventBuf`, `EventFlags`, `CountedSignal`, and `LatestBuf` are SPSC by design: exactly one producer and one consumer may be active. Use `try_producer()`/`try_consumer()`, which return `None` rather than panicking — on a microcontroller a panic is a reset, and the panic machinery costs flash you may not have. The panicking `producer()`/`consumer()`, deprecated since 0.2.0, **were removed in - 0.3.0**. Using unsafe to bypass `SeqRing`/`EventBuf` ownership can be undefined + 0.3.0**. Using unsafe to bypass `SeqRing`/`EventBuf`/`LatestBuf` ownership can be undefined behavior. Forging or concurrently sharing a `CountedSignal` producer breaks its bounded no-wrap contract; the handle is `!Sync` to prevent that in safe Rust. + Forging a second `LatestBuf` producer or consumer similarly breaks the three-slot + exclusive-ownership exchange. - `T: Copy` is required by all payload-carrying types to avoid allocation and return values by copy. - `EventFlags` has no unsafe slot access and passes Miri with the race detector enabled. - `EventBuf` is race-free by construction: its producer and consumer never touch the same slot, @@ -418,8 +420,8 @@ them is a runtime step and always will be. The typical embedded shape is a producer in an interrupt handler and a consumer in a task loop. That works, with three things to know: -- **The primitive is shared; the handles are owned.** `SeqRing` and - `EventBuf` are `Sync` when `T: Send`, and `EventFlags` and +- **The primitive is shared; the handles are owned.** `SeqRing`, + `EventBuf`, and `LatestBuf` are `Sync` when `T: Send`, and `EventFlags` and `CountedSignal` are `Sync`, so the primitive can be handed to both contexts. `Producer` and `Consumer` are `Send + !Sync` — move each one into the context that owns it, and never share a single handle between contexts. diff --git a/scripts/ci.sh b/scripts/ci.sh index ab258c4..4c5668c 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -146,6 +146,14 @@ if [ "${SKIP_EMBEDDED:-0}" = "0" ]; then # loud SKIP — visible, and not a pass. run_check 'codesize (block matrix gate)' ./scripts/codesize.sh block-matrix + # LatestBuf admission matrices were measured at promotion but historically + # exited before the baseline compare — a post-promotion flash regression on + # those shapes would pass CI while default/block gates stayed green. Gate + # them the same way as block-matrix (loud SKIP until baseline-*.tsv exists). + run_check 'codesize (latest matrix gate)' ./scripts/codesize.sh latest-matrix + run_check 'codesize (latest-block matrix gate)' \ + ./scripts/codesize.sh latest-block-matrix + run_check 'thumbv6m-none-eabi' \ cargo check --target thumbv6m-none-eabi \ --features portable-atomic-unsafe-assume-single-core diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 9a7a0d2..3f8f36d 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -85,6 +85,14 @@ REGEN="./scripts/codesize.sh --bless" if [ "$BLOCK_MATRIX" = "1" ]; then BASELINE="scripts/codesize/baseline-block.tsv" REGEN="./scripts/codesize.sh block-matrix --bless" +elif [ "$LATEST_MATRIX" = "1" ]; then + # Same isolation as block-matrix: a latest bless must not rewrite the + # default or block baselines (partial-bless would silently drop their rows). + BASELINE="scripts/codesize/baseline-latest.tsv" + REGEN="./scripts/codesize.sh latest-matrix --bless" +elif [ "$LATEST_BLOCK_MATRIX" = "1" ]; then + BASELINE="scripts/codesize/baseline-latest-block.tsv" + REGEN="./scripts/codesize.sh latest-block-matrix --bless" fi # Growth beyond this many bytes on any row fails the gate. Absolute, not a # percentage: at 100-200 bytes a percentage is noise, and the regression this @@ -284,6 +292,17 @@ for entry in $TARGETS; do printf '%-30s %-10s %10s %10s %10s %6s\n' \ "$target" "$shape" "${publish:--}" "${take:--}" \ "${channel:--}" "${init:--}" + # Persist flash rows for the baseline gate (channel RAM / .bss is + # already enforced by matrix_not_bss above; Xtensa stays ungated). + case "$target" in + xtensa-*) ;; + *) + [ -n "$publish" ] && printf '%s\tlatest_%s_publish\t%s\n' \ + "$target" "$shape" "$publish" >> "$RESULTS" + [ -n "$take" ] && printf '%s\tlatest_%s_take\t%s\n' \ + "$target" "$shape" "$take" >> "$RESULTS" + ;; + esac done producer_role="$(fn_size "$ar" latest_producer_reacquire)" @@ -292,6 +311,15 @@ for entry in $TARGETS; do [ -z "$consumer_role" ] && matrix_missing=$((matrix_missing + 1)) printf '%-30s %-10s %10s %10s %10s %6s\n' \ "$target" roles "${producer_role:--}" "${consumer_role:--}" '-' '-' + case "$target" in + xtensa-*) ;; + *) + [ -n "$producer_role" ] && printf '%s\tlatest_roles_producer\t%s\n' \ + "$target" "$producer_role" >> "$RESULTS" + [ -n "$consumer_role" ] && printf '%s\tlatest_roles_consumer\t%s\n' \ + "$target" "$consumer_role" >> "$RESULTS" + ;; + esac continue fi @@ -334,6 +362,23 @@ for entry in $TARGETS; do printf '%-30s %-16s %10s %10s %10s %10s %10s %6s\n' \ "$target" "$shape" "${publish:--}" "${take:--}" \ "${complete:--}" "${channel:--}" "${builder:--}" "${init:--}" + case "$target" in + xtensa-*) ;; + *) + case "$shape" in + block_*) + [ -n "$complete" ] && printf '%s\tlatest_%s_complete\t%s\n' \ + "$target" "$shape" "$complete" >> "$RESULTS" + ;; + *) + [ -n "$publish" ] && printf '%s\tlatest_%s_publish\t%s\n' \ + "$target" "$shape" "$publish" >> "$RESULTS" + ;; + esac + [ -n "$take" ] && printf '%s\tlatest_%s_take\t%s\n' \ + "$target" "$shape" "$take" >> "$RESULTS" + ;; + esac done continue fi @@ -422,7 +467,10 @@ if [ "$LATEST_MATRIX" = "1" ] || [ "$LATEST_BLOCK_MATRIX" = "1" ]; then else printf 'Run scripts/cycles.sh latest-block-matrix for state-dependent paths.\n' fi - exit 0 + printf '\n' + # Fall through to the baseline gate against baseline-latest.tsv / + # baseline-latest-block.tsv — without this, admission flash rows are + # measured then thrown away and post-promotion regressions cannot fail CI. fi # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- diff --git a/scripts/codesize/baseline-latest-block.tsv b/scripts/codesize/baseline-latest-block.tsv new file mode 100644 index 0000000..7a2a146 --- /dev/null +++ b/scripts/codesize/baseline-latest-block.tsv @@ -0,0 +1,201 @@ +# ph-eventing code-size baseline. Regenerate: ./scripts/codesize.sh latest-block-matrix --bless +# rustc-commit: ded5c06cf21d2b93bffd5d884aa6e96934ee4234 +# +# Host-independent: byte-identical on x86_64-pc-windows-msvc and +# x86_64-unknown-linux-gnu for the same pinned rustc, verified across all +# eight gated targets. That is what makes committing it sound. +# +# Xtensa is deliberately absent -- it needs the esp-rs fork, and gating it +# would make that fork mandatory for every contributor. +armv7a-none-eabi latest_block_w16_n128_complete 332 +armv7a-none-eabi latest_block_w16_n128_take 204 +armv7a-none-eabi latest_block_w16_n32_complete 312 +armv7a-none-eabi latest_block_w16_n32_take 196 +armv7a-none-eabi latest_block_w16_n8_complete 312 +armv7a-none-eabi latest_block_w16_n8_take 196 +armv7a-none-eabi latest_block_w2_n128_complete 304 +armv7a-none-eabi latest_block_w2_n128_take 180 +armv7a-none-eabi latest_block_w2_n32_complete 304 +armv7a-none-eabi latest_block_w2_n32_take 176 +armv7a-none-eabi latest_block_w2_n8_complete 284 +armv7a-none-eabi latest_block_w2_n8_take 172 +armv7a-none-eabi latest_block_w8_n128_complete 312 +armv7a-none-eabi latest_block_w8_n128_take 196 +armv7a-none-eabi latest_block_w8_n32_complete 328 +armv7a-none-eabi latest_block_w8_n32_take 196 +armv7a-none-eabi latest_block_w8_n8_complete 336 +armv7a-none-eabi latest_block_w8_n8_take 196 +armv7a-none-eabi latest_sample_w16_publish 128 +armv7a-none-eabi latest_sample_w16_take 192 +armv7a-none-eabi latest_sample_w2_publish 116 +armv7a-none-eabi latest_sample_w2_take 160 +armv7a-none-eabi latest_sample_w8_publish 128 +armv7a-none-eabi latest_sample_w8_take 176 +armv7r-none-eabi latest_block_w16_n128_complete 332 +armv7r-none-eabi latest_block_w16_n128_take 204 +armv7r-none-eabi latest_block_w16_n32_complete 312 +armv7r-none-eabi latest_block_w16_n32_take 196 +armv7r-none-eabi latest_block_w16_n8_complete 312 +armv7r-none-eabi latest_block_w16_n8_take 196 +armv7r-none-eabi latest_block_w2_n128_complete 304 +armv7r-none-eabi latest_block_w2_n128_take 180 +armv7r-none-eabi latest_block_w2_n32_complete 304 +armv7r-none-eabi latest_block_w2_n32_take 176 +armv7r-none-eabi latest_block_w2_n8_complete 284 +armv7r-none-eabi latest_block_w2_n8_take 172 +armv7r-none-eabi latest_block_w8_n128_complete 312 +armv7r-none-eabi latest_block_w8_n128_take 196 +armv7r-none-eabi latest_block_w8_n32_complete 328 +armv7r-none-eabi latest_block_w8_n32_take 196 +armv7r-none-eabi latest_block_w8_n8_complete 336 +armv7r-none-eabi latest_block_w8_n8_take 196 +armv7r-none-eabi latest_sample_w16_publish 128 +armv7r-none-eabi latest_sample_w16_take 192 +armv7r-none-eabi latest_sample_w2_publish 116 +armv7r-none-eabi latest_sample_w2_take 160 +armv7r-none-eabi latest_sample_w8_publish 128 +armv7r-none-eabi latest_sample_w8_take 176 +riscv32imac-unknown-none-elf latest_block_w16_n128_complete 300 +riscv32imac-unknown-none-elf latest_block_w16_n128_take 164 +riscv32imac-unknown-none-elf latest_block_w16_n32_complete 250 +riscv32imac-unknown-none-elf latest_block_w16_n32_take 136 +riscv32imac-unknown-none-elf latest_block_w16_n8_complete 248 +riscv32imac-unknown-none-elf latest_block_w16_n8_take 136 +riscv32imac-unknown-none-elf latest_block_w2_n128_complete 218 +riscv32imac-unknown-none-elf latest_block_w2_n128_take 106 +riscv32imac-unknown-none-elf latest_block_w2_n32_complete 200 +riscv32imac-unknown-none-elf latest_block_w2_n32_take 102 +riscv32imac-unknown-none-elf latest_block_w2_n8_complete 200 +riscv32imac-unknown-none-elf latest_block_w2_n8_take 116 +riscv32imac-unknown-none-elf latest_block_w8_n128_complete 258 +riscv32imac-unknown-none-elf latest_block_w8_n128_take 142 +riscv32imac-unknown-none-elf latest_block_w8_n32_complete 264 +riscv32imac-unknown-none-elf latest_block_w8_n32_take 136 +riscv32imac-unknown-none-elf latest_block_w8_n8_complete 246 +riscv32imac-unknown-none-elf latest_block_w8_n8_take 132 +riscv32imac-unknown-none-elf latest_sample_w16_publish 102 +riscv32imac-unknown-none-elf latest_sample_w16_take 140 +riscv32imac-unknown-none-elf latest_sample_w2_publish 76 +riscv32imac-unknown-none-elf latest_sample_w2_take 112 +riscv32imac-unknown-none-elf latest_sample_w8_publish 80 +riscv32imac-unknown-none-elf latest_sample_w8_take 120 +thumbv6m-none-eabi latest_block_w16_n128_complete 276 +thumbv6m-none-eabi latest_block_w16_n128_take 148 +thumbv6m-none-eabi latest_block_w16_n32_complete 272 +thumbv6m-none-eabi latest_block_w16_n32_take 146 +thumbv6m-none-eabi latest_block_w16_n8_complete 262 +thumbv6m-none-eabi latest_block_w16_n8_take 142 +thumbv6m-none-eabi latest_block_w2_n128_complete 250 +thumbv6m-none-eabi latest_block_w2_n128_take 124 +thumbv6m-none-eabi latest_block_w2_n32_complete 210 +thumbv6m-none-eabi latest_block_w2_n32_take 110 +thumbv6m-none-eabi latest_block_w2_n8_complete 192 +thumbv6m-none-eabi latest_block_w2_n8_take 110 +thumbv6m-none-eabi latest_block_w8_n128_complete 276 +thumbv6m-none-eabi latest_block_w8_n128_take 148 +thumbv6m-none-eabi latest_block_w8_n32_complete 274 +thumbv6m-none-eabi latest_block_w8_n32_take 146 +thumbv6m-none-eabi latest_block_w8_n8_complete 242 +thumbv6m-none-eabi latest_block_w8_n8_take 130 +thumbv6m-none-eabi latest_sample_w16_publish 98 +thumbv6m-none-eabi latest_sample_w16_take 134 +thumbv6m-none-eabi latest_sample_w2_publish 78 +thumbv6m-none-eabi latest_sample_w2_take 98 +thumbv6m-none-eabi latest_sample_w8_publish 80 +thumbv6m-none-eabi latest_sample_w8_take 118 +thumbv7em-none-eabi latest_block_w16_n128_complete 260 +thumbv7em-none-eabi latest_block_w16_n128_take 144 +thumbv7em-none-eabi latest_block_w16_n32_complete 216 +thumbv7em-none-eabi latest_block_w16_n32_take 112 +thumbv7em-none-eabi latest_block_w16_n8_complete 214 +thumbv7em-none-eabi latest_block_w16_n8_take 110 +thumbv7em-none-eabi latest_block_w2_n128_complete 188 +thumbv7em-none-eabi latest_block_w2_n128_take 102 +thumbv7em-none-eabi latest_block_w2_n32_complete 182 +thumbv7em-none-eabi latest_block_w2_n32_take 96 +thumbv7em-none-eabi latest_block_w2_n8_complete 188 +thumbv7em-none-eabi latest_block_w2_n8_take 124 +thumbv7em-none-eabi latest_block_w8_n128_complete 230 +thumbv7em-none-eabi latest_block_w8_n128_take 116 +thumbv7em-none-eabi latest_block_w8_n32_complete 202 +thumbv7em-none-eabi latest_block_w8_n32_take 112 +thumbv7em-none-eabi latest_block_w8_n8_complete 202 +thumbv7em-none-eabi latest_block_w8_n8_take 108 +thumbv7em-none-eabi latest_sample_w16_publish 114 +thumbv7em-none-eabi latest_sample_w16_take 120 +thumbv7em-none-eabi latest_sample_w2_publish 76 +thumbv7em-none-eabi latest_sample_w2_take 90 +thumbv7em-none-eabi latest_sample_w8_publish 78 +thumbv7em-none-eabi latest_sample_w8_take 102 +thumbv7m-none-eabi latest_block_w16_n128_complete 260 +thumbv7m-none-eabi latest_block_w16_n128_take 144 +thumbv7m-none-eabi latest_block_w16_n32_complete 216 +thumbv7m-none-eabi latest_block_w16_n32_take 112 +thumbv7m-none-eabi latest_block_w16_n8_complete 214 +thumbv7m-none-eabi latest_block_w16_n8_take 110 +thumbv7m-none-eabi latest_block_w2_n128_complete 188 +thumbv7m-none-eabi latest_block_w2_n128_take 102 +thumbv7m-none-eabi latest_block_w2_n32_complete 182 +thumbv7m-none-eabi latest_block_w2_n32_take 96 +thumbv7m-none-eabi latest_block_w2_n8_complete 188 +thumbv7m-none-eabi latest_block_w2_n8_take 124 +thumbv7m-none-eabi latest_block_w8_n128_complete 230 +thumbv7m-none-eabi latest_block_w8_n128_take 116 +thumbv7m-none-eabi latest_block_w8_n32_complete 202 +thumbv7m-none-eabi latest_block_w8_n32_take 112 +thumbv7m-none-eabi latest_block_w8_n8_complete 202 +thumbv7m-none-eabi latest_block_w8_n8_take 108 +thumbv7m-none-eabi latest_sample_w16_publish 114 +thumbv7m-none-eabi latest_sample_w16_take 120 +thumbv7m-none-eabi latest_sample_w2_publish 76 +thumbv7m-none-eabi latest_sample_w2_take 90 +thumbv7m-none-eabi latest_sample_w8_publish 78 +thumbv7m-none-eabi latest_sample_w8_take 102 +thumbv8m.base-none-eabi latest_block_w16_n128_complete 284 +thumbv8m.base-none-eabi latest_block_w16_n128_take 156 +thumbv8m.base-none-eabi latest_block_w16_n32_complete 278 +thumbv8m.base-none-eabi latest_block_w16_n32_take 154 +thumbv8m.base-none-eabi latest_block_w16_n8_complete 270 +thumbv8m.base-none-eabi latest_block_w16_n8_take 148 +thumbv8m.base-none-eabi latest_block_w2_n128_complete 228 +thumbv8m.base-none-eabi latest_block_w2_n128_take 132 +thumbv8m.base-none-eabi latest_block_w2_n32_complete 200 +thumbv8m.base-none-eabi latest_block_w2_n32_take 114 +thumbv8m.base-none-eabi latest_block_w2_n8_complete 192 +thumbv8m.base-none-eabi latest_block_w2_n8_take 114 +thumbv8m.base-none-eabi latest_block_w8_n128_complete 280 +thumbv8m.base-none-eabi latest_block_w8_n128_take 156 +thumbv8m.base-none-eabi latest_block_w8_n32_complete 274 +thumbv8m.base-none-eabi latest_block_w8_n32_take 154 +thumbv8m.base-none-eabi latest_block_w8_n8_complete 238 +thumbv8m.base-none-eabi latest_block_w8_n8_take 134 +thumbv8m.base-none-eabi latest_sample_w16_publish 100 +thumbv8m.base-none-eabi latest_sample_w16_take 130 +thumbv8m.base-none-eabi latest_sample_w2_publish 80 +thumbv8m.base-none-eabi latest_sample_w2_take 102 +thumbv8m.base-none-eabi latest_sample_w8_publish 82 +thumbv8m.base-none-eabi latest_sample_w8_take 118 +thumbv8m.main-none-eabi latest_block_w16_n128_complete 252 +thumbv8m.main-none-eabi latest_block_w16_n128_take 134 +thumbv8m.main-none-eabi latest_block_w16_n32_complete 212 +thumbv8m.main-none-eabi latest_block_w16_n32_take 108 +thumbv8m.main-none-eabi latest_block_w16_n8_complete 210 +thumbv8m.main-none-eabi latest_block_w16_n8_take 106 +thumbv8m.main-none-eabi latest_block_w2_n128_complete 184 +thumbv8m.main-none-eabi latest_block_w2_n128_take 100 +thumbv8m.main-none-eabi latest_block_w2_n32_complete 178 +thumbv8m.main-none-eabi latest_block_w2_n32_take 94 +thumbv8m.main-none-eabi latest_block_w2_n8_complete 180 +thumbv8m.main-none-eabi latest_block_w2_n8_take 120 +thumbv8m.main-none-eabi latest_block_w8_n128_complete 230 +thumbv8m.main-none-eabi latest_block_w8_n128_take 112 +thumbv8m.main-none-eabi latest_block_w8_n32_complete 202 +thumbv8m.main-none-eabi latest_block_w8_n32_take 108 +thumbv8m.main-none-eabi latest_block_w8_n8_complete 202 +thumbv8m.main-none-eabi latest_block_w8_n8_take 104 +thumbv8m.main-none-eabi latest_sample_w16_publish 102 +thumbv8m.main-none-eabi latest_sample_w16_take 118 +thumbv8m.main-none-eabi latest_sample_w2_publish 76 +thumbv8m.main-none-eabi latest_sample_w2_take 88 +thumbv8m.main-none-eabi latest_sample_w8_publish 78 +thumbv8m.main-none-eabi latest_sample_w8_take 100 diff --git a/scripts/codesize/baseline-latest.tsv b/scripts/codesize/baseline-latest.tsv new file mode 100644 index 0000000..7e0017f --- /dev/null +++ b/scripts/codesize/baseline-latest.tsv @@ -0,0 +1,73 @@ +# ph-eventing code-size baseline. Regenerate: ./scripts/codesize.sh latest-matrix --bless +# rustc-commit: ded5c06cf21d2b93bffd5d884aa6e96934ee4234 +# +# Host-independent: byte-identical on x86_64-pc-windows-msvc and +# x86_64-unknown-linux-gnu for the same pinned rustc, verified across all +# eight gated targets. That is what makes committing it sound. +# +# Xtensa is deliberately absent -- it needs the esp-rs fork, and gating it +# would make that fork mandatory for every contributor. +armv7a-none-eabi latest_block128_publish 128 +armv7a-none-eabi latest_block128_take 172 +armv7a-none-eabi latest_roles_consumer 64 +armv7a-none-eabi latest_roles_producer 64 +armv7a-none-eabi latest_u32_publish 112 +armv7a-none-eabi latest_u32_take 152 +armv7a-none-eabi latest_w16_publish 124 +armv7a-none-eabi latest_w16_take 168 +armv7r-none-eabi latest_block128_publish 128 +armv7r-none-eabi latest_block128_take 172 +armv7r-none-eabi latest_roles_consumer 64 +armv7r-none-eabi latest_roles_producer 64 +armv7r-none-eabi latest_u32_publish 112 +armv7r-none-eabi latest_u32_take 152 +armv7r-none-eabi latest_w16_publish 124 +armv7r-none-eabi latest_w16_take 168 +riscv32imac-unknown-none-elf latest_block128_publish 124 +riscv32imac-unknown-none-elf latest_block128_take 158 +riscv32imac-unknown-none-elf latest_roles_consumer 48 +riscv32imac-unknown-none-elf latest_roles_producer 34 +riscv32imac-unknown-none-elf latest_u32_publish 72 +riscv32imac-unknown-none-elf latest_u32_take 106 +riscv32imac-unknown-none-elf latest_w16_publish 100 +riscv32imac-unknown-none-elf latest_w16_take 130 +thumbv6m-none-eabi latest_block128_publish 94 +thumbv6m-none-eabi latest_block128_take 118 +thumbv6m-none-eabi latest_roles_consumer 42 +thumbv6m-none-eabi latest_roles_producer 42 +thumbv6m-none-eabi latest_u32_publish 76 +thumbv6m-none-eabi latest_u32_take 96 +thumbv6m-none-eabi latest_w16_publish 98 +thumbv6m-none-eabi latest_w16_take 114 +thumbv7em-none-eabi latest_block128_publish 112 +thumbv7em-none-eabi latest_block128_take 134 +thumbv7em-none-eabi latest_roles_consumer 46 +thumbv7em-none-eabi latest_roles_producer 46 +thumbv7em-none-eabi latest_u32_publish 88 +thumbv7em-none-eabi latest_u32_take 102 +thumbv7em-none-eabi latest_w16_publish 116 +thumbv7em-none-eabi latest_w16_take 122 +thumbv7m-none-eabi latest_block128_publish 112 +thumbv7m-none-eabi latest_block128_take 134 +thumbv7m-none-eabi latest_roles_consumer 46 +thumbv7m-none-eabi latest_roles_producer 46 +thumbv7m-none-eabi latest_u32_publish 88 +thumbv7m-none-eabi latest_u32_take 102 +thumbv7m-none-eabi latest_w16_publish 116 +thumbv7m-none-eabi latest_w16_take 122 +thumbv8m.base-none-eabi latest_block128_publish 96 +thumbv8m.base-none-eabi latest_block128_take 120 +thumbv8m.base-none-eabi latest_roles_consumer 34 +thumbv8m.base-none-eabi latest_roles_producer 34 +thumbv8m.base-none-eabi latest_u32_publish 78 +thumbv8m.base-none-eabi latest_u32_take 100 +thumbv8m.base-none-eabi latest_w16_publish 100 +thumbv8m.base-none-eabi latest_w16_take 116 +thumbv8m.main-none-eabi latest_block128_publish 102 +thumbv8m.main-none-eabi latest_block128_take 124 +thumbv8m.main-none-eabi latest_roles_consumer 36 +thumbv8m.main-none-eabi latest_roles_producer 36 +thumbv8m.main-none-eabi latest_u32_publish 80 +thumbv8m.main-none-eabi latest_u32_take 102 +thumbv8m.main-none-eabi latest_w16_publish 108 +thumbv8m.main-none-eabi latest_w16_take 122 diff --git a/src/block.rs b/src/block.rs index 1f4fa4f..67e3ac3 100644 --- a/src/block.rs +++ b/src/block.rs @@ -5,8 +5,8 @@ //! //! - `EventBuf, Q>` queues up to `Q` complete blocks and rejects //! the newest block when full; -//! - the proposed `LatestBuf>` retains only the latest complete -//! block. +//! - `LatestBuf>` retains only the latest complete +//! block (decision D3 composition). //! //! Publication cannot expose a partial block because [`BlockBuilder`] only //! yields a [`Block`] after all `N` samples have been written. Dropping or diff --git a/src/counted_signal.rs b/src/counted_signal.rs index f25347a..fcd5efd 100644 --- a/src/counted_signal.rs +++ b/src/counted_signal.rs @@ -1,6 +1,6 @@ //! A saturating count for payload-free events. //! -//! [`CountedSignal`] is an exploratory SPSC primitive for events whose +//! [`CountedSignal`] is an SPSC primitive for events whose //! multiplicity matters but whose payload and ordering do not. Its producer //! commits each increment with a sole-producer–bounded path; its consumer //! atomically takes the accumulated count. diff --git a/src/latest_buf.rs b/src/latest_buf.rs index 0045e3b..fe11160 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -664,8 +664,32 @@ mod tests { // Equal endpoints are indistinguishable from no progress after a full // generation cycle. D1 is closed as documented approximation plus // payload escape hatch (contract C3/X6); this test is the closure's - // named pin for the full-cycle modular result. + // named pin for the full-cycle modular result — including the take + // path, not only the pure distance helper. assert_eq!(LatestBuf::::generation_distance(17, 17), 0); + + let channel = LatestBuf::::new(); + // SAFETY: no handles exist while the test seeds role state. + channel.producer_state.with_mut(|state| unsafe { + // Next publish assigns generation 17 (skipping reserved 0). + (*state).next_generation = 16; + }); + channel.consumer_state.with_mut(|state| unsafe { + // Resume cursor already at 17: a wrap-aliased publish of 17 looks + // like "nothing skipped" even though a full span was lost. + (*state).last_generation = 17; + }); + let producer = channel.try_producer().unwrap(); + let consumer = channel.try_consumer().unwrap(); + assert_eq!(producer.publish(99).generation, 17); + assert_eq!( + consumer.take_latest(), + Some(LatestItem { + value: 99, + generation: 17, + skipped: 0, + }) + ); } #[test] From b849751e06493b4edaa247d6cae6f85ed3c5ff86 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 03:09:13 -0400 Subject: [PATCH 72/87] Fix both review P1s: clippy SAFETY comment; bound poll_up_to at a frozen entry window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 (clippy, CI red): the consumer-state seeding closure in the full-generation-cycle test had no SAFETY comment of its own — the one at the producer-state closure does not cover it, and undocumented_unsafe_blocks fails the build under -D warnings. Reproduced locally (one error, src/latest_buf.rs:677), now clean. P1 (unbounded poll): poll_up_to re-read the newest published sequence every iteration and counted only successful reads against its budget, so a producer that stayed ahead starved the poll — with N = 1 every read misses and poll_up_to(1) never returns, inherited by poll_one, poll_one_value, Source::try_pop, and forward, contradicting the crate's no-unbounded-loops rule. Per the maintainer's chosen contract the drain goal is now frozen at entry: one lag-recovery jump computed once against the entry sample, then a walk in which every iteration advances the cursor by exactly one toward it — bounded by construction at one jump + at most N slots + at most max reads. Items published mid-poll wait for the next call; PollStats::newest reports the entry sample. Evidence: all 102 unit tests pass including the new poll_window_is_frozen_at_entry pin (publish-from-hook waits for the next call, nothing lost or double-counted); new Loom model seq_ring_frozen_poll_window_conserves_under_concurrent_publish proves exact read+dropped conservation in every interleaving (17/17 models pass). Writing that model also produced a concrete witness of the documented formal seqlock race: asserting payload VALUES fails on the old loop and the new loop identically (verified by transplanting the model onto cf77ead) because Loom serialises non-atomic slot memory while C11 coherence lets both Relaxed sequence checks stay stale — the fence pairing closes it on hardware only. The module docs and record now cite this witness, and the shipped model scopes to the sequence protocol like its siblings. Re-measured in the reference image (QEMU 10.0.11): lag recovery stays O(1) and gets cheaper — 90 instructions at both 2xN and ~2,000 behind (was 115), poll_one_value 83 (was 92), poll empty 25 (was 24); all other rows unchanged. AGENTS, record, and the 0.3.0 changelog restate the numbers; 0.2.0 history is left as measured then. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 14 +- CHANGELOG.md | 13 + docs/records/seq-ring.md | 5 +- src/latest_buf.rs | 1 + src/loom_tests.rs | 67 ++ src/seq_ring.rs | 104 ++- verify-rerun-49cf88a.log | 1362 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 1535 insertions(+), 31 deletions(-) create mode 100644 verify-rerun-49cf88a.log diff --git a/AGENTS.md b/AGENTS.md index 5d8451f..de00c1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -768,9 +768,9 @@ Run the default probe as well when changing shared measurement infrastructure. | `EventBuf::pop` | — | 20 (full) | 13 (empty) | | `EventBuf::peek` / `len` | 13 / 14 | | | | `SeqRing::push` | 34 | **33** (overwriting) | | -| `SeqRing::poll_one_value` | — | 92 | 24 (empty) | -| `SeqRing` poll, lagged 2×N | | **115** | | -| `SeqRing` poll, lagged ~2000 | | **115** | | +| `SeqRing::poll_one_value` | — | 83 | 25 (empty) | +| `SeqRing` poll, lagged 2×N | | **90** | | +| `SeqRing` poll, lagged ~2000 | | **90** | | | `SeqRing::latest_value` | — | 30 | | | `RingBuf::push` | 20 | **20** (overwriting) | | | `RingBuf::get` / `latest` | 22 / 16 | | | @@ -803,9 +803,11 @@ Four results carry the argument: 1. **Every `push` is constant.** Empty vs loaded differs by at most one instruction on all three types. Cost does not scale with occupancy or `N`. 2. **`SeqRing` lag recovery is O(1) in the lag.** A consumer 2×N behind and one - ~2000 behind both cost **115 instructions**. If recovery walked the backlog - the second would be two orders of magnitude larger. It is a jump, and now - that is measured rather than asserted. + ~2000 behind both cost **90 instructions** (re-measured after the 0.3.0 + bounded-poll fix froze the drain goal at entry; it was 115 with the + per-iteration re-read). If recovery walked the backlog the second would be + two orders of magnitude larger. It is a jump, and that is measured rather + than asserted. 3. **CountedSignal's SPSC hot paths are small and bounded.** `increment` retires 8 instructions on the below-`MAX` common path and 9 on the probe-seeded saturated sentinel arm; `take_count` retires 9 — all in the diff --git a/CHANGELOG.md b/CHANGELOG.md index b75568d..2f95526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,19 @@ All notable changes to this project will be documented in this file. reject, without the take's ordering guarantees. ### Fixed +- `SeqRing::poll_up_to` (and everything built on it: `poll_one`, + `poll_one_value`, `Source::try_pop`, `forward`) is now bounded per call + under continuous overwrite. The previous loop re-read the newest published + sequence every iteration and only counted successful reads against its + budget, so a producer that stayed ahead could starve the poll indefinitely + — contradicting the crate's no-unbounded-loops rule. The drain goal is now + sampled once at entry: each call performs at most one lag-recovery jump, a + walk of at most `N` slots, and at most `max` reads; items published while + the poll runs wait for the next call, with nothing lost or double-counted + at the hand-off (unit-pinned and Loom-modelled). Lag recovery remains O(1) + in the lag and got cheaper: 90 instructions at both 2×N and ~2,000 behind + (was 115), with `poll_one_value` at 83 (was 92), in the reference + environment on the assembled tree. - `scripts/loom.sh`: a bare name filter is scoped to `loom_tests::` instead of being passed as a second positional Cargo filter (leading `-` flags still pass through), and a run is only reported verified when the diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md index 89ffe4f..db55cf7 100644 --- a/docs/records/seq-ring.md +++ b/docs/records/seq-ring.md @@ -95,9 +95,10 @@ channel (`LatestBuf` is, with a race-free ownership argument). | Claim | Evidence | Status | |---|---|---| -| No torn value materialises as `T` (racy copies discarded before use) | Volatile access + `MaybeUninit` holding + re-check discipline; Loom models the sequence protocol (slot cells deliberately untracked — see the module's sync note); the dedicated Miri pass runs with the race detector **off** for this ring | Protocol-validated, not formally proven: the slot copy is a Rust data race (UB), so no checker validates the absolute claim — the discipline is argued in the module docs, widely deployed (Linux `seqlock_t`), and has no observed failure; span-bounded (counter-width ABA, §2) | +| No torn value materialises as `T` (racy copies discarded before use) | Volatile access + `MaybeUninit` holding + re-check discipline; Loom models the sequence protocol (slot cells deliberately untracked — see the module's sync note); the dedicated Miri pass runs with the race detector **off** for this ring | Protocol-validated, not formally proven: the slot copy is a Rust data race (UB), so no checker validates the absolute claim — the discipline is argued in the module docs, widely deployed (Linux `seqlock_t`), and has no observed failure on hardware — while the formal gap itself is directly witnessable (a Loom model asserting payload values fails exactly as the abstract machine permits; module docs, "Loom shows it too"), which is why the shipped models scope to the sequence protocol; span-bounded (counter-width ABA, §2) | | Fence placement is necessary (Release before value write; Acquire before re-check) | Module-doc argument; Loom; ordering discipline enforced by AGENTS' rerun-after-atomic-change rule | Proven | -| Recovery cost is constant w.r.t. lag | 0.2.0 measurement: a consumer 2,000 sequences behind recovers in the same 115 instructions as one 16 behind | Measured | +| Recovery cost is constant w.r.t. lag | Re-measured on the 0.3.0 bounded-poll loop: a consumer 2,000 sequences behind recovers in the same 90 instructions as one 2×N behind (was 115 before the frozen entry-sample window removed the per-iteration `newest` re-read) | Measured | +| Every ordered poll call is bounded — one lag-recovery jump plus a walk of at most `N` slots plus at most `max` reads against a drain goal frozen at entry | The `for`-bounded structure of `poll_up_to`; unit pin `poll_window_is_frozen_at_entry` (an item published mid-poll waits for the next call, nothing lost or double-counted); Loom model `seq_ring_frozen_poll_window_conserves_under_concurrent_publish` (exact read+dropped conservation in every interleaving) | Fixed and pinned at 0.3.0 — the previous loop re-read `newest` every iteration and could be starved by a producer that stayed ahead | | Loss accounting is exact and saturating (`read + dropped` accounts for every sequence within one span; `dropped_accum` saturates, never wraps) | Unit set incl. `lag_across_wrap_counts_drops_exactly`, `dropped_accum_saturates_instead_of_overflowing`; `seq_distance` wrap tests (0.1.3 fix pinned) | Pinned, while the resume cursor stays within one span of the newest publication (§2; sufficient: publications between consecutive nonzero polls below one span minus `N − 1`) | | Const-constructs into `.bss` — no flash image, no startup copy; RAM is `size_of`-computable per shape (payload array + `N` per-slot sequence atomics) | `const fn new` (constructibility is compiler-enforced); a dedicated `SeqRing` probe static measures 524 B in `.bss` (`seq_bss` row, added at 0.3.0 promotion) and is baseline-gated on all eight upstream targets alongside the EventBuf static (268 B) | Measured, gated (the promotion-time gate addition this row previously flagged) | diff --git a/src/latest_buf.rs b/src/latest_buf.rs index fe11160..bb2fb87 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -674,6 +674,7 @@ mod tests { // Next publish assigns generation 17 (skipping reserved 0). (*state).next_generation = 16; }); + // SAFETY: no handles exist while the test seeds role state. channel.consumer_state.with_mut(|state| unsafe { // Resume cursor already at 17: a wrap-aliased publish of 17 looks // like "nothing skipped" even though a full span was lost. diff --git a/src/loom_tests.rs b/src/loom_tests.rs index fee41e8..989ff28 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -493,6 +493,73 @@ fn event_buf_pop_never_sees_unpublished_data() { }); } +/// The frozen entry-sample poll window (the bounded-poll fix) must neither +/// lose nor double-count items across the call boundary: in every +/// interleaving, each published sequence ends up delivered or counted +/// dropped exactly once, and deliveries arrive in strictly increasing +/// sequence order. +/// +/// Payload VALUES are deliberately not asserted, matching the scoping of the +/// other SeqRing models: doing so directly reproduces the documented formal +/// seqlock race (verified on both the old and the bounded poll loop — Loom +/// serialises the non-atomic slot memory to latest-value semantics while +/// C11 coherence lets both Relaxed sequence checks keep returning the stale +/// pre-invalidation sequence, because a non-atomic read establishes no +/// happens-before). The producer's Release fence and the consumer's Acquire +/// fence are what close that window on real hardware; see the module's +/// "Known deviation" section, which cites this witness. +#[test] +fn seq_ring_frozen_poll_window_conserves_under_concurrent_publish() { + loom::model(|| { + let ring = Arc::new(SeqRing::::new()); + let done = Arc::new(AtomicBool::new(false)); + + let producer_ring = Arc::clone(&ring); + let producer_done = Arc::clone(&done); + let producer = thread::spawn(move || { + let producer = producer_ring.try_producer().unwrap(); + for value in 1..=3u32 { + producer.push(value * 10); + } + // Release pairs with the consumer's Acquire: once `done` is + // observed, every publication above is visible, so the final + // empty poll below is conclusive rather than racy. + producer_done.store(true, Ordering::Release); + }); + + let consumer_ring = Arc::clone(&ring); + let consumer = thread::spawn(move || { + let mut consumer = consumer_ring.try_consumer().unwrap(); + let mut read = 0usize; + let mut dropped = 0usize; + let mut last_seq = 0u32; + loop { + // Observe `done` BEFORE polling: only an empty poll that + // happens-after the producer's Release store is conclusive + // evidence that nothing remains. + let producer_was_done = done.load(Ordering::Acquire); + let stats = consumer.poll_up_to(2, |seq, _value| { + assert!(seq > last_seq); + last_seq = seq; + }); + read += stats.read; + dropped += stats.dropped; + if producer_was_done && stats.read == 0 && stats.dropped == 0 { + break; + } + thread::yield_now(); + } + (read, dropped) + }); + + producer.join().unwrap(); + let (read, dropped) = consumer.join().unwrap(); + // Exact conservation within the span: three publications, each + // delivered or dropped exactly once, never both, never neither. + assert_eq!(read + dropped, 3); + }); +} + /// The `SeqRing` sequence protocol never hands the consumer a sequence the /// producer has not published, never goes backwards, and never exceeds the /// newest published sequence. diff --git a/src/seq_ring.rs b/src/seq_ring.rs index b836d64..29f2551 100644 --- a/src/seq_ring.rs +++ b/src/seq_ring.rs @@ -60,6 +60,14 @@ //! failure" is not a guarantee: the compiler is *permitted* to assume the race cannot happen. //! `read_volatile`/`write_volatile` block the optimisations that would plausibly exploit it //! (splitting, duplicating, hoisting the copy); nothing blocks the ones nobody has thought of. +//! - **Loom shows it too, if you let it.** A model that asserts delivered payload *values* +//! fails: Loom serialises the non-atomic slot memory (the copy returns the latest bytes) +//! while C11 coherence lets both Relaxed sequence checks keep returning the stale +//! pre-invalidation sequence — a non-atomic read establishes no happens-before to force the +//! re-check forward. That is the formal gap of the abstract machine witnessed concretely; the +//! fence pairing above is what closes it on real hardware, where observing the new value +//! implies the earlier invalidation is visible to the fenced re-check. The shipped models +//! therefore assert the sequence protocol and conservation, never payload values. //! - **Your own Miri runs will flag it.** If you run `cargo miri test` over a test that drives //! this ring from two threads, you will get a UB report pointing into this crate. That is the //! deviation, not a new bug. `scripts/miri.*` shows the split-pass approach: full checking @@ -150,7 +158,8 @@ //! cadence: aliasing needs the distance from the resume cursor to the newest publication to //! reach one whole span, and a partial drain leaves residual backlog that counts against it. A //! nonzero ordered poll (`poll_one`, or `poll_up_to` with a nonzero budget) always leaves the -//! cursor at most `N - 1` behind the newest publication it observed — the lag-recovery jump +//! cursor at most `N - 1` behind the newest publication it observed at entry (each call freezes +//! that entry sample as its drain goal, which is also what bounds the call) — the lag-recovery jump //! handles a lag over `N`, and draining even one item brings a lag of at most `N` below that — //! so keeping the publications between consecutive nonzero polls below one span *minus* //! `N - 1` suffices. [`Consumer::skip_to_latest`] leaves the cursor exactly **one** behind the @@ -226,7 +235,8 @@ pub struct PollStats { pub read: usize, /// Number of items skipped because the consumer lagged or slots were overwritten. pub dropped: usize, - /// Newest sequence observed while polling. + /// Newest sequence sampled at poll entry — the frozen drain goal for + /// that call (later publications wait for the next poll). pub newest: u32, } @@ -532,7 +542,9 @@ impl<'a, T: Copy, const N: usize> Consumer<'a, T, N> { self.dropped_accum = 0; } - /// Drain at most one item (in-order). + /// Drain at most one item (in-order). Bounded per call — this is + /// [`poll_up_to`](Self::poll_up_to)`(1, …)` and inherits its frozen + /// entry-sample window. /// Returns true if an item was delivered to the hook. #[inline] pub fn poll_one(&mut self, hook: impl FnOnce(u32, &T)) -> bool { @@ -556,11 +568,22 @@ impl<'a, T: Copy, const N: usize> Consumer<'a, T, N> { result } - /// Drain up to `max` items (in-order). + /// Drain up to `max` items (in-order) from the window that existed when + /// the call began. /// Hook sees `&T` but it is a reference to a **local copy** inside poll. /// + /// The newest published sequence is sampled **once at entry** and the + /// drain stops there: items the producer publishes while the poll runs + /// wait for the next call, and nothing is lost or double-counted by the + /// hand-off. Freezing the goal is what makes every call bounded — at + /// most one lag-recovery jump plus a walk of at most `N` slots plus + /// `max` reads, regardless of how fast the producer publishes. (The + /// previous formulation re-read the newest sequence every iteration, so + /// a producer that stayed ahead could starve the poll indefinitely.) + /// /// If `max == 0`, this returns immediately with `read = 0`, `dropped = 0`, and - /// `newest` set to the latest published sequence. + /// `newest` set to the latest published sequence. Otherwise + /// [`PollStats::newest`] reports the entry sample the drain ran against. pub fn poll_up_to(&mut self, max: usize, mut hook: impl FnMut(u32, &T)) -> PollStats { if max == 0 { return PollStats { @@ -570,7 +593,8 @@ impl<'a, T: Copy, const N: usize> Consumer<'a, T, N> { }; } - let mut newest = self.ring.newest_seq(); + // The frozen high-water mark: the drain goal for this entire call. + let newest = self.ring.newest_seq(); if newest == 0 || newest == self.last_seq { return PollStats { read: 0, @@ -582,24 +606,26 @@ impl<'a, T: Copy, const N: usize> Consumer<'a, T, N> { let mut read = 0usize; let mut dropped = 0usize; - while read < max { - newest = self.ring.newest_seq(); - if self.last_seq == newest { - break; - } - - let lag = SeqRing::::seq_distance(self.last_seq, newest) as usize; - if lag > N { - let keep_from = newest.wrapping_sub((N - 1) as u32); - let resume_after = keep_from.wrapping_sub(1); - // Everything in (last_seq, keep_from) is gone; count what was - // really assigned rather than the raw sequence span. - let jumped = SeqRing::::seq_distance(self.last_seq, resume_after) as usize; - dropped = dropped.saturating_add(jumped); - self.last_seq = resume_after; - continue; - } + // At most one lag-recovery jump per call, computed against the frozen + // mark: the cursor only moves toward it below, so the distance never + // grows again within this call. + let lag = SeqRing::::seq_distance(self.last_seq, newest) as usize; + if lag > N { + let keep_from = newest.wrapping_sub((N - 1) as u32); + let resume_after = keep_from.wrapping_sub(1); + // Everything in (last_seq, keep_from) is gone; count what was + // really assigned rather than the raw sequence span. + let jumped = SeqRing::::seq_distance(self.last_seq, resume_after) as usize; + dropped = dropped.saturating_add(jumped); + self.last_seq = resume_after; + } + // Bounded by construction: after the jump at most `N` sequences lie + // between the cursor and the frozen mark, and every iteration — + // hit or miss — advances the cursor by exactly one toward it. A miss + // means the producer overwrote that slot after the entry sample; the + // item is genuinely gone and is counted as dropped. + while read < max && self.last_seq != newest { let next = SeqRing::::next_after(self.last_seq); match self.ring.read_seq_inner(next) { @@ -924,6 +950,38 @@ mod tests { assert_eq!(got, Some((1, 20))); } + #[test] + fn poll_window_is_frozen_at_entry() { + // The bounded-poll pin: the drain goal is sampled once at entry, so + // an item published while the poll runs waits for the next call — + // freezing the goal is what bounds the call under continuous + // overwrite — and nothing is lost or double-counted at the hand-off. + let ring = SeqRing::::new(); + let producer = ring.try_producer().unwrap(); + let mut consumer = ring.try_consumer().unwrap(); + + producer.push(10); + producer.push(20); + + let mut seen = std::vec::Vec::new(); + let stats = consumer.poll_up_to(4, |seq, v| { + if seq == 1 { + // Published mid-poll: must not extend this call's window. + producer.push(30); + } + seen.push((seq, *v)); + }); + assert_eq!(stats.read, 2); + assert_eq!(stats.dropped, 0); + assert_eq!(stats.newest, 2); + assert_eq!(seen, [(1, 10), (2, 20)]); + + let stats = consumer.poll_up_to(4, |seq, v| assert_eq!((seq, *v), (3, 30))); + assert_eq!(stats.read, 1); + assert_eq!(stats.dropped, 0); + assert_eq!(stats.newest, 3); + } + #[test] fn lag_across_wrap_counts_drops_exactly() { let ring = SeqRing::::new(); diff --git a/verify-rerun-49cf88a.log b/verify-rerun-49cf88a.log new file mode 100644 index 0000000..56ea5aa --- /dev/null +++ b/verify-rerun-49cf88a.log @@ -0,0 +1,1362 @@ +==> building ph-eventing-verify (cached after the first time) +#0 building with "desktop-linux" instance using docker driver + +#1 [internal] load build definition from Dockerfile +#1 transferring dockerfile: 7.08kB 0.0s done +#1 DONE 0.0s + +#2 [internal] load metadata for docker.io/library/rust:1.92.0-slim-trixie +#2 ... + +#3 [auth] library/rust:pull token for registry-1.docker.io +#3 DONE 0.0s + +#2 [internal] load metadata for docker.io/library/rust:1.92.0-slim-trixie +#2 DONE 0.6s + +#4 [internal] load .dockerignore +#4 transferring context: 432B done +#4 DONE 0.0s + +#5 [ 1/13] FROM docker.io/library/rust:1.92.0-slim-trixie@sha256:bf3368a992915f128293ac76917ab6e561e4dda883273c8f5c9f6f8ea37a378e +#5 resolve docker.io/library/rust:1.92.0-slim-trixie@sha256:bf3368a992915f128293ac76917ab6e561e4dda883273c8f5c9f6f8ea37a378e 0.0s done +#5 DONE 0.0s + +#6 [internal] load build context +#6 transferring context: 382B done +#6 DONE 0.0s + +#7 [10/13] COPY scripts/codesize/Cargo.toml scripts/codesize/Cargo.lock /tmp/fetch/scripts/codesize/ +#7 CACHED + +#8 [ 6/13] RUN rustup toolchain install "nightly-2026-08-08" --profile minimal --component miri,rust-src && rustup target add --toolchain "nightly-2026-08-08" i686-unknown-linux-gnu armv7-unknown-linux-gnueabihf s390x-unknown-linux-gnu +#8 CACHED + +#9 [12/13] RUN mkdir -p /tmp/fetch/src /tmp/fetch/scripts/codesize/src /tmp/fetch/scripts/cycles/src && touch /tmp/fetch/src/lib.rs /tmp/fetch/scripts/codesize/src/lib.rs && printf 'fn main() {}\n' > /tmp/fetch/scripts/cycles/src/main.rs && cd /tmp/fetch && cargo fetch --locked && cd /tmp/fetch/scripts/codesize && cargo fetch --locked && cd /tmp/fetch/scripts/cycles && cargo fetch --locked && rm -rf /tmp/fetch +#9 CACHED + +#10 [ 9/13] COPY Cargo.toml Cargo.lock /tmp/fetch/ +#10 CACHED + +#11 [ 2/13] RUN apt-get update && apt-get install -y --no-install-recommends qemu-system-arm git ca-certificates && rm -rf /var/lib/apt/lists/* +#11 CACHED + +#12 [ 5/13] RUN cd /tmp/pin && rustup toolchain install && rm -rf /tmp/pin +#12 CACHED + +#13 [11/13] COPY scripts/cycles/Cargo.toml scripts/cycles/Cargo.lock /tmp/fetch/scripts/cycles/ +#13 CACHED + +#14 [ 3/13] RUN qemu-system-arm --version | head -1 | grep -qF "version 10.0." || { echo "QEMU series drifted from 10.0 -- this rebuild would not" ; echo "reproduce the release evidence. Use the published image tag, or" ; echo "re-measure and re-bless the documented counts with the new QEMU" ; echo "(then update QEMU_SERIES)." ; qemu-system-arm --version | head -1 ; exit 1 ; } +#14 CACHED + +#15 [ 4/13] COPY rust-toolchain.toml /tmp/pin/rust-toolchain.toml +#15 CACHED + +#16 [ 7/13] RUN rustup toolchain install stable --profile minimal --component clippy +#16 CACHED + +#17 [ 8/13] RUN cargo install --locked cargo-deny@0.20.2 cargo-llvm-cov@0.8.7 +#17 CACHED + +#18 [13/13] WORKDIR /work +#18 CACHED + +#19 exporting to image +#19 exporting layers done +#19 exporting manifest sha256:3a303e22980005cdb07e49d504f213ec1f0b0198afa6f436f98f8e5c5589a122 done +#19 exporting config sha256:d364bcf3741d81aa47a1ac1a84a020a5d65c9df9f144683a20ba2cdcbb61e1a6 done +#19 exporting attestation manifest sha256:c352cec73e080e3143c304f3de012f4eeabc41c4fa45b488d50aeaa8c9b8e4cc +#19 exporting attestation manifest sha256:c352cec73e080e3143c304f3de012f4eeabc41c4fa45b488d50aeaa8c9b8e4cc 0.0s done +#19 exporting manifest list sha256:f0615b6646e101e86205324ebb5f820a38af915b7cff304afe8135bc04d19620 0.0s done +#19 naming to docker.io/library/ph-eventing-verify:latest done +#19 unpacking to docker.io/library/ph-eventing-verify:latest 0.0s done +#19 DONE 0.1s +rustc 1.92.0 (ded5c06cf 2025-12-08) +rustc 1.99.0-nightly (1a98b1e13 2026-08-07) +QEMU emulator version 10.0.11 (Debian 1:10.0.11+ds-0+deb13u1) + +########## ci ########## + +==> fmt + +==> clippy + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s + +==> test + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.17s + Running unittests src/lib.rs (target/debug/deps/ph_eventing-0738c850a5905c40) + +running 101 tests +test block::tests::clear_discards_a_partial_block ... ok +test block::tests::default_and_capacity_match_new ... ok +test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok +test block::tests::rejects_gap_without_hiding_loss_policy ... ok +test block::tests::completion_resets_for_the_next_block ... ok +test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok +test block::tests::sequence_wrap_skips_zero ... ok +test block::tests::completes_only_after_n_contiguous_samples ... ok +test block::tests::works_without_default_bound ... ok +test counted_signal::tests::handles_are_send ... ok +test counted_signal::tests::const_new_works_in_static_context ... ok +test counted_signal::tests::increments_accumulate_and_take_clears ... ok +test event_buf::tests::default_is_new ... ok +test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test counted_signal::tests::saturates_instead_of_wrapping ... ok +test event_buf::tests::const_new_works_in_const_context ... ok +test event_buf::tests::drain_on_empty_returns_zero ... ok +test event_buf::tests::drain_returns_count ... ok +test event_buf::tests::handles_are_send ... ok +test event_buf::tests::len_and_full_track_state ... ok +test event_buf::tests::peek_copies_without_advancing ... ok +test event_buf::tests::new_buf_is_empty ... ok +test event_buf::tests::producer_consumer_can_be_recreated ... ok +test event_buf::tests::push_and_pop_fifo ... ok +test event_buf::tests::push_rejects_when_full ... ok +test event_buf::tests::static_buf_yields_static_sendable_handles ... ok +test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok +test event_buf::tests::try_producer_and_try_consumer ... ok +test event_buf::tests::wraps_around_correctly ... ok +test event_flags::tests::const_new_works_in_static_context ... ok +test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok +test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok +test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok +test event_flags::tests::event_flags_object_is_eight_bytes ... ok +test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok +test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test event_flags::tests::handles_are_send_and_container_is_sync ... ok +test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok +test event_flags::tests::observed_raise_publishes_preceding_memory ... ok +test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok +test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok +test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok +test latest_buf::tests::handle_reacquisition_continues_role_state ... ok +test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok +test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok +test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok +test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok +test macros::tests::event_buf_module_round_trips ... ok +test macros::tests::event_buf_take_is_once_only ... ok +test macros::tests::failed_take_strands_nothing ... ok +test macros::tests::handles_are_send ... ok +test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok +test ring::tests::clear_resets_state ... ok +test ring::tests::huge_capacity_does_not_overflow_the_index ... ok +test ring::tests::default_is_new ... ok +test ring::tests::const_new_works_in_const_context ... ok +test ring::tests::capacity_returns_n ... ok +test ring::tests::iter_oldest_to_newest ... ok +test ring::tests::new_ring_is_empty ... ok +test ring::tests::into_iter_for_ref ... ok +test ring::tests::iter_exact_size ... ok +test ring::tests::push_and_get ... ok +test macros::tests::seq_ring_module_round_trips ... ok +test seq_ring::tests::capacity_returns_n ... ok +test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok +test seq_ring::tests::const_new_works_in_const_context ... ok +test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok +test ring::tests::works_without_default_bound ... ok +test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok +test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok +test ring::tests::overwrite_oldest_when_full ... ok +test seq_ring::tests::drops_when_consumer_lags ... ok +test seq_ring::tests::latest_empty_returns_false ... ok +test seq_ring::tests::latest_reads_newest ... ok +test seq_ring::tests::dropped_counter_can_reset ... ok +test seq_ring::tests::latest_returns_false_when_slot_missing ... ok +test seq_ring::tests::poll_one_empty_returns_false ... ok +test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok +test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok +test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok +test seq_ring::tests::polls_in_order ... ok +test seq_ring::tests::poll_one_value_and_latest_value ... ok +test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok +test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok +test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok +test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok +test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok +test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok +test traits::tests::event_consumer_as_source ... ok +test seq_ring::tests::try_producer_and_try_consumer ... ok +test traits::tests::event_producer_as_sink ... ok +test traits::tests::forward_event_to_ringbuf ... ok +test traits::tests::forward_empty_source_transfers_nothing ... ok +test traits::tests::forward_seq_to_event ... ok +test traits::tests::forward_stops_when_sink_full ... ok +test traits::tests::generic_drain_event ... ok +test traits::tests::generic_drain_seq ... ok +test traits::tests::ringbuf_as_sink ... ok +test traits::tests::seq_consumer_as_source ... ok +test seq_ring::tests::concurrent_overwrite_never_yields_a_mismatched_value ... ok +test traits::tests::seq_producer_as_sink ... ok + +test result: ok. 101 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + Doc-tests ph_eventing + +running 13 tests +test src/event_buf.rs - event_buf (line 31) ... ok +test src/latest_buf.rs - latest_buf (line 59) ... ok +test src/block.rs - block (line 61) ... ok +test src/lib.rs - (line 131) ... ok +test src/macros.rs - macros::static_spsc (line 27) ... ok +test src/lib.rs - (line 36) ... ok +test src/lib.rs - (line 88) ... ok +test src/lib.rs - (line 60) ... ok +test src/ring.rs - ring (line 16) ... ok +test src/lib.rs - (line 49) ... ok +test src/macros.rs - macros::static_spsc (line 48) ... ok +test src/traits.rs - traits::forward (line 84) ... ok +test src/lib.rs - (line 75) ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + +running 11 tests +test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok +test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok +test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok +test src/block.rs - block (line 79) - compile fail ... ok +test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok +test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok +test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +all doctests ran in 0.75s; merged doctests compilation took 0.67s + +==> doc + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s + Generated /work/target/doc/ph_eventing/index.html + +==> features: default + +==> features: portable-atomic + +==> features: critical-section + +==> stable: test + +running 101 tests +....................................................................................... 87/101 +.............. +test result: ok. 101 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + + +running 13 tests +............. +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + + +running 11 tests +........... +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +all doctests ran in 0.75s; merged doctests compilation took 0.67s + +==> stable: clippy + +==> deny +advisories ok, bans ok, licenses ok, sources ok + +==> coverage (>=90% lines) +info: cargo-llvm-cov currently setting cfg(coverage); you can opt-out it by passing --no-cfg-coverage + Compiling ph-eventing v0.3.0 (/work) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.40s + Running unittests src/lib.rs (target/llvm-cov-target/debug/deps/ph_eventing-0738c850a5905c40) + +running 101 tests +test block::tests::clear_discards_a_partial_block ... ok +test block::tests::default_and_capacity_match_new ... ok +test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok +test block::tests::rejects_gap_without_hiding_loss_policy ... ok +test block::tests::completion_resets_for_the_next_block ... ok +test block::tests::completes_only_after_n_contiguous_samples ... ok +test block::tests::sequence_wrap_skips_zero ... ok +test counted_signal::tests::const_new_works_in_static_context ... ok +test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok +test block::tests::works_without_default_bound ... ok +test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test counted_signal::tests::handles_are_send ... ok +test counted_signal::tests::increments_accumulate_and_take_clears ... ok +test counted_signal::tests::saturates_instead_of_wrapping ... ok +test event_buf::tests::const_new_works_in_const_context ... ok +test event_buf::tests::default_is_new ... ok +test event_buf::tests::drain_on_empty_returns_zero ... ok +test event_buf::tests::drain_returns_count ... ok +test event_buf::tests::handles_are_send ... ok +test event_buf::tests::len_and_full_track_state ... ok +test event_buf::tests::new_buf_is_empty ... ok +test event_buf::tests::peek_copies_without_advancing ... ok +test event_buf::tests::producer_consumer_can_be_recreated ... ok +test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok +test event_buf::tests::push_and_pop_fifo ... ok +test event_buf::tests::push_rejects_when_full ... ok +test event_buf::tests::static_buf_yields_static_sendable_handles ... ok +test event_buf::tests::try_producer_and_try_consumer ... ok +test event_buf::tests::wraps_around_correctly ... ok +test event_flags::tests::const_new_works_in_static_context ... ok +test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok +test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok +test event_flags::tests::event_flags_object_is_eight_bytes ... ok +test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok +test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test event_flags::tests::handles_are_send_and_container_is_sync ... ok +test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok +test event_flags::tests::observed_raise_publishes_preceding_memory ... ok +test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok +test latest_buf::tests::handle_reacquisition_continues_role_state ... ok +test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok +test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok +test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok +test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok +test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok +test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok +test macros::tests::event_buf_module_round_trips ... ok +test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok +test macros::tests::failed_take_strands_nothing ... ok +test macros::tests::handles_are_send ... ok +test macros::tests::event_buf_take_is_once_only ... ok +test macros::tests::seq_ring_module_round_trips ... ok +test ring::tests::capacity_returns_n ... ok +test ring::tests::const_new_works_in_const_context ... ok +test ring::tests::clear_resets_state ... ok +test ring::tests::default_is_new ... ok +test ring::tests::huge_capacity_does_not_overflow_the_index ... ok +test ring::tests::into_iter_for_ref ... ok +test ring::tests::iter_exact_size ... ok +test ring::tests::iter_oldest_to_newest ... ok +test ring::tests::new_ring_is_empty ... ok +test ring::tests::overwrite_oldest_when_full ... ok +test ring::tests::push_and_get ... ok +test ring::tests::works_without_default_bound ... ok +test seq_ring::tests::capacity_returns_n ... ok +test seq_ring::tests::const_new_works_in_const_context ... ok +test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok +test seq_ring::tests::dropped_counter_can_reset ... ok +test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok +test seq_ring::tests::drops_when_consumer_lags ... ok +test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok +test seq_ring::tests::latest_empty_returns_false ... ok +test seq_ring::tests::latest_reads_newest ... ok +test seq_ring::tests::latest_returns_false_when_slot_missing ... ok +test seq_ring::tests::poll_one_empty_returns_false ... ok +test seq_ring::tests::poll_one_value_and_latest_value ... ok +test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok +test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok +test seq_ring::tests::polls_in_order ... ok +test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok +test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok +test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok +test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok +test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok +test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok +test seq_ring::tests::try_producer_and_try_consumer ... ok +test traits::tests::event_consumer_as_source ... ok +test traits::tests::event_producer_as_sink ... ok +test traits::tests::forward_empty_source_transfers_nothing ... ok +test seq_ring::tests::concurrent_overwrite_never_yields_a_mismatched_value ... ok +test traits::tests::forward_event_to_ringbuf ... ok +test traits::tests::forward_seq_to_event ... ok +test traits::tests::forward_stops_when_sink_full ... ok +test traits::tests::generic_drain_event ... ok +test traits::tests::generic_drain_seq ... ok +test traits::tests::ringbuf_as_sink ... ok +test traits::tests::seq_consumer_as_source ... ok +test traits::tests::seq_producer_as_sink ... ok +test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok +test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok +test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok + +test result: ok. 101 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + +Filename Regions Missed Regions Cover Functions Missed Functions Executed Lines Missed Lines Cover Branches Missed Branches Cover +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +block.rs 261 14 94.64% 23 1 95.65% 148 9 93.92% 0 0 - +counted_signal.rs 250 18 92.80% 24 4 83.33% 136 12 91.18% 0 0 - +event_buf.rs 667 30 95.50% 53 4 92.45% 342 19 94.44% 0 0 - +event_flags.rs 340 23 93.24% 38 5 86.84% 197 15 92.39% 0 0 - +latest_buf.rs 405 28 93.09% 46 6 86.96% 260 19 92.69% 0 0 - +lib.rs 5 1 80.00% 1 0 100.00% 3 0 100.00% 0 0 - +macros.rs 106 10 90.57% 10 0 100.00% 57 4 92.98% 0 0 - +ring.rs 330 19 94.24% 30 2 93.33% 180 11 93.89% 0 0 - +seq_ring.rs 940 48 94.89% 81 9 88.89% 522 36 93.10% 0 0 - +sync.rs 11 0 100.00% 3 0 100.00% 9 0 100.00% 0 0 - +traits.rs 299 2 99.33% 13 0 100.00% 132 1 99.24% 0 0 - +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +TOTAL 3614 193 94.66% 322 31 90.37% 1986 126 93.66% 0 0 - + +==> codesize (baseline gate) +TARGET two_calls cs_incr cs_take flags_acq flags_raise flags_take split bss seq_bss data +------------------------------ --------- ------- ------- --------- ----------- ---------- ----- --- ------- ---- +thumbv6m-none-eabi 156 46 24 68 24 24 - 268 524 0 +thumbv8m.base-none-eabi 156 44 22 64 22 22 - 268 524 0 +thumbv7m-none-eabi 172 44 22 72 26 26 - 268 524 0 +thumbv7em-none-eabi 172 44 22 72 26 26 - 268 524 0 +thumbv8m.main-none-eabi 152 44 22 64 22 22 - 268 524 0 +armv7r-none-eabi 220 64 28 116 32 32 - 268 524 0 +armv7a-none-eabi 220 64 28 116 32 32 - 268 524 0 +riscv32imac-unknown-none-elf 152 26 8 66 8 8 - 268 524 0 + + +Baseline gate (tolerance +16 bytes, growth only) + ok -- no row grew by more than 16 bytes +split column is "-" unless run as: ./scripts/codesize.sh split +Xtensa rows need: XTENSA=1 and the esp-rs toolchain. + +==> codesize (block matrix gate) +TARGET SHAPE code_B block_B accepted_B rejected_B +------------------------------ ---------- ------ ------- ---------- ---------- +thumbv6m-none-eabi w2_n8 138 24 48 48 +thumbv6m-none-eabi w2_n32 168 72 144 144 +thumbv6m-none-eabi w2_n128 200 264 528 528 +thumbv6m-none-eabi w8_n8 192 72 144 144 +thumbv6m-none-eabi w8_n32 224 264 528 528 +thumbv6m-none-eabi w8_n128 240 1032 2064 2064 +thumbv6m-none-eabi w16_n8 242 136 272 272 +thumbv6m-none-eabi w16_n32 254 520 1040 1040 +thumbv6m-none-eabi w16_n128 268 2056 4112 4112 +thumbv8m.base-none-eabi w2_n8 136 24 48 48 +thumbv8m.base-none-eabi w2_n32 168 72 144 144 +thumbv8m.base-none-eabi w2_n128 190 264 528 528 +thumbv8m.base-none-eabi w8_n8 192 72 144 144 +thumbv8m.base-none-eabi w8_n32 228 264 528 528 +thumbv8m.base-none-eabi w8_n128 244 1032 2064 2064 +thumbv8m.base-none-eabi w16_n8 242 136 272 272 +thumbv8m.base-none-eabi w16_n32 250 520 1040 1040 +thumbv8m.base-none-eabi w16_n128 264 2056 4112 4112 +thumbv7m-none-eabi w2_n8 136 24 48 48 +thumbv7m-none-eabi w2_n32 132 72 144 144 +thumbv7m-none-eabi w2_n128 130 264 528 528 +thumbv7m-none-eabi w8_n8 120 72 144 144 +thumbv7m-none-eabi w8_n32 118 264 528 528 +thumbv7m-none-eabi w8_n128 178 1032 2064 2064 +thumbv7m-none-eabi w16_n8 200 136 272 272 +thumbv7m-none-eabi w16_n32 208 520 1040 1040 +thumbv7m-none-eabi w16_n128 216 2056 4112 4112 +thumbv7em-none-eabi w2_n8 136 24 48 48 +thumbv7em-none-eabi w2_n32 132 72 144 144 +thumbv7em-none-eabi w2_n128 130 264 528 528 +thumbv7em-none-eabi w8_n8 120 72 144 144 +thumbv7em-none-eabi w8_n32 118 264 528 528 +thumbv7em-none-eabi w8_n128 178 1032 2064 2064 +thumbv7em-none-eabi w16_n8 200 136 272 272 +thumbv7em-none-eabi w16_n32 208 520 1040 1040 +thumbv7em-none-eabi w16_n128 216 2056 4112 4112 +thumbv8m.main-none-eabi w2_n8 138 24 48 48 +thumbv8m.main-none-eabi w2_n32 126 72 144 144 +thumbv8m.main-none-eabi w2_n128 122 264 528 528 +thumbv8m.main-none-eabi w8_n8 114 72 144 144 +thumbv8m.main-none-eabi w8_n32 110 264 528 528 +thumbv8m.main-none-eabi w8_n128 182 1032 2064 2064 +thumbv8m.main-none-eabi w16_n8 204 136 272 272 +thumbv8m.main-none-eabi w16_n32 212 520 1040 1040 +thumbv8m.main-none-eabi w16_n128 216 2056 4112 4112 +armv7r-none-eabi w2_n8 212 24 48 48 +armv7r-none-eabi w2_n32 248 72 144 144 +armv7r-none-eabi w2_n128 252 264 528 528 +armv7r-none-eabi w8_n8 312 72 144 144 +armv7r-none-eabi w8_n32 312 264 528 528 +armv7r-none-eabi w8_n128 312 1032 2064 2064 +armv7r-none-eabi w16_n8 296 136 272 272 +armv7r-none-eabi w16_n32 288 520 1040 1040 +armv7r-none-eabi w16_n128 300 2056 4112 4112 +armv7a-none-eabi w2_n8 212 24 48 48 +armv7a-none-eabi w2_n32 248 72 144 144 +armv7a-none-eabi w2_n128 252 264 528 528 +armv7a-none-eabi w8_n8 312 72 144 144 +armv7a-none-eabi w8_n32 312 264 528 528 +armv7a-none-eabi w8_n128 312 1032 2064 2064 +armv7a-none-eabi w16_n8 296 136 272 272 +armv7a-none-eabi w16_n32 288 520 1040 1040 +armv7a-none-eabi w16_n128 300 2056 4112 4112 +riscv32imac-unknown-none-elf w2_n8 150 24 48 48 +riscv32imac-unknown-none-elf w2_n32 190 72 144 144 +riscv32imac-unknown-none-elf w2_n128 210 264 528 528 +riscv32imac-unknown-none-elf w8_n8 202 72 144 144 +riscv32imac-unknown-none-elf w8_n32 222 264 528 528 +riscv32imac-unknown-none-elf w8_n128 252 1032 2064 2064 +riscv32imac-unknown-none-elf w16_n8 250 136 272 272 +riscv32imac-unknown-none-elf w16_n32 248 520 1040 1040 +riscv32imac-unknown-none-elf w16_n128 300 2056 4112 4112 + +block_B is size_of::>(). accepted_B counts builder +completion plus publication; rejected_B counts completion plus +returning the complete rejected block to the caller. +These are logical payload-traffic bounds; code_B is emitted flash. +Run scripts/cycles.sh for accepted/rejected instruction paths. + + +Baseline gate (tolerance +16 bytes, growth only) + ok -- no row grew by more than 16 bytes +split column is "-" unless run as: ./scripts/codesize.sh split +Xtensa rows need: XTENSA=1 and the esp-rs toolchain. + +==> thumbv6m-none-eabi + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s + +==> thumbv7em-none-eabi + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s + +==> riscv32imac-unknown-none-elf + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.14s + +Summary + PASS fmt + PASS clippy + PASS test + PASS doc + PASS features: default + PASS features: portable-atomic + PASS features: critical-section + PASS stable: test + PASS stable: clippy + PASS deny + PASS coverage (>=90% lines) + PASS codesize (baseline gate) + PASS codesize (block matrix gate) + PASS thumbv6m-none-eabi + PASS thumbv7em-none-eabi + PASS riscv32imac-unknown-none-elf + +All checks passed. + +########## miri ########## +==> miri toolchain: rustc 1.99.0-nightly (1a98b1e13 2026-08-07) + +==> host: full checking +Preparing a sysroot for Miri (target: x86_64-unknown-linux-gnu)... done + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s + Running unittests src/lib.rs (target/miri/x86_64-unknown-linux-gnu/debug/build/ph-eventing/073c6757111f2f6e/out/ph_eventing-073c6757111f2f6e) + +running 100 tests +test block::tests::clear_discards_a_partial_block ... ok +test block::tests::completes_only_after_n_contiguous_samples ... ok +test block::tests::completion_resets_for_the_next_block ... ok +test block::tests::default_and_capacity_match_new ... ok +test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok +test block::tests::rejects_gap_without_hiding_loss_policy ... ok +test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok +test block::tests::sequence_wrap_skips_zero ... ok +test block::tests::works_without_default_bound ... ok +test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok +test counted_signal::tests::const_new_works_in_static_context ... ok +test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test counted_signal::tests::handles_are_send ... ok +test counted_signal::tests::increments_accumulate_and_take_clears ... ok +test counted_signal::tests::saturates_instead_of_wrapping ... ok +test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok +test event_buf::tests::const_new_works_in_const_context ... ok +test event_buf::tests::default_is_new ... ok +test event_buf::tests::drain_on_empty_returns_zero ... ok +test event_buf::tests::drain_returns_count ... ok +test event_buf::tests::handles_are_send ... ok +test event_buf::tests::len_and_full_track_state ... ok +test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok +test event_buf::tests::new_buf_is_empty ... ok +test event_buf::tests::peek_copies_without_advancing ... ok +test event_buf::tests::producer_consumer_can_be_recreated ... ok +test event_buf::tests::push_and_pop_fifo ... ok +test event_buf::tests::push_rejects_when_full ... ok +test event_buf::tests::static_buf_yields_static_sendable_handles ... ok +test event_buf::tests::try_producer_and_try_consumer ... ok +test event_buf::tests::wraps_around_correctly ... ok +test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok +test event_flags::tests::const_new_works_in_static_context ... ok +test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok +test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok +test event_flags::tests::event_flags_object_is_eight_bytes ... ok +test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok +test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test event_flags::tests::handles_are_send_and_container_is_sync ... ok +test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok +test event_flags::tests::observed_raise_publishes_preceding_memory ... ok +test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok +test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok +test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok +test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok +test latest_buf::tests::handle_reacquisition_continues_role_state ... ok +test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok +test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok +test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok +test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok +test macros::tests::event_buf_module_round_trips ... ok +test macros::tests::event_buf_take_is_once_only ... ok +test macros::tests::failed_take_strands_nothing ... ok +test macros::tests::handles_are_send ... ok +test macros::tests::seq_ring_module_round_trips ... ok +test ring::tests::capacity_returns_n ... ok +test ring::tests::clear_resets_state ... ok +test ring::tests::const_new_works_in_const_context ... ok +test ring::tests::default_is_new ... ok +test ring::tests::huge_capacity_does_not_overflow_the_index ... ok +test ring::tests::into_iter_for_ref ... ok +test ring::tests::iter_exact_size ... ok +test ring::tests::iter_oldest_to_newest ... ok +test ring::tests::new_ring_is_empty ... ok +test ring::tests::overwrite_oldest_when_full ... ok +test ring::tests::push_and_get ... ok +test ring::tests::works_without_default_bound ... ok +test seq_ring::tests::capacity_returns_n ... ok +test seq_ring::tests::const_new_works_in_const_context ... ok +test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok +test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok +test seq_ring::tests::dropped_counter_can_reset ... ok +test seq_ring::tests::drops_when_consumer_lags ... ok +test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok +test seq_ring::tests::latest_empty_returns_false ... ok +test seq_ring::tests::latest_reads_newest ... ok +test seq_ring::tests::latest_returns_false_when_slot_missing ... ok +test seq_ring::tests::poll_one_empty_returns_false ... ok +test seq_ring::tests::poll_one_value_and_latest_value ... ok +test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok +test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok +test seq_ring::tests::polls_in_order ... ok +test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok +test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok +test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok +test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok +test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok +test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok +test seq_ring::tests::try_producer_and_try_consumer ... ok +test traits::tests::event_consumer_as_source ... ok +test traits::tests::event_producer_as_sink ... ok +test traits::tests::forward_empty_source_transfers_nothing ... ok +test traits::tests::forward_event_to_ringbuf ... ok +test traits::tests::forward_seq_to_event ... ok +test traits::tests::forward_stops_when_sink_full ... ok +test traits::tests::generic_drain_event ... ok +test traits::tests::generic_drain_seq ... ok +test traits::tests::ringbuf_as_sink ... ok +test traits::tests::seq_consumer_as_source ... ok +test traits::tests::seq_producer_as_sink ... ok + +test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 6.15s + + Doc-tests ph_eventing + +running 24 tests +test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok +test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok +test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok +test src/block.rs - block (line 79) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok +test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok +test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok +test src/lib.rs - (line 49) ... ok +test src/ring.rs - ring (line 16) ... ok +test src/lib.rs - (line 131) ... ok +test src/lib.rs - (line 60) ... ok +test src/event_buf.rs - event_buf (line 31) ... ok +test src/traits.rs - traits::forward (line 84) ... ok +test src/lib.rs - (line 75) ... ok +test src/macros.rs - macros::static_spsc (line 27) ... ok +test src/block.rs - block (line 61) ... ok +test src/lib.rs - (line 36) ... ok +test src/macros.rs - macros::static_spsc (line 48) ... ok +test src/latest_buf.rs - latest_buf (line 59) ... ok +test src/lib.rs - (line 88) ... ok + +test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s + +all doctests ran in 0.22s; merged doctests compilation took 0.01s + +==> host: seqlock logic (race detector off) + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.16s + Running unittests src/lib.rs (target/miri/x86_64-unknown-linux-gnu/debug/build/ph-eventing/073c6757111f2f6e/out/ph_eventing-073c6757111f2f6e) + +running 1 test +test seq_ring::tests::concurrent_overwrite_never_yields_a_mismatched_value ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 100 filtered out; finished in 0.51s + + +==> host: 16 scheduler seeds + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s + Running unittests src/lib.rs (target/miri/x86_64-unknown-linux-gnu/debug/build/ph-eventing/073c6757111f2f6e/out/ph_eventing-073c6757111f2f6e) +Trying seed: 12 +Trying seed: 13 +Trying seed: 14 +Trying seed: 11 +Trying seed: 15 +Trying seed: 10 +Trying seed: 8 +Trying seed: 6 +Trying seed: 0 +Trying seed: 2 +Trying seed: 7 +Trying seed: 9 +Trying seed: 1 +Trying seed: 5 +Trying seed: 4 +Trying seed: 3 + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests + +running 2 tests +test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... okokokokokokok + + + +ok + + +ok +ok +okokok + + + +test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... okok + +ok +test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... okok + +okokok + + +okok +okok +ok + + + +ok + + +test result: test result: test result: +test result: +oktest result: ok +okok +okok +test result: +test result: +test result: okok +test result: +oktest result: test result: +ok +oktest result: okok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered outok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered outok +. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out +. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered outok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered outok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out + +; finished in 2.60s; finished in 2.60s; finished in 2.61s; finished in 2.61stest result: +test result: ; finished in 2.61s; finished in 2.60s; finished in 2.62s + + + + + + + +; finished in 2.62s + +ok + +ok + +; finished in 2.62s; finished in 2.62s; finished in 2.62s + +. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out + + + + + + +; finished in 2.63stest result: + +ok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out; finished in 2.64s. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out + +. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out; finished in 2.67s; finished in 2.67s + + + +; finished in 2.68s + + Doc-tests ph_eventing + +running 0 tests + +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 24 filtered out; finished in 0.00s + +all doctests ran in 0.02s; merged doctests compilation took 0.01s + +==> i686-unknown-linux-gnu +Preparing a sysroot for Miri (target: i686-unknown-linux-gnu)... done + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s + Running unittests src/lib.rs (target/miri/i686-unknown-linux-gnu/debug/build/ph-eventing/d6bee4c6ee73bcea/out/ph_eventing-d6bee4c6ee73bcea) + +running 100 tests +test block::tests::clear_discards_a_partial_block ... ok +test block::tests::completes_only_after_n_contiguous_samples ... ok +test block::tests::completion_resets_for_the_next_block ... ok +test block::tests::default_and_capacity_match_new ... ok +test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok +test block::tests::rejects_gap_without_hiding_loss_policy ... ok +test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok +test block::tests::sequence_wrap_skips_zero ... ok +test block::tests::works_without_default_bound ... ok +test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok +test counted_signal::tests::const_new_works_in_static_context ... ok +test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test counted_signal::tests::handles_are_send ... ok +test counted_signal::tests::increments_accumulate_and_take_clears ... ok +test counted_signal::tests::saturates_instead_of_wrapping ... ok +test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok +test event_buf::tests::const_new_works_in_const_context ... ok +test event_buf::tests::default_is_new ... ok +test event_buf::tests::drain_on_empty_returns_zero ... ok +test event_buf::tests::drain_returns_count ... ok +test event_buf::tests::handles_are_send ... ok +test event_buf::tests::len_and_full_track_state ... ok +test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok +test event_buf::tests::new_buf_is_empty ... ok +test event_buf::tests::peek_copies_without_advancing ... ok +test event_buf::tests::producer_consumer_can_be_recreated ... ok +test event_buf::tests::push_and_pop_fifo ... ok +test event_buf::tests::push_rejects_when_full ... ok +test event_buf::tests::static_buf_yields_static_sendable_handles ... ok +test event_buf::tests::try_producer_and_try_consumer ... ok +test event_buf::tests::wraps_around_correctly ... ok +test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok +test event_flags::tests::const_new_works_in_static_context ... ok +test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok +test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok +test event_flags::tests::event_flags_object_is_eight_bytes ... ok +test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok +test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test event_flags::tests::handles_are_send_and_container_is_sync ... ok +test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok +test event_flags::tests::observed_raise_publishes_preceding_memory ... ok +test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok +test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok +test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok +test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok +test latest_buf::tests::handle_reacquisition_continues_role_state ... ok +test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok +test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok +test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok +test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok +test macros::tests::event_buf_module_round_trips ... ok +test macros::tests::event_buf_take_is_once_only ... ok +test macros::tests::failed_take_strands_nothing ... ok +test macros::tests::handles_are_send ... ok +test macros::tests::seq_ring_module_round_trips ... ok +test ring::tests::capacity_returns_n ... ok +test ring::tests::clear_resets_state ... ok +test ring::tests::const_new_works_in_const_context ... ok +test ring::tests::default_is_new ... ok +test ring::tests::huge_capacity_does_not_overflow_the_index ... ok +test ring::tests::into_iter_for_ref ... ok +test ring::tests::iter_exact_size ... ok +test ring::tests::iter_oldest_to_newest ... ok +test ring::tests::new_ring_is_empty ... ok +test ring::tests::overwrite_oldest_when_full ... ok +test ring::tests::push_and_get ... ok +test ring::tests::works_without_default_bound ... ok +test seq_ring::tests::capacity_returns_n ... ok +test seq_ring::tests::const_new_works_in_const_context ... ok +test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok +test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok +test seq_ring::tests::dropped_counter_can_reset ... ok +test seq_ring::tests::drops_when_consumer_lags ... ok +test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok +test seq_ring::tests::latest_empty_returns_false ... ok +test seq_ring::tests::latest_reads_newest ... ok +test seq_ring::tests::latest_returns_false_when_slot_missing ... ok +test seq_ring::tests::poll_one_empty_returns_false ... ok +test seq_ring::tests::poll_one_value_and_latest_value ... ok +test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok +test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok +test seq_ring::tests::polls_in_order ... ok +test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok +test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok +test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok +test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok +test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok +test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok +test seq_ring::tests::try_producer_and_try_consumer ... ok +test traits::tests::event_consumer_as_source ... ok +test traits::tests::event_producer_as_sink ... ok +test traits::tests::forward_empty_source_transfers_nothing ... ok +test traits::tests::forward_event_to_ringbuf ... ok +test traits::tests::forward_seq_to_event ... ok +test traits::tests::forward_stops_when_sink_full ... ok +test traits::tests::generic_drain_event ... ok +test traits::tests::generic_drain_seq ... ok +test traits::tests::ringbuf_as_sink ... ok +test traits::tests::seq_consumer_as_source ... ok +test traits::tests::seq_producer_as_sink ... ok + +test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 6.48s + + Doc-tests ph_eventing + +running 24 tests +test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok +test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok +test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok +test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok +test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok +test src/block.rs - block (line 79) - compile fail ... ok +test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok +test src/event_buf.rs - event_buf (line 31) ... ok +test src/block.rs - block (line 61) ... ok +test src/lib.rs - (line 131) ... ok +test src/lib.rs - (line 75) ... ok +test src/lib.rs - (line 49) ... ok +test src/traits.rs - traits::forward (line 84) ... ok +test src/lib.rs - (line 88) ... ok +test src/ring.rs - ring (line 16) ... ok +test src/latest_buf.rs - latest_buf (line 59) ... ok +test src/lib.rs - (line 60) ... ok +test src/lib.rs - (line 36) ... ok +test src/macros.rs - macros::static_spsc (line 48) ... ok +test src/macros.rs - macros::static_spsc (line 27) ... ok + +test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s + +all doctests ran in 0.24s; merged doctests compilation took 0.01s + +==> armv7-unknown-linux-gnueabihf +Preparing a sysroot for Miri (target: armv7-unknown-linux-gnueabihf)... done + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s + Running unittests src/lib.rs (target/miri/armv7-unknown-linux-gnueabihf/debug/build/ph-eventing/d6573e025e5953bc/out/ph_eventing-d6573e025e5953bc) + +running 100 tests +test block::tests::clear_discards_a_partial_block ... ok +test block::tests::completes_only_after_n_contiguous_samples ... ok +test block::tests::completion_resets_for_the_next_block ... ok +test block::tests::default_and_capacity_match_new ... ok +test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok +test block::tests::rejects_gap_without_hiding_loss_policy ... ok +test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok +test block::tests::sequence_wrap_skips_zero ... ok +test block::tests::works_without_default_bound ... ok +test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok +test counted_signal::tests::const_new_works_in_static_context ... ok +test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test counted_signal::tests::handles_are_send ... ok +test counted_signal::tests::increments_accumulate_and_take_clears ... ok +test counted_signal::tests::saturates_instead_of_wrapping ... ok +test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok +test event_buf::tests::const_new_works_in_const_context ... ok +test event_buf::tests::default_is_new ... ok +test event_buf::tests::drain_on_empty_returns_zero ... ok +test event_buf::tests::drain_returns_count ... ok +test event_buf::tests::handles_are_send ... ok +test event_buf::tests::len_and_full_track_state ... ok +test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok +test event_buf::tests::new_buf_is_empty ... ok +test event_buf::tests::peek_copies_without_advancing ... ok +test event_buf::tests::producer_consumer_can_be_recreated ... ok +test event_buf::tests::push_and_pop_fifo ... ok +test event_buf::tests::push_rejects_when_full ... ok +test event_buf::tests::static_buf_yields_static_sendable_handles ... ok +test event_buf::tests::try_producer_and_try_consumer ... ok +test event_buf::tests::wraps_around_correctly ... ok +test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok +test event_flags::tests::const_new_works_in_static_context ... ok +test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok +test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok +test event_flags::tests::event_flags_object_is_eight_bytes ... ok +test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok +test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test event_flags::tests::handles_are_send_and_container_is_sync ... ok +test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok +test event_flags::tests::observed_raise_publishes_preceding_memory ... ok +test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok +test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok +test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok +test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok +test latest_buf::tests::handle_reacquisition_continues_role_state ... ok +test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok +test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok +test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok +test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok +test macros::tests::event_buf_module_round_trips ... ok +test macros::tests::event_buf_take_is_once_only ... ok +test macros::tests::failed_take_strands_nothing ... ok +test macros::tests::handles_are_send ... ok +test macros::tests::seq_ring_module_round_trips ... ok +test ring::tests::capacity_returns_n ... ok +test ring::tests::clear_resets_state ... ok +test ring::tests::const_new_works_in_const_context ... ok +test ring::tests::default_is_new ... ok +test ring::tests::huge_capacity_does_not_overflow_the_index ... ok +test ring::tests::into_iter_for_ref ... ok +test ring::tests::iter_exact_size ... ok +test ring::tests::iter_oldest_to_newest ... ok +test ring::tests::new_ring_is_empty ... ok +test ring::tests::overwrite_oldest_when_full ... ok +test ring::tests::push_and_get ... ok +test ring::tests::works_without_default_bound ... ok +test seq_ring::tests::capacity_returns_n ... ok +test seq_ring::tests::const_new_works_in_const_context ... ok +test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok +test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok +test seq_ring::tests::dropped_counter_can_reset ... ok +test seq_ring::tests::drops_when_consumer_lags ... ok +test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok +test seq_ring::tests::latest_empty_returns_false ... ok +test seq_ring::tests::latest_reads_newest ... ok +test seq_ring::tests::latest_returns_false_when_slot_missing ... ok +test seq_ring::tests::poll_one_empty_returns_false ... ok +test seq_ring::tests::poll_one_value_and_latest_value ... ok +test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok +test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok +test seq_ring::tests::polls_in_order ... ok +test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok +test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok +test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok +test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok +test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok +test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok +test seq_ring::tests::try_producer_and_try_consumer ... ok +test traits::tests::event_consumer_as_source ... ok +test traits::tests::event_producer_as_sink ... ok +test traits::tests::forward_empty_source_transfers_nothing ... ok +test traits::tests::forward_event_to_ringbuf ... ok +test traits::tests::forward_seq_to_event ... ok +test traits::tests::forward_stops_when_sink_full ... ok +test traits::tests::generic_drain_event ... ok +test traits::tests::generic_drain_seq ... ok +test traits::tests::ringbuf_as_sink ... ok +test traits::tests::seq_consumer_as_source ... ok +test traits::tests::seq_producer_as_sink ... ok + +test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 5.97s + + Doc-tests ph_eventing + +running 24 tests +test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok +test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok +test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok +test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok +test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok +test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok +test src/block.rs - block (line 79) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok +test src/ring.rs - ring (line 16) ... ok +test src/lib.rs - (line 75) ... ok +test src/block.rs - block (line 61) ... ok +test src/lib.rs - (line 131) ... ok +test src/lib.rs - (line 60) ... ok +test src/lib.rs - (line 88) ... ok +test src/event_buf.rs - event_buf (line 31) ... ok +test src/lib.rs - (line 49) ... ok +test src/macros.rs - macros::static_spsc (line 27) ... ok +test src/traits.rs - traits::forward (line 84) ... ok +test src/latest_buf.rs - latest_buf (line 59) ... ok +test src/lib.rs - (line 36) ... ok +test src/macros.rs - macros::static_spsc (line 48) ... ok + +test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s + +all doctests ran in 0.23s; merged doctests compilation took 0.01s + +==> s390x-unknown-linux-gnu +Preparing a sysroot for Miri (target: s390x-unknown-linux-gnu)... done + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.23s + Running unittests src/lib.rs (target/miri/s390x-unknown-linux-gnu/debug/build/ph-eventing/dc7f9b689ed67400/out/ph_eventing-dc7f9b689ed67400) + +running 100 tests +test block::tests::clear_discards_a_partial_block ... ok +test block::tests::completes_only_after_n_contiguous_samples ... ok +test block::tests::completion_resets_for_the_next_block ... ok +test block::tests::default_and_capacity_match_new ... ok +test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok +test block::tests::rejects_gap_without_hiding_loss_policy ... ok +test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok +test block::tests::sequence_wrap_skips_zero ... ok +test block::tests::works_without_default_bound ... ok +test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok +test counted_signal::tests::const_new_works_in_static_context ... ok +test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test counted_signal::tests::handles_are_send ... ok +test counted_signal::tests::increments_accumulate_and_take_clears ... ok +test counted_signal::tests::saturates_instead_of_wrapping ... ok +test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok +test event_buf::tests::const_new_works_in_const_context ... ok +test event_buf::tests::default_is_new ... ok +test event_buf::tests::drain_on_empty_returns_zero ... ok +test event_buf::tests::drain_returns_count ... ok +test event_buf::tests::handles_are_send ... ok +test event_buf::tests::len_and_full_track_state ... ok +test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok +test event_buf::tests::new_buf_is_empty ... ok +test event_buf::tests::peek_copies_without_advancing ... ok +test event_buf::tests::producer_consumer_can_be_recreated ... ok +test event_buf::tests::push_and_pop_fifo ... ok +test event_buf::tests::push_rejects_when_full ... ok +test event_buf::tests::static_buf_yields_static_sendable_handles ... ok +test event_buf::tests::try_producer_and_try_consumer ... ok +test event_buf::tests::wraps_around_correctly ... ok +test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok +test event_flags::tests::const_new_works_in_static_context ... ok +test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok +test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok +test event_flags::tests::event_flags_object_is_eight_bytes ... ok +test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok +test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok +test event_flags::tests::handles_are_send_and_container_is_sync ... ok +test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok +test event_flags::tests::observed_raise_publishes_preceding_memory ... ok +test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok +test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok +test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok +test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok +test latest_buf::tests::handle_reacquisition_continues_role_state ... ok +test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok +test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok +test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok +test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok +test macros::tests::event_buf_module_round_trips ... ok +test macros::tests::event_buf_take_is_once_only ... ok +test macros::tests::failed_take_strands_nothing ... ok +test macros::tests::handles_are_send ... ok +test macros::tests::seq_ring_module_round_trips ... ok +test ring::tests::capacity_returns_n ... ok +test ring::tests::clear_resets_state ... ok +test ring::tests::const_new_works_in_const_context ... ok +test ring::tests::default_is_new ... ok +test ring::tests::huge_capacity_does_not_overflow_the_index ... ok +test ring::tests::into_iter_for_ref ... ok +test ring::tests::iter_exact_size ... ok +test ring::tests::iter_oldest_to_newest ... ok +test ring::tests::new_ring_is_empty ... ok +test ring::tests::overwrite_oldest_when_full ... ok +test ring::tests::push_and_get ... ok +test ring::tests::works_without_default_bound ... ok +test seq_ring::tests::capacity_returns_n ... ok +test seq_ring::tests::const_new_works_in_const_context ... ok +test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok +test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok +test seq_ring::tests::dropped_counter_can_reset ... ok +test seq_ring::tests::drops_when_consumer_lags ... ok +test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok +test seq_ring::tests::latest_empty_returns_false ... ok +test seq_ring::tests::latest_reads_newest ... ok +test seq_ring::tests::latest_returns_false_when_slot_missing ... ok +test seq_ring::tests::poll_one_empty_returns_false ... ok +test seq_ring::tests::poll_one_value_and_latest_value ... ok +test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok +test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok +test seq_ring::tests::polls_in_order ... ok +test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok +test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok +test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok +test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok +test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok +test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok +test seq_ring::tests::try_producer_and_try_consumer ... ok +test traits::tests::event_consumer_as_source ... ok +test traits::tests::event_producer_as_sink ... ok +test traits::tests::forward_empty_source_transfers_nothing ... ok +test traits::tests::forward_event_to_ringbuf ... ok +test traits::tests::forward_seq_to_event ... ok +test traits::tests::forward_stops_when_sink_full ... ok +test traits::tests::generic_drain_event ... ok +test traits::tests::generic_drain_seq ... ok +test traits::tests::ringbuf_as_sink ... ok +test traits::tests::seq_consumer_as_source ... ok +test traits::tests::seq_producer_as_sink ... ok + +test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 5.88s + + Doc-tests ph_eventing + +running 24 tests +test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok +test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok +test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok +test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok +test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok +test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok +test src/block.rs - block (line 79) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok +test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok +test src/lib.rs - (line 49) ... ok +test src/ring.rs - ring (line 16) ... ok +test src/lib.rs - (line 131) ... ok +test src/lib.rs - (line 60) ... ok +test src/block.rs - block (line 61) ... ok +test src/traits.rs - traits::forward (line 84) ... ok +test src/event_buf.rs - event_buf (line 31) ... ok +test src/lib.rs - (line 88) ... ok +test src/lib.rs - (line 75) ... ok +test src/macros.rs - macros::static_spsc (line 48) ... ok +test src/macros.rs - macros::static_spsc (line 27) ... ok +test src/latest_buf.rs - latest_buf (line 59) ... ok +test src/lib.rs - (line 36) ... ok + +test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s + +all doctests ran in 0.24s; merged doctests compilation took 0.01s + +Summary + PASS host: full checking + PASS host: seqlock logic (race detector off) + PASS host: 16 scheduler seeds + PASS i686-unknown-linux-gnu + PASS armv7-unknown-linux-gnueabihf + PASS s390x-unknown-linux-gnu + +All Miri passes clean. + +########## loom ########## +==> loom (max_preemptions=2) + Compiling generator v0.8.9 + Compiling loom v0.7.2 + Compiling ph-eventing v0.3.0 (/work) +warning: variable does not need to be mutable + --> src/loom_tests.rs:215:17 + | +215 | let mut take_spinning = |expected: u32| loop { + | ----^^^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: `ph-eventing` (lib test) generated 1 warning (run `cargo fix --lib -p ph-eventing --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.72s + Running unittests src/lib.rs (target/debug/deps/ph_eventing-8ebf478bfdbabaa8) + +running 16 tests +test loom_tests::event_flags_raise_racing_take_is_partitioned_exactly ... ok +test loom_tests::latest_buf_empty_fast_path_preserves_concurrent_publication ... ok +test loom_tests::event_flags_distinct_raises_partition_across_takes ... ok +test loom_tests::event_flags_observed_raise_publishes_payload ... ok +test loom_tests::counted_signal_saturation_boundary_is_linearizable ... ok +test loom_tests::event_buf_pop_never_sees_unpublished_data ... ok +test loom_tests::latest_buf_producer_reacquisition_continues_across_threads ... ok +test loom_tests::seq_ring_latest_is_never_ahead_of_published ... ok +test loom_tests::event_buf_len_never_exceeds_capacity ... ok +test loom_tests::counted_signal_take_partitions_increments ... ok +test loom_tests::counted_signal_post_take_increment_observes_reset_epoch ... ok +test loom_tests::latest_buf_returns_only_complete_publications ... ok +test loom_tests::latest_buf_reused_slot_keeps_exclusive_ownership ... ok +test loom_tests::seq_ring_consumer_never_outruns_the_producer ... ok +test loom_tests::event_buf_spsc_is_lossless_and_ordered ... ok +test loom_tests::latest_buf_consumer_reacquisition_continues_across_threads ... ok + +test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 86 filtered out; finished in 0.39s + + +All 16 Loom models that matched were verified. + +########## cycles ########## +==> QEMU emulator version 10.0.11 (Debian 1:10.0.11+ds-0+deb13u1) +==> building probe +==> tracing under qemu + + +EventBuf (backpressure SPSC) + eb push empty 25 + eb push nearly full 25 + eb push full rejected 19 + eb peek 13 + eb len 14 + eb pop full 20 + eb pop empty 13 + +SeqRing (overwrite SPSC) + sr push empty 34 + sr poll value 92 + sr poll empty 24 + sr push overwriting 33 + sr latest 30 + sr poll lagged 115 + sr poll lagged far 115 + +RingBuf (single owner) + rb push empty 20 + rb push overwriting 20 + rb get 22 + rb latest 16 + +CountedSignal (payload-free SPSC) + cs increment 8 + cs take count 9 + cs increment saturated 9 + +EventFlags (coalesced SPSC conditions) + ef raise clear 12 + ef raise already set 12 + ef take nonempty 10 + ef take empty 10 + +Instructions retired on the guest, marker overhead subtracted. +Deterministic per environment: -icount shift=0 pins one instruction to +one tick, so the same ELF under the same QEMU build yields the same +counts on any host. Different QEMU builds can shift region boundaries +by an instruction -- compare inside the reference image (verify.sh). + +########## atomic-window ########## +==> rustc 1.92.0 +==> building thumbv6m probe (ESP rows opt-in: ESP=1) + Finished `release` profile [optimized] target(s) in 0.27s + +TARGET raise take implementation +------------------ ----- ---- -------------- +thumbv6m 4 4 PRIMASK critical section +esp32-s2 - - opt-in: ESP=1 (needs esp-rs) +esp32-s3 - - opt-in: ESP=1 (needs esp-rs) + +Counts are instructions after interrupt disable through restore/sync. +thumbv6m is always gated; ESP rows require ESP=1 and the esp-rs toolchain. +Portable paths are straight-line and contain exactly one read, +one update/store sequence, and no branch or compare-exchange loop. + +Full matrix passed in the reference environment. +Note: EventFlags ESP32-S2/S3 interrupt windows are opt-in +(ESP=1 ./scripts/event-flags-atomic-window.sh); they are not +part of this Docker matrix (no esp-rs in the image). From 354922d3b2f5df9cb7712b4e908a5866e65b4f02 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 03:09:31 -0400 Subject: [PATCH 73/87] Untrack the maintainer's local verification log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-rerun-49cf88a.log was explicitly kept local ('not committed' per the re-verification comment) and was swept into the previous commit by an indiscriminate git add -A — the exact staging mistake the release discipline's rule 8 exists to prevent. Removed from tracking; the file remains on the maintainer's worktree. Co-Authored-By: Claude Fable 5 --- verify-rerun-49cf88a.log | 1362 -------------------------------------- 1 file changed, 1362 deletions(-) delete mode 100644 verify-rerun-49cf88a.log diff --git a/verify-rerun-49cf88a.log b/verify-rerun-49cf88a.log deleted file mode 100644 index 56ea5aa..0000000 --- a/verify-rerun-49cf88a.log +++ /dev/null @@ -1,1362 +0,0 @@ -==> building ph-eventing-verify (cached after the first time) -#0 building with "desktop-linux" instance using docker driver - -#1 [internal] load build definition from Dockerfile -#1 transferring dockerfile: 7.08kB 0.0s done -#1 DONE 0.0s - -#2 [internal] load metadata for docker.io/library/rust:1.92.0-slim-trixie -#2 ... - -#3 [auth] library/rust:pull token for registry-1.docker.io -#3 DONE 0.0s - -#2 [internal] load metadata for docker.io/library/rust:1.92.0-slim-trixie -#2 DONE 0.6s - -#4 [internal] load .dockerignore -#4 transferring context: 432B done -#4 DONE 0.0s - -#5 [ 1/13] FROM docker.io/library/rust:1.92.0-slim-trixie@sha256:bf3368a992915f128293ac76917ab6e561e4dda883273c8f5c9f6f8ea37a378e -#5 resolve docker.io/library/rust:1.92.0-slim-trixie@sha256:bf3368a992915f128293ac76917ab6e561e4dda883273c8f5c9f6f8ea37a378e 0.0s done -#5 DONE 0.0s - -#6 [internal] load build context -#6 transferring context: 382B done -#6 DONE 0.0s - -#7 [10/13] COPY scripts/codesize/Cargo.toml scripts/codesize/Cargo.lock /tmp/fetch/scripts/codesize/ -#7 CACHED - -#8 [ 6/13] RUN rustup toolchain install "nightly-2026-08-08" --profile minimal --component miri,rust-src && rustup target add --toolchain "nightly-2026-08-08" i686-unknown-linux-gnu armv7-unknown-linux-gnueabihf s390x-unknown-linux-gnu -#8 CACHED - -#9 [12/13] RUN mkdir -p /tmp/fetch/src /tmp/fetch/scripts/codesize/src /tmp/fetch/scripts/cycles/src && touch /tmp/fetch/src/lib.rs /tmp/fetch/scripts/codesize/src/lib.rs && printf 'fn main() {}\n' > /tmp/fetch/scripts/cycles/src/main.rs && cd /tmp/fetch && cargo fetch --locked && cd /tmp/fetch/scripts/codesize && cargo fetch --locked && cd /tmp/fetch/scripts/cycles && cargo fetch --locked && rm -rf /tmp/fetch -#9 CACHED - -#10 [ 9/13] COPY Cargo.toml Cargo.lock /tmp/fetch/ -#10 CACHED - -#11 [ 2/13] RUN apt-get update && apt-get install -y --no-install-recommends qemu-system-arm git ca-certificates && rm -rf /var/lib/apt/lists/* -#11 CACHED - -#12 [ 5/13] RUN cd /tmp/pin && rustup toolchain install && rm -rf /tmp/pin -#12 CACHED - -#13 [11/13] COPY scripts/cycles/Cargo.toml scripts/cycles/Cargo.lock /tmp/fetch/scripts/cycles/ -#13 CACHED - -#14 [ 3/13] RUN qemu-system-arm --version | head -1 | grep -qF "version 10.0." || { echo "QEMU series drifted from 10.0 -- this rebuild would not" ; echo "reproduce the release evidence. Use the published image tag, or" ; echo "re-measure and re-bless the documented counts with the new QEMU" ; echo "(then update QEMU_SERIES)." ; qemu-system-arm --version | head -1 ; exit 1 ; } -#14 CACHED - -#15 [ 4/13] COPY rust-toolchain.toml /tmp/pin/rust-toolchain.toml -#15 CACHED - -#16 [ 7/13] RUN rustup toolchain install stable --profile minimal --component clippy -#16 CACHED - -#17 [ 8/13] RUN cargo install --locked cargo-deny@0.20.2 cargo-llvm-cov@0.8.7 -#17 CACHED - -#18 [13/13] WORKDIR /work -#18 CACHED - -#19 exporting to image -#19 exporting layers done -#19 exporting manifest sha256:3a303e22980005cdb07e49d504f213ec1f0b0198afa6f436f98f8e5c5589a122 done -#19 exporting config sha256:d364bcf3741d81aa47a1ac1a84a020a5d65c9df9f144683a20ba2cdcbb61e1a6 done -#19 exporting attestation manifest sha256:c352cec73e080e3143c304f3de012f4eeabc41c4fa45b488d50aeaa8c9b8e4cc -#19 exporting attestation manifest sha256:c352cec73e080e3143c304f3de012f4eeabc41c4fa45b488d50aeaa8c9b8e4cc 0.0s done -#19 exporting manifest list sha256:f0615b6646e101e86205324ebb5f820a38af915b7cff304afe8135bc04d19620 0.0s done -#19 naming to docker.io/library/ph-eventing-verify:latest done -#19 unpacking to docker.io/library/ph-eventing-verify:latest 0.0s done -#19 DONE 0.1s -rustc 1.92.0 (ded5c06cf 2025-12-08) -rustc 1.99.0-nightly (1a98b1e13 2026-08-07) -QEMU emulator version 10.0.11 (Debian 1:10.0.11+ds-0+deb13u1) - -########## ci ########## - -==> fmt - -==> clippy - Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s - -==> test - Finished `test` profile [unoptimized + debuginfo] target(s) in 0.17s - Running unittests src/lib.rs (target/debug/deps/ph_eventing-0738c850a5905c40) - -running 101 tests -test block::tests::clear_discards_a_partial_block ... ok -test block::tests::default_and_capacity_match_new ... ok -test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok -test block::tests::rejects_gap_without_hiding_loss_policy ... ok -test block::tests::completion_resets_for_the_next_block ... ok -test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok -test block::tests::sequence_wrap_skips_zero ... ok -test block::tests::completes_only_after_n_contiguous_samples ... ok -test block::tests::works_without_default_bound ... ok -test counted_signal::tests::handles_are_send ... ok -test counted_signal::tests::const_new_works_in_static_context ... ok -test counted_signal::tests::increments_accumulate_and_take_clears ... ok -test event_buf::tests::default_is_new ... ok -test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test counted_signal::tests::saturates_instead_of_wrapping ... ok -test event_buf::tests::const_new_works_in_const_context ... ok -test event_buf::tests::drain_on_empty_returns_zero ... ok -test event_buf::tests::drain_returns_count ... ok -test event_buf::tests::handles_are_send ... ok -test event_buf::tests::len_and_full_track_state ... ok -test event_buf::tests::peek_copies_without_advancing ... ok -test event_buf::tests::new_buf_is_empty ... ok -test event_buf::tests::producer_consumer_can_be_recreated ... ok -test event_buf::tests::push_and_pop_fifo ... ok -test event_buf::tests::push_rejects_when_full ... ok -test event_buf::tests::static_buf_yields_static_sendable_handles ... ok -test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok -test event_buf::tests::try_producer_and_try_consumer ... ok -test event_buf::tests::wraps_around_correctly ... ok -test event_flags::tests::const_new_works_in_static_context ... ok -test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok -test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok -test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok -test event_flags::tests::event_flags_object_is_eight_bytes ... ok -test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok -test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test event_flags::tests::handles_are_send_and_container_is_sync ... ok -test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok -test event_flags::tests::observed_raise_publishes_preceding_memory ... ok -test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok -test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok -test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok -test latest_buf::tests::handle_reacquisition_continues_role_state ... ok -test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok -test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok -test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok -test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok -test macros::tests::event_buf_module_round_trips ... ok -test macros::tests::event_buf_take_is_once_only ... ok -test macros::tests::failed_take_strands_nothing ... ok -test macros::tests::handles_are_send ... ok -test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok -test ring::tests::clear_resets_state ... ok -test ring::tests::huge_capacity_does_not_overflow_the_index ... ok -test ring::tests::default_is_new ... ok -test ring::tests::const_new_works_in_const_context ... ok -test ring::tests::capacity_returns_n ... ok -test ring::tests::iter_oldest_to_newest ... ok -test ring::tests::new_ring_is_empty ... ok -test ring::tests::into_iter_for_ref ... ok -test ring::tests::iter_exact_size ... ok -test ring::tests::push_and_get ... ok -test macros::tests::seq_ring_module_round_trips ... ok -test seq_ring::tests::capacity_returns_n ... ok -test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok -test seq_ring::tests::const_new_works_in_const_context ... ok -test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok -test ring::tests::works_without_default_bound ... ok -test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok -test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok -test ring::tests::overwrite_oldest_when_full ... ok -test seq_ring::tests::drops_when_consumer_lags ... ok -test seq_ring::tests::latest_empty_returns_false ... ok -test seq_ring::tests::latest_reads_newest ... ok -test seq_ring::tests::dropped_counter_can_reset ... ok -test seq_ring::tests::latest_returns_false_when_slot_missing ... ok -test seq_ring::tests::poll_one_empty_returns_false ... ok -test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok -test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok -test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok -test seq_ring::tests::polls_in_order ... ok -test seq_ring::tests::poll_one_value_and_latest_value ... ok -test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok -test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok -test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok -test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok -test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok -test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok -test traits::tests::event_consumer_as_source ... ok -test seq_ring::tests::try_producer_and_try_consumer ... ok -test traits::tests::event_producer_as_sink ... ok -test traits::tests::forward_event_to_ringbuf ... ok -test traits::tests::forward_empty_source_transfers_nothing ... ok -test traits::tests::forward_seq_to_event ... ok -test traits::tests::forward_stops_when_sink_full ... ok -test traits::tests::generic_drain_event ... ok -test traits::tests::generic_drain_seq ... ok -test traits::tests::ringbuf_as_sink ... ok -test traits::tests::seq_consumer_as_source ... ok -test seq_ring::tests::concurrent_overwrite_never_yields_a_mismatched_value ... ok -test traits::tests::seq_producer_as_sink ... ok - -test result: ok. 101 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s - - Doc-tests ph_eventing - -running 13 tests -test src/event_buf.rs - event_buf (line 31) ... ok -test src/latest_buf.rs - latest_buf (line 59) ... ok -test src/block.rs - block (line 61) ... ok -test src/lib.rs - (line 131) ... ok -test src/macros.rs - macros::static_spsc (line 27) ... ok -test src/lib.rs - (line 36) ... ok -test src/lib.rs - (line 88) ... ok -test src/lib.rs - (line 60) ... ok -test src/ring.rs - ring (line 16) ... ok -test src/lib.rs - (line 49) ... ok -test src/macros.rs - macros::static_spsc (line 48) ... ok -test src/traits.rs - traits::forward (line 84) ... ok -test src/lib.rs - (line 75) ... ok - -test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s - - -running 11 tests -test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok -test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok -test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok -test src/block.rs - block (line 79) - compile fail ... ok -test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok -test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok -test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok - -test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s - -all doctests ran in 0.75s; merged doctests compilation took 0.67s - -==> doc - Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s - Generated /work/target/doc/ph_eventing/index.html - -==> features: default - -==> features: portable-atomic - -==> features: critical-section - -==> stable: test - -running 101 tests -....................................................................................... 87/101 -.............. -test result: ok. 101 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s - - -running 13 tests -............. -test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s - - -running 11 tests -........... -test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s - -all doctests ran in 0.75s; merged doctests compilation took 0.67s - -==> stable: clippy - -==> deny -advisories ok, bans ok, licenses ok, sources ok - -==> coverage (>=90% lines) -info: cargo-llvm-cov currently setting cfg(coverage); you can opt-out it by passing --no-cfg-coverage - Compiling ph-eventing v0.3.0 (/work) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.40s - Running unittests src/lib.rs (target/llvm-cov-target/debug/deps/ph_eventing-0738c850a5905c40) - -running 101 tests -test block::tests::clear_discards_a_partial_block ... ok -test block::tests::default_and_capacity_match_new ... ok -test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok -test block::tests::rejects_gap_without_hiding_loss_policy ... ok -test block::tests::completion_resets_for_the_next_block ... ok -test block::tests::completes_only_after_n_contiguous_samples ... ok -test block::tests::sequence_wrap_skips_zero ... ok -test counted_signal::tests::const_new_works_in_static_context ... ok -test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok -test block::tests::works_without_default_bound ... ok -test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test counted_signal::tests::handles_are_send ... ok -test counted_signal::tests::increments_accumulate_and_take_clears ... ok -test counted_signal::tests::saturates_instead_of_wrapping ... ok -test event_buf::tests::const_new_works_in_const_context ... ok -test event_buf::tests::default_is_new ... ok -test event_buf::tests::drain_on_empty_returns_zero ... ok -test event_buf::tests::drain_returns_count ... ok -test event_buf::tests::handles_are_send ... ok -test event_buf::tests::len_and_full_track_state ... ok -test event_buf::tests::new_buf_is_empty ... ok -test event_buf::tests::peek_copies_without_advancing ... ok -test event_buf::tests::producer_consumer_can_be_recreated ... ok -test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok -test event_buf::tests::push_and_pop_fifo ... ok -test event_buf::tests::push_rejects_when_full ... ok -test event_buf::tests::static_buf_yields_static_sendable_handles ... ok -test event_buf::tests::try_producer_and_try_consumer ... ok -test event_buf::tests::wraps_around_correctly ... ok -test event_flags::tests::const_new_works_in_static_context ... ok -test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok -test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok -test event_flags::tests::event_flags_object_is_eight_bytes ... ok -test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok -test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test event_flags::tests::handles_are_send_and_container_is_sync ... ok -test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok -test event_flags::tests::observed_raise_publishes_preceding_memory ... ok -test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok -test latest_buf::tests::handle_reacquisition_continues_role_state ... ok -test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok -test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok -test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok -test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok -test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok -test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok -test macros::tests::event_buf_module_round_trips ... ok -test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok -test macros::tests::failed_take_strands_nothing ... ok -test macros::tests::handles_are_send ... ok -test macros::tests::event_buf_take_is_once_only ... ok -test macros::tests::seq_ring_module_round_trips ... ok -test ring::tests::capacity_returns_n ... ok -test ring::tests::const_new_works_in_const_context ... ok -test ring::tests::clear_resets_state ... ok -test ring::tests::default_is_new ... ok -test ring::tests::huge_capacity_does_not_overflow_the_index ... ok -test ring::tests::into_iter_for_ref ... ok -test ring::tests::iter_exact_size ... ok -test ring::tests::iter_oldest_to_newest ... ok -test ring::tests::new_ring_is_empty ... ok -test ring::tests::overwrite_oldest_when_full ... ok -test ring::tests::push_and_get ... ok -test ring::tests::works_without_default_bound ... ok -test seq_ring::tests::capacity_returns_n ... ok -test seq_ring::tests::const_new_works_in_const_context ... ok -test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok -test seq_ring::tests::dropped_counter_can_reset ... ok -test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok -test seq_ring::tests::drops_when_consumer_lags ... ok -test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok -test seq_ring::tests::latest_empty_returns_false ... ok -test seq_ring::tests::latest_reads_newest ... ok -test seq_ring::tests::latest_returns_false_when_slot_missing ... ok -test seq_ring::tests::poll_one_empty_returns_false ... ok -test seq_ring::tests::poll_one_value_and_latest_value ... ok -test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok -test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok -test seq_ring::tests::polls_in_order ... ok -test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok -test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok -test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok -test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok -test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok -test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok -test seq_ring::tests::try_producer_and_try_consumer ... ok -test traits::tests::event_consumer_as_source ... ok -test traits::tests::event_producer_as_sink ... ok -test traits::tests::forward_empty_source_transfers_nothing ... ok -test seq_ring::tests::concurrent_overwrite_never_yields_a_mismatched_value ... ok -test traits::tests::forward_event_to_ringbuf ... ok -test traits::tests::forward_seq_to_event ... ok -test traits::tests::forward_stops_when_sink_full ... ok -test traits::tests::generic_drain_event ... ok -test traits::tests::generic_drain_seq ... ok -test traits::tests::ringbuf_as_sink ... ok -test traits::tests::seq_consumer_as_source ... ok -test traits::tests::seq_producer_as_sink ... ok -test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok -test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok -test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok - -test result: ok. 101 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s - -Filename Regions Missed Regions Cover Functions Missed Functions Executed Lines Missed Lines Cover Branches Missed Branches Cover ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ -block.rs 261 14 94.64% 23 1 95.65% 148 9 93.92% 0 0 - -counted_signal.rs 250 18 92.80% 24 4 83.33% 136 12 91.18% 0 0 - -event_buf.rs 667 30 95.50% 53 4 92.45% 342 19 94.44% 0 0 - -event_flags.rs 340 23 93.24% 38 5 86.84% 197 15 92.39% 0 0 - -latest_buf.rs 405 28 93.09% 46 6 86.96% 260 19 92.69% 0 0 - -lib.rs 5 1 80.00% 1 0 100.00% 3 0 100.00% 0 0 - -macros.rs 106 10 90.57% 10 0 100.00% 57 4 92.98% 0 0 - -ring.rs 330 19 94.24% 30 2 93.33% 180 11 93.89% 0 0 - -seq_ring.rs 940 48 94.89% 81 9 88.89% 522 36 93.10% 0 0 - -sync.rs 11 0 100.00% 3 0 100.00% 9 0 100.00% 0 0 - -traits.rs 299 2 99.33% 13 0 100.00% 132 1 99.24% 0 0 - ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ -TOTAL 3614 193 94.66% 322 31 90.37% 1986 126 93.66% 0 0 - - -==> codesize (baseline gate) -TARGET two_calls cs_incr cs_take flags_acq flags_raise flags_take split bss seq_bss data ------------------------------- --------- ------- ------- --------- ----------- ---------- ----- --- ------- ---- -thumbv6m-none-eabi 156 46 24 68 24 24 - 268 524 0 -thumbv8m.base-none-eabi 156 44 22 64 22 22 - 268 524 0 -thumbv7m-none-eabi 172 44 22 72 26 26 - 268 524 0 -thumbv7em-none-eabi 172 44 22 72 26 26 - 268 524 0 -thumbv8m.main-none-eabi 152 44 22 64 22 22 - 268 524 0 -armv7r-none-eabi 220 64 28 116 32 32 - 268 524 0 -armv7a-none-eabi 220 64 28 116 32 32 - 268 524 0 -riscv32imac-unknown-none-elf 152 26 8 66 8 8 - 268 524 0 - - -Baseline gate (tolerance +16 bytes, growth only) - ok -- no row grew by more than 16 bytes -split column is "-" unless run as: ./scripts/codesize.sh split -Xtensa rows need: XTENSA=1 and the esp-rs toolchain. - -==> codesize (block matrix gate) -TARGET SHAPE code_B block_B accepted_B rejected_B ------------------------------- ---------- ------ ------- ---------- ---------- -thumbv6m-none-eabi w2_n8 138 24 48 48 -thumbv6m-none-eabi w2_n32 168 72 144 144 -thumbv6m-none-eabi w2_n128 200 264 528 528 -thumbv6m-none-eabi w8_n8 192 72 144 144 -thumbv6m-none-eabi w8_n32 224 264 528 528 -thumbv6m-none-eabi w8_n128 240 1032 2064 2064 -thumbv6m-none-eabi w16_n8 242 136 272 272 -thumbv6m-none-eabi w16_n32 254 520 1040 1040 -thumbv6m-none-eabi w16_n128 268 2056 4112 4112 -thumbv8m.base-none-eabi w2_n8 136 24 48 48 -thumbv8m.base-none-eabi w2_n32 168 72 144 144 -thumbv8m.base-none-eabi w2_n128 190 264 528 528 -thumbv8m.base-none-eabi w8_n8 192 72 144 144 -thumbv8m.base-none-eabi w8_n32 228 264 528 528 -thumbv8m.base-none-eabi w8_n128 244 1032 2064 2064 -thumbv8m.base-none-eabi w16_n8 242 136 272 272 -thumbv8m.base-none-eabi w16_n32 250 520 1040 1040 -thumbv8m.base-none-eabi w16_n128 264 2056 4112 4112 -thumbv7m-none-eabi w2_n8 136 24 48 48 -thumbv7m-none-eabi w2_n32 132 72 144 144 -thumbv7m-none-eabi w2_n128 130 264 528 528 -thumbv7m-none-eabi w8_n8 120 72 144 144 -thumbv7m-none-eabi w8_n32 118 264 528 528 -thumbv7m-none-eabi w8_n128 178 1032 2064 2064 -thumbv7m-none-eabi w16_n8 200 136 272 272 -thumbv7m-none-eabi w16_n32 208 520 1040 1040 -thumbv7m-none-eabi w16_n128 216 2056 4112 4112 -thumbv7em-none-eabi w2_n8 136 24 48 48 -thumbv7em-none-eabi w2_n32 132 72 144 144 -thumbv7em-none-eabi w2_n128 130 264 528 528 -thumbv7em-none-eabi w8_n8 120 72 144 144 -thumbv7em-none-eabi w8_n32 118 264 528 528 -thumbv7em-none-eabi w8_n128 178 1032 2064 2064 -thumbv7em-none-eabi w16_n8 200 136 272 272 -thumbv7em-none-eabi w16_n32 208 520 1040 1040 -thumbv7em-none-eabi w16_n128 216 2056 4112 4112 -thumbv8m.main-none-eabi w2_n8 138 24 48 48 -thumbv8m.main-none-eabi w2_n32 126 72 144 144 -thumbv8m.main-none-eabi w2_n128 122 264 528 528 -thumbv8m.main-none-eabi w8_n8 114 72 144 144 -thumbv8m.main-none-eabi w8_n32 110 264 528 528 -thumbv8m.main-none-eabi w8_n128 182 1032 2064 2064 -thumbv8m.main-none-eabi w16_n8 204 136 272 272 -thumbv8m.main-none-eabi w16_n32 212 520 1040 1040 -thumbv8m.main-none-eabi w16_n128 216 2056 4112 4112 -armv7r-none-eabi w2_n8 212 24 48 48 -armv7r-none-eabi w2_n32 248 72 144 144 -armv7r-none-eabi w2_n128 252 264 528 528 -armv7r-none-eabi w8_n8 312 72 144 144 -armv7r-none-eabi w8_n32 312 264 528 528 -armv7r-none-eabi w8_n128 312 1032 2064 2064 -armv7r-none-eabi w16_n8 296 136 272 272 -armv7r-none-eabi w16_n32 288 520 1040 1040 -armv7r-none-eabi w16_n128 300 2056 4112 4112 -armv7a-none-eabi w2_n8 212 24 48 48 -armv7a-none-eabi w2_n32 248 72 144 144 -armv7a-none-eabi w2_n128 252 264 528 528 -armv7a-none-eabi w8_n8 312 72 144 144 -armv7a-none-eabi w8_n32 312 264 528 528 -armv7a-none-eabi w8_n128 312 1032 2064 2064 -armv7a-none-eabi w16_n8 296 136 272 272 -armv7a-none-eabi w16_n32 288 520 1040 1040 -armv7a-none-eabi w16_n128 300 2056 4112 4112 -riscv32imac-unknown-none-elf w2_n8 150 24 48 48 -riscv32imac-unknown-none-elf w2_n32 190 72 144 144 -riscv32imac-unknown-none-elf w2_n128 210 264 528 528 -riscv32imac-unknown-none-elf w8_n8 202 72 144 144 -riscv32imac-unknown-none-elf w8_n32 222 264 528 528 -riscv32imac-unknown-none-elf w8_n128 252 1032 2064 2064 -riscv32imac-unknown-none-elf w16_n8 250 136 272 272 -riscv32imac-unknown-none-elf w16_n32 248 520 1040 1040 -riscv32imac-unknown-none-elf w16_n128 300 2056 4112 4112 - -block_B is size_of::>(). accepted_B counts builder -completion plus publication; rejected_B counts completion plus -returning the complete rejected block to the caller. -These are logical payload-traffic bounds; code_B is emitted flash. -Run scripts/cycles.sh for accepted/rejected instruction paths. - - -Baseline gate (tolerance +16 bytes, growth only) - ok -- no row grew by more than 16 bytes -split column is "-" unless run as: ./scripts/codesize.sh split -Xtensa rows need: XTENSA=1 and the esp-rs toolchain. - -==> thumbv6m-none-eabi - Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s - -==> thumbv7em-none-eabi - Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s - -==> riscv32imac-unknown-none-elf - Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.14s - -Summary - PASS fmt - PASS clippy - PASS test - PASS doc - PASS features: default - PASS features: portable-atomic - PASS features: critical-section - PASS stable: test - PASS stable: clippy - PASS deny - PASS coverage (>=90% lines) - PASS codesize (baseline gate) - PASS codesize (block matrix gate) - PASS thumbv6m-none-eabi - PASS thumbv7em-none-eabi - PASS riscv32imac-unknown-none-elf - -All checks passed. - -########## miri ########## -==> miri toolchain: rustc 1.99.0-nightly (1a98b1e13 2026-08-07) - -==> host: full checking -Preparing a sysroot for Miri (target: x86_64-unknown-linux-gnu)... done - Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s - Running unittests src/lib.rs (target/miri/x86_64-unknown-linux-gnu/debug/build/ph-eventing/073c6757111f2f6e/out/ph_eventing-073c6757111f2f6e) - -running 100 tests -test block::tests::clear_discards_a_partial_block ... ok -test block::tests::completes_only_after_n_contiguous_samples ... ok -test block::tests::completion_resets_for_the_next_block ... ok -test block::tests::default_and_capacity_match_new ... ok -test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok -test block::tests::rejects_gap_without_hiding_loss_policy ... ok -test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok -test block::tests::sequence_wrap_skips_zero ... ok -test block::tests::works_without_default_bound ... ok -test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok -test counted_signal::tests::const_new_works_in_static_context ... ok -test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test counted_signal::tests::handles_are_send ... ok -test counted_signal::tests::increments_accumulate_and_take_clears ... ok -test counted_signal::tests::saturates_instead_of_wrapping ... ok -test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok -test event_buf::tests::const_new_works_in_const_context ... ok -test event_buf::tests::default_is_new ... ok -test event_buf::tests::drain_on_empty_returns_zero ... ok -test event_buf::tests::drain_returns_count ... ok -test event_buf::tests::handles_are_send ... ok -test event_buf::tests::len_and_full_track_state ... ok -test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok -test event_buf::tests::new_buf_is_empty ... ok -test event_buf::tests::peek_copies_without_advancing ... ok -test event_buf::tests::producer_consumer_can_be_recreated ... ok -test event_buf::tests::push_and_pop_fifo ... ok -test event_buf::tests::push_rejects_when_full ... ok -test event_buf::tests::static_buf_yields_static_sendable_handles ... ok -test event_buf::tests::try_producer_and_try_consumer ... ok -test event_buf::tests::wraps_around_correctly ... ok -test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok -test event_flags::tests::const_new_works_in_static_context ... ok -test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok -test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok -test event_flags::tests::event_flags_object_is_eight_bytes ... ok -test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok -test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test event_flags::tests::handles_are_send_and_container_is_sync ... ok -test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok -test event_flags::tests::observed_raise_publishes_preceding_memory ... ok -test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok -test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok -test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok -test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok -test latest_buf::tests::handle_reacquisition_continues_role_state ... ok -test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok -test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok -test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok -test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok -test macros::tests::event_buf_module_round_trips ... ok -test macros::tests::event_buf_take_is_once_only ... ok -test macros::tests::failed_take_strands_nothing ... ok -test macros::tests::handles_are_send ... ok -test macros::tests::seq_ring_module_round_trips ... ok -test ring::tests::capacity_returns_n ... ok -test ring::tests::clear_resets_state ... ok -test ring::tests::const_new_works_in_const_context ... ok -test ring::tests::default_is_new ... ok -test ring::tests::huge_capacity_does_not_overflow_the_index ... ok -test ring::tests::into_iter_for_ref ... ok -test ring::tests::iter_exact_size ... ok -test ring::tests::iter_oldest_to_newest ... ok -test ring::tests::new_ring_is_empty ... ok -test ring::tests::overwrite_oldest_when_full ... ok -test ring::tests::push_and_get ... ok -test ring::tests::works_without_default_bound ... ok -test seq_ring::tests::capacity_returns_n ... ok -test seq_ring::tests::const_new_works_in_const_context ... ok -test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok -test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok -test seq_ring::tests::dropped_counter_can_reset ... ok -test seq_ring::tests::drops_when_consumer_lags ... ok -test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok -test seq_ring::tests::latest_empty_returns_false ... ok -test seq_ring::tests::latest_reads_newest ... ok -test seq_ring::tests::latest_returns_false_when_slot_missing ... ok -test seq_ring::tests::poll_one_empty_returns_false ... ok -test seq_ring::tests::poll_one_value_and_latest_value ... ok -test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok -test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok -test seq_ring::tests::polls_in_order ... ok -test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok -test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok -test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok -test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok -test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok -test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok -test seq_ring::tests::try_producer_and_try_consumer ... ok -test traits::tests::event_consumer_as_source ... ok -test traits::tests::event_producer_as_sink ... ok -test traits::tests::forward_empty_source_transfers_nothing ... ok -test traits::tests::forward_event_to_ringbuf ... ok -test traits::tests::forward_seq_to_event ... ok -test traits::tests::forward_stops_when_sink_full ... ok -test traits::tests::generic_drain_event ... ok -test traits::tests::generic_drain_seq ... ok -test traits::tests::ringbuf_as_sink ... ok -test traits::tests::seq_consumer_as_source ... ok -test traits::tests::seq_producer_as_sink ... ok - -test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 6.15s - - Doc-tests ph_eventing - -running 24 tests -test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok -test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok -test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok -test src/block.rs - block (line 79) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok -test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok -test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok -test src/lib.rs - (line 49) ... ok -test src/ring.rs - ring (line 16) ... ok -test src/lib.rs - (line 131) ... ok -test src/lib.rs - (line 60) ... ok -test src/event_buf.rs - event_buf (line 31) ... ok -test src/traits.rs - traits::forward (line 84) ... ok -test src/lib.rs - (line 75) ... ok -test src/macros.rs - macros::static_spsc (line 27) ... ok -test src/block.rs - block (line 61) ... ok -test src/lib.rs - (line 36) ... ok -test src/macros.rs - macros::static_spsc (line 48) ... ok -test src/latest_buf.rs - latest_buf (line 59) ... ok -test src/lib.rs - (line 88) ... ok - -test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s - -all doctests ran in 0.22s; merged doctests compilation took 0.01s - -==> host: seqlock logic (race detector off) - Finished `test` profile [unoptimized + debuginfo] target(s) in 0.16s - Running unittests src/lib.rs (target/miri/x86_64-unknown-linux-gnu/debug/build/ph-eventing/073c6757111f2f6e/out/ph_eventing-073c6757111f2f6e) - -running 1 test -test seq_ring::tests::concurrent_overwrite_never_yields_a_mismatched_value ... ok - -test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 100 filtered out; finished in 0.51s - - -==> host: 16 scheduler seeds - Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s - Running unittests src/lib.rs (target/miri/x86_64-unknown-linux-gnu/debug/build/ph-eventing/073c6757111f2f6e/out/ph_eventing-073c6757111f2f6e) -Trying seed: 12 -Trying seed: 13 -Trying seed: 14 -Trying seed: 11 -Trying seed: 15 -Trying seed: 10 -Trying seed: 8 -Trying seed: 6 -Trying seed: 0 -Trying seed: 2 -Trying seed: 7 -Trying seed: 9 -Trying seed: 1 -Trying seed: 5 -Trying seed: 4 -Trying seed: 3 - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests - -running 2 tests -test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... okokokokokokok - - - -ok - - -ok -ok -okokok - - - -test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... okok - -ok -test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... okok - -okokok - - -okok -okok -ok - - - -ok - - -test result: test result: test result: -test result: -oktest result: ok -okok -okok -test result: -test result: -test result: okok -test result: -oktest result: test result: -ok -oktest result: okok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered outok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered outok -. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out -test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out -. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered outok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered outok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out - -; finished in 2.60s; finished in 2.60s; finished in 2.61s; finished in 2.61stest result: -test result: ; finished in 2.61s; finished in 2.60s; finished in 2.62s - - - - - - - -; finished in 2.62s - -ok - -ok - -; finished in 2.62s; finished in 2.62s; finished in 2.62s - -. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out - - - - - - -; finished in 2.63stest result: - -ok. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out; finished in 2.64s. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out - -. 2 passed; 0 failed; 0 ignored; 0 measured; 99 filtered out; finished in 2.67s; finished in 2.67s - - - -; finished in 2.68s - - Doc-tests ph_eventing - -running 0 tests - -test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 24 filtered out; finished in 0.00s - -all doctests ran in 0.02s; merged doctests compilation took 0.01s - -==> i686-unknown-linux-gnu -Preparing a sysroot for Miri (target: i686-unknown-linux-gnu)... done - Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s - Running unittests src/lib.rs (target/miri/i686-unknown-linux-gnu/debug/build/ph-eventing/d6bee4c6ee73bcea/out/ph_eventing-d6bee4c6ee73bcea) - -running 100 tests -test block::tests::clear_discards_a_partial_block ... ok -test block::tests::completes_only_after_n_contiguous_samples ... ok -test block::tests::completion_resets_for_the_next_block ... ok -test block::tests::default_and_capacity_match_new ... ok -test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok -test block::tests::rejects_gap_without_hiding_loss_policy ... ok -test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok -test block::tests::sequence_wrap_skips_zero ... ok -test block::tests::works_without_default_bound ... ok -test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok -test counted_signal::tests::const_new_works_in_static_context ... ok -test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test counted_signal::tests::handles_are_send ... ok -test counted_signal::tests::increments_accumulate_and_take_clears ... ok -test counted_signal::tests::saturates_instead_of_wrapping ... ok -test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok -test event_buf::tests::const_new_works_in_const_context ... ok -test event_buf::tests::default_is_new ... ok -test event_buf::tests::drain_on_empty_returns_zero ... ok -test event_buf::tests::drain_returns_count ... ok -test event_buf::tests::handles_are_send ... ok -test event_buf::tests::len_and_full_track_state ... ok -test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok -test event_buf::tests::new_buf_is_empty ... ok -test event_buf::tests::peek_copies_without_advancing ... ok -test event_buf::tests::producer_consumer_can_be_recreated ... ok -test event_buf::tests::push_and_pop_fifo ... ok -test event_buf::tests::push_rejects_when_full ... ok -test event_buf::tests::static_buf_yields_static_sendable_handles ... ok -test event_buf::tests::try_producer_and_try_consumer ... ok -test event_buf::tests::wraps_around_correctly ... ok -test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok -test event_flags::tests::const_new_works_in_static_context ... ok -test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok -test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok -test event_flags::tests::event_flags_object_is_eight_bytes ... ok -test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok -test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test event_flags::tests::handles_are_send_and_container_is_sync ... ok -test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok -test event_flags::tests::observed_raise_publishes_preceding_memory ... ok -test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok -test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok -test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok -test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok -test latest_buf::tests::handle_reacquisition_continues_role_state ... ok -test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok -test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok -test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok -test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok -test macros::tests::event_buf_module_round_trips ... ok -test macros::tests::event_buf_take_is_once_only ... ok -test macros::tests::failed_take_strands_nothing ... ok -test macros::tests::handles_are_send ... ok -test macros::tests::seq_ring_module_round_trips ... ok -test ring::tests::capacity_returns_n ... ok -test ring::tests::clear_resets_state ... ok -test ring::tests::const_new_works_in_const_context ... ok -test ring::tests::default_is_new ... ok -test ring::tests::huge_capacity_does_not_overflow_the_index ... ok -test ring::tests::into_iter_for_ref ... ok -test ring::tests::iter_exact_size ... ok -test ring::tests::iter_oldest_to_newest ... ok -test ring::tests::new_ring_is_empty ... ok -test ring::tests::overwrite_oldest_when_full ... ok -test ring::tests::push_and_get ... ok -test ring::tests::works_without_default_bound ... ok -test seq_ring::tests::capacity_returns_n ... ok -test seq_ring::tests::const_new_works_in_const_context ... ok -test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok -test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok -test seq_ring::tests::dropped_counter_can_reset ... ok -test seq_ring::tests::drops_when_consumer_lags ... ok -test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok -test seq_ring::tests::latest_empty_returns_false ... ok -test seq_ring::tests::latest_reads_newest ... ok -test seq_ring::tests::latest_returns_false_when_slot_missing ... ok -test seq_ring::tests::poll_one_empty_returns_false ... ok -test seq_ring::tests::poll_one_value_and_latest_value ... ok -test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok -test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok -test seq_ring::tests::polls_in_order ... ok -test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok -test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok -test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok -test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok -test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok -test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok -test seq_ring::tests::try_producer_and_try_consumer ... ok -test traits::tests::event_consumer_as_source ... ok -test traits::tests::event_producer_as_sink ... ok -test traits::tests::forward_empty_source_transfers_nothing ... ok -test traits::tests::forward_event_to_ringbuf ... ok -test traits::tests::forward_seq_to_event ... ok -test traits::tests::forward_stops_when_sink_full ... ok -test traits::tests::generic_drain_event ... ok -test traits::tests::generic_drain_seq ... ok -test traits::tests::ringbuf_as_sink ... ok -test traits::tests::seq_consumer_as_source ... ok -test traits::tests::seq_producer_as_sink ... ok - -test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 6.48s - - Doc-tests ph_eventing - -running 24 tests -test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok -test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok -test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok -test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok -test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok -test src/block.rs - block (line 79) - compile fail ... ok -test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok -test src/event_buf.rs - event_buf (line 31) ... ok -test src/block.rs - block (line 61) ... ok -test src/lib.rs - (line 131) ... ok -test src/lib.rs - (line 75) ... ok -test src/lib.rs - (line 49) ... ok -test src/traits.rs - traits::forward (line 84) ... ok -test src/lib.rs - (line 88) ... ok -test src/ring.rs - ring (line 16) ... ok -test src/latest_buf.rs - latest_buf (line 59) ... ok -test src/lib.rs - (line 60) ... ok -test src/lib.rs - (line 36) ... ok -test src/macros.rs - macros::static_spsc (line 48) ... ok -test src/macros.rs - macros::static_spsc (line 27) ... ok - -test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s - -all doctests ran in 0.24s; merged doctests compilation took 0.01s - -==> armv7-unknown-linux-gnueabihf -Preparing a sysroot for Miri (target: armv7-unknown-linux-gnueabihf)... done - Finished `test` profile [unoptimized + debuginfo] target(s) in 0.22s - Running unittests src/lib.rs (target/miri/armv7-unknown-linux-gnueabihf/debug/build/ph-eventing/d6573e025e5953bc/out/ph_eventing-d6573e025e5953bc) - -running 100 tests -test block::tests::clear_discards_a_partial_block ... ok -test block::tests::completes_only_after_n_contiguous_samples ... ok -test block::tests::completion_resets_for_the_next_block ... ok -test block::tests::default_and_capacity_match_new ... ok -test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok -test block::tests::rejects_gap_without_hiding_loss_policy ... ok -test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok -test block::tests::sequence_wrap_skips_zero ... ok -test block::tests::works_without_default_bound ... ok -test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok -test counted_signal::tests::const_new_works_in_static_context ... ok -test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test counted_signal::tests::handles_are_send ... ok -test counted_signal::tests::increments_accumulate_and_take_clears ... ok -test counted_signal::tests::saturates_instead_of_wrapping ... ok -test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok -test event_buf::tests::const_new_works_in_const_context ... ok -test event_buf::tests::default_is_new ... ok -test event_buf::tests::drain_on_empty_returns_zero ... ok -test event_buf::tests::drain_returns_count ... ok -test event_buf::tests::handles_are_send ... ok -test event_buf::tests::len_and_full_track_state ... ok -test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok -test event_buf::tests::new_buf_is_empty ... ok -test event_buf::tests::peek_copies_without_advancing ... ok -test event_buf::tests::producer_consumer_can_be_recreated ... ok -test event_buf::tests::push_and_pop_fifo ... ok -test event_buf::tests::push_rejects_when_full ... ok -test event_buf::tests::static_buf_yields_static_sendable_handles ... ok -test event_buf::tests::try_producer_and_try_consumer ... ok -test event_buf::tests::wraps_around_correctly ... ok -test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok -test event_flags::tests::const_new_works_in_static_context ... ok -test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok -test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok -test event_flags::tests::event_flags_object_is_eight_bytes ... ok -test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok -test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test event_flags::tests::handles_are_send_and_container_is_sync ... ok -test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok -test event_flags::tests::observed_raise_publishes_preceding_memory ... ok -test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok -test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok -test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok -test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok -test latest_buf::tests::handle_reacquisition_continues_role_state ... ok -test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok -test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok -test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok -test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok -test macros::tests::event_buf_module_round_trips ... ok -test macros::tests::event_buf_take_is_once_only ... ok -test macros::tests::failed_take_strands_nothing ... ok -test macros::tests::handles_are_send ... ok -test macros::tests::seq_ring_module_round_trips ... ok -test ring::tests::capacity_returns_n ... ok -test ring::tests::clear_resets_state ... ok -test ring::tests::const_new_works_in_const_context ... ok -test ring::tests::default_is_new ... ok -test ring::tests::huge_capacity_does_not_overflow_the_index ... ok -test ring::tests::into_iter_for_ref ... ok -test ring::tests::iter_exact_size ... ok -test ring::tests::iter_oldest_to_newest ... ok -test ring::tests::new_ring_is_empty ... ok -test ring::tests::overwrite_oldest_when_full ... ok -test ring::tests::push_and_get ... ok -test ring::tests::works_without_default_bound ... ok -test seq_ring::tests::capacity_returns_n ... ok -test seq_ring::tests::const_new_works_in_const_context ... ok -test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok -test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok -test seq_ring::tests::dropped_counter_can_reset ... ok -test seq_ring::tests::drops_when_consumer_lags ... ok -test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok -test seq_ring::tests::latest_empty_returns_false ... ok -test seq_ring::tests::latest_reads_newest ... ok -test seq_ring::tests::latest_returns_false_when_slot_missing ... ok -test seq_ring::tests::poll_one_empty_returns_false ... ok -test seq_ring::tests::poll_one_value_and_latest_value ... ok -test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok -test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok -test seq_ring::tests::polls_in_order ... ok -test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok -test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok -test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok -test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok -test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok -test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok -test seq_ring::tests::try_producer_and_try_consumer ... ok -test traits::tests::event_consumer_as_source ... ok -test traits::tests::event_producer_as_sink ... ok -test traits::tests::forward_empty_source_transfers_nothing ... ok -test traits::tests::forward_event_to_ringbuf ... ok -test traits::tests::forward_seq_to_event ... ok -test traits::tests::forward_stops_when_sink_full ... ok -test traits::tests::generic_drain_event ... ok -test traits::tests::generic_drain_seq ... ok -test traits::tests::ringbuf_as_sink ... ok -test traits::tests::seq_consumer_as_source ... ok -test traits::tests::seq_producer_as_sink ... ok - -test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 5.97s - - Doc-tests ph_eventing - -running 24 tests -test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok -test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok -test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok -test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok -test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok -test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok -test src/block.rs - block (line 79) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok -test src/ring.rs - ring (line 16) ... ok -test src/lib.rs - (line 75) ... ok -test src/block.rs - block (line 61) ... ok -test src/lib.rs - (line 131) ... ok -test src/lib.rs - (line 60) ... ok -test src/lib.rs - (line 88) ... ok -test src/event_buf.rs - event_buf (line 31) ... ok -test src/lib.rs - (line 49) ... ok -test src/macros.rs - macros::static_spsc (line 27) ... ok -test src/traits.rs - traits::forward (line 84) ... ok -test src/latest_buf.rs - latest_buf (line 59) ... ok -test src/lib.rs - (line 36) ... ok -test src/macros.rs - macros::static_spsc (line 48) ... ok - -test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s - -all doctests ran in 0.23s; merged doctests compilation took 0.01s - -==> s390x-unknown-linux-gnu -Preparing a sysroot for Miri (target: s390x-unknown-linux-gnu)... done - Finished `test` profile [unoptimized + debuginfo] target(s) in 0.23s - Running unittests src/lib.rs (target/miri/s390x-unknown-linux-gnu/debug/build/ph-eventing/dc7f9b689ed67400/out/ph_eventing-dc7f9b689ed67400) - -running 100 tests -test block::tests::clear_discards_a_partial_block ... ok -test block::tests::completes_only_after_n_contiguous_samples ... ok -test block::tests::completion_resets_for_the_next_block ... ok -test block::tests::default_and_capacity_match_new ... ok -test block::tests::event_buf_composition_queues_and_returns_a_rejected_block ... ok -test block::tests::rejects_gap_without_hiding_loss_policy ... ok -test block::tests::rejects_reserved_zero_without_changing_partial_block ... ok -test block::tests::sequence_wrap_skips_zero ... ok -test block::tests::works_without_default_bound ... ok -test counted_signal::tests::concurrent_takes_do_not_lose_increments ... ok -test counted_signal::tests::const_new_works_in_static_context ... ok -test counted_signal::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test counted_signal::tests::handles_are_send ... ok -test counted_signal::tests::increments_accumulate_and_take_clears ... ok -test counted_signal::tests::saturates_instead_of_wrapping ... ok -test event_buf::tests::concurrent_spsc_preserves_fifo_and_loses_nothing ... ok -test event_buf::tests::const_new_works_in_const_context ... ok -test event_buf::tests::default_is_new ... ok -test event_buf::tests::drain_on_empty_returns_zero ... ok -test event_buf::tests::drain_returns_count ... ok -test event_buf::tests::handles_are_send ... ok -test event_buf::tests::len_and_full_track_state ... ok -test event_buf::tests::len_stays_within_capacity_while_consumer_drains ... ok -test event_buf::tests::new_buf_is_empty ... ok -test event_buf::tests::peek_copies_without_advancing ... ok -test event_buf::tests::producer_consumer_can_be_recreated ... ok -test event_buf::tests::push_and_pop_fifo ... ok -test event_buf::tests::push_rejects_when_full ... ok -test event_buf::tests::static_buf_yields_static_sendable_handles ... ok -test event_buf::tests::try_producer_and_try_consumer ... ok -test event_buf::tests::wraps_around_correctly ... ok -test event_flags::tests::concurrent_raise_and_take_never_loses_the_condition ... ok -test event_flags::tests::const_new_works_in_static_context ... ok -test event_flags::tests::duplicate_raises_coalesce_and_take_clears ... ok -test event_flags::tests::empty_raise_and_empty_take_are_no_ops ... ok -test event_flags::tests::event_flags_object_is_eight_bytes ... ok -test event_flags::tests::event_mask_is_an_explicit_panic_free_32_bit_set ... ok -test event_flags::tests::handles_are_exclusive_and_reusable_after_drop ... ok -test event_flags::tests::handles_are_send_and_container_is_sync ... ok -test event_flags::tests::multi_bit_and_all_bit_masks_round_trip ... ok -test event_flags::tests::observed_raise_publishes_preceding_memory ... ok -test latest_buf::tests::concurrent_publication_never_returns_torn_value ... ok -test latest_buf::tests::full_generation_cycle_uses_documented_approximation ... ok -test latest_buf::tests::generation_wrap_skips_zero_and_counts_gap_exactly ... ok -test latest_buf::tests::generic_payload_can_be_a_complete_block ... ok -test latest_buf::tests::handle_reacquisition_continues_role_state ... ok -test latest_buf::tests::replacement_is_reported_on_both_endpoints ... ok -test latest_buf::tests::role_acquisition_is_unique_and_handles_are_send ... ok -test latest_buf::tests::starts_empty_and_takes_each_publication_at_most_once ... ok -test latest_buf::tests::static_channel_yields_static_sendable_handles ... ok -test macros::tests::event_buf_module_round_trips ... ok -test macros::tests::event_buf_take_is_once_only ... ok -test macros::tests::failed_take_strands_nothing ... ok -test macros::tests::handles_are_send ... ok -test macros::tests::seq_ring_module_round_trips ... ok -test ring::tests::capacity_returns_n ... ok -test ring::tests::clear_resets_state ... ok -test ring::tests::const_new_works_in_const_context ... ok -test ring::tests::default_is_new ... ok -test ring::tests::huge_capacity_does_not_overflow_the_index ... ok -test ring::tests::into_iter_for_ref ... ok -test ring::tests::iter_exact_size ... ok -test ring::tests::iter_oldest_to_newest ... ok -test ring::tests::new_ring_is_empty ... ok -test ring::tests::overwrite_oldest_when_full ... ok -test ring::tests::push_and_get ... ok -test ring::tests::works_without_default_bound ... ok -test seq_ring::tests::capacity_returns_n ... ok -test seq_ring::tests::const_new_works_in_const_context ... ok -test seq_ring::tests::consumer_skips_reserved_seq_zero_on_wrap ... ok -test seq_ring::tests::dropped_accum_saturates_instead_of_overflowing ... ok -test seq_ring::tests::dropped_counter_can_reset ... ok -test seq_ring::tests::drops_when_consumer_lags ... ok -test seq_ring::tests::lag_across_wrap_counts_drops_exactly ... ok -test seq_ring::tests::latest_empty_returns_false ... ok -test seq_ring::tests::latest_reads_newest ... ok -test seq_ring::tests::latest_returns_false_when_slot_missing ... ok -test seq_ring::tests::poll_one_empty_returns_false ... ok -test seq_ring::tests::poll_one_value_and_latest_value ... ok -test seq_ring::tests::poll_up_to_counts_dropped_when_slot_missing ... ok -test seq_ring::tests::poll_up_to_zero_returns_newest_only ... ok -test seq_ring::tests::polls_in_order ... ok -test seq_ring::tests::push_wraps_seq_from_zero_to_one ... ok -test seq_ring::tests::read_seq_inner_detects_overwrite_during_read ... ok -test seq_ring::tests::read_seq_inner_rejects_invalidated_slot ... ok -test seq_ring::tests::seq_distance_skips_the_reserved_zero ... ok -test seq_ring::tests::skip_to_latest_makes_next_poll_latest ... ok -test seq_ring::tests::static_ring_yields_static_sendable_handles ... ok -test seq_ring::tests::try_producer_and_try_consumer ... ok -test traits::tests::event_consumer_as_source ... ok -test traits::tests::event_producer_as_sink ... ok -test traits::tests::forward_empty_source_transfers_nothing ... ok -test traits::tests::forward_event_to_ringbuf ... ok -test traits::tests::forward_seq_to_event ... ok -test traits::tests::forward_stops_when_sink_full ... ok -test traits::tests::generic_drain_event ... ok -test traits::tests::generic_drain_seq ... ok -test traits::tests::ringbuf_as_sink ... ok -test traits::tests::seq_consumer_as_source ... ok -test traits::tests::seq_producer_as_sink ... ok - -test result: ok. 100 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 5.88s - - Doc-tests ph_eventing - -running 24 tests -test src/event_buf.rs - event_buf::EventBuf::new (line 113) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Producer (line 154) - compile fail ... ok -test src/seq_ring.rs - seq_ring::SeqRing::new (line 265) - compile fail ... ok -test src/ring.rs - ring::RingBuf::new (line 76) - compile fail ... ok -test src/counted_signal.rs - counted_signal::Consumer (line 215) - compile fail ... ok -test src/event_flags.rs - event_flags::Producer (line 208) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Consumer (line 409) - compile fail ... ok -test src/event_flags.rs - event_flags::Consumer (line 256) - compile fail ... ok -test src/block.rs - block (line 79) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Producer (line 337) - compile fail ... ok -test src/latest_buf.rs - latest_buf::Consumer (line 427) - compile fail ... ok -test src/lib.rs - (line 49) ... ok -test src/ring.rs - ring (line 16) ... ok -test src/lib.rs - (line 131) ... ok -test src/lib.rs - (line 60) ... ok -test src/block.rs - block (line 61) ... ok -test src/traits.rs - traits::forward (line 84) ... ok -test src/event_buf.rs - event_buf (line 31) ... ok -test src/lib.rs - (line 88) ... ok -test src/lib.rs - (line 75) ... ok -test src/macros.rs - macros::static_spsc (line 48) ... ok -test src/macros.rs - macros::static_spsc (line 27) ... ok -test src/latest_buf.rs - latest_buf (line 59) ... ok -test src/lib.rs - (line 36) ... ok - -test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s - -all doctests ran in 0.24s; merged doctests compilation took 0.01s - -Summary - PASS host: full checking - PASS host: seqlock logic (race detector off) - PASS host: 16 scheduler seeds - PASS i686-unknown-linux-gnu - PASS armv7-unknown-linux-gnueabihf - PASS s390x-unknown-linux-gnu - -All Miri passes clean. - -########## loom ########## -==> loom (max_preemptions=2) - Compiling generator v0.8.9 - Compiling loom v0.7.2 - Compiling ph-eventing v0.3.0 (/work) -warning: variable does not need to be mutable - --> src/loom_tests.rs:215:17 - | -215 | let mut take_spinning = |expected: u32| loop { - | ----^^^^^^^^^^^^^ - | | - | help: remove this `mut` - | - = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default - -warning: `ph-eventing` (lib test) generated 1 warning (run `cargo fix --lib -p ph-eventing --tests` to apply 1 suggestion) - Finished `test` profile [unoptimized + debuginfo] target(s) in 4.72s - Running unittests src/lib.rs (target/debug/deps/ph_eventing-8ebf478bfdbabaa8) - -running 16 tests -test loom_tests::event_flags_raise_racing_take_is_partitioned_exactly ... ok -test loom_tests::latest_buf_empty_fast_path_preserves_concurrent_publication ... ok -test loom_tests::event_flags_distinct_raises_partition_across_takes ... ok -test loom_tests::event_flags_observed_raise_publishes_payload ... ok -test loom_tests::counted_signal_saturation_boundary_is_linearizable ... ok -test loom_tests::event_buf_pop_never_sees_unpublished_data ... ok -test loom_tests::latest_buf_producer_reacquisition_continues_across_threads ... ok -test loom_tests::seq_ring_latest_is_never_ahead_of_published ... ok -test loom_tests::event_buf_len_never_exceeds_capacity ... ok -test loom_tests::counted_signal_take_partitions_increments ... ok -test loom_tests::counted_signal_post_take_increment_observes_reset_epoch ... ok -test loom_tests::latest_buf_returns_only_complete_publications ... ok -test loom_tests::latest_buf_reused_slot_keeps_exclusive_ownership ... ok -test loom_tests::seq_ring_consumer_never_outruns_the_producer ... ok -test loom_tests::event_buf_spsc_is_lossless_and_ordered ... ok -test loom_tests::latest_buf_consumer_reacquisition_continues_across_threads ... ok - -test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 86 filtered out; finished in 0.39s - - -All 16 Loom models that matched were verified. - -########## cycles ########## -==> QEMU emulator version 10.0.11 (Debian 1:10.0.11+ds-0+deb13u1) -==> building probe -==> tracing under qemu - - -EventBuf (backpressure SPSC) - eb push empty 25 - eb push nearly full 25 - eb push full rejected 19 - eb peek 13 - eb len 14 - eb pop full 20 - eb pop empty 13 - -SeqRing (overwrite SPSC) - sr push empty 34 - sr poll value 92 - sr poll empty 24 - sr push overwriting 33 - sr latest 30 - sr poll lagged 115 - sr poll lagged far 115 - -RingBuf (single owner) - rb push empty 20 - rb push overwriting 20 - rb get 22 - rb latest 16 - -CountedSignal (payload-free SPSC) - cs increment 8 - cs take count 9 - cs increment saturated 9 - -EventFlags (coalesced SPSC conditions) - ef raise clear 12 - ef raise already set 12 - ef take nonempty 10 - ef take empty 10 - -Instructions retired on the guest, marker overhead subtracted. -Deterministic per environment: -icount shift=0 pins one instruction to -one tick, so the same ELF under the same QEMU build yields the same -counts on any host. Different QEMU builds can shift region boundaries -by an instruction -- compare inside the reference image (verify.sh). - -########## atomic-window ########## -==> rustc 1.92.0 -==> building thumbv6m probe (ESP rows opt-in: ESP=1) - Finished `release` profile [optimized] target(s) in 0.27s - -TARGET raise take implementation ------------------- ----- ---- -------------- -thumbv6m 4 4 PRIMASK critical section -esp32-s2 - - opt-in: ESP=1 (needs esp-rs) -esp32-s3 - - opt-in: ESP=1 (needs esp-rs) - -Counts are instructions after interrupt disable through restore/sync. -thumbv6m is always gated; ESP rows require ESP=1 and the esp-rs toolchain. -Portable paths are straight-line and contain exactly one read, -one update/store sequence, and no branch or compare-exchange loop. - -Full matrix passed in the reference environment. -Note: EventFlags ESP32-S2/S3 interrupt windows are opt-in -(ESP=1 ./scripts/event-flags-atomic-window.sh); they are not -part of this Docker matrix (no esp-rs in the image). From 7848f59bdc07d97cbf909813b7ebbc2c2a0e0ce4 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 03:16:37 -0400 Subject: [PATCH 74/87] Close the six review P2s: span propagation, builder span alias, Send bound, per-report model, full cycle matrix, static-only .bss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Crate-root surfaces (src/lib.rs seqlock bullet and wrap bullet, README safety section) no longer promise unconditional no-torn-value: each carries the whole-span bound with a pointer to the seq_ring disclosure, matching the module docs the review said they contradict. - BlockBuilder's contiguity check compares u32 identity only, so an upstream omission of exactly one whole 2^32 - 1 span aliases the recurring sequence to the expected successor. Documented as a known-limitation section (reachability arithmetic, clear()-on-outage recovery guidance), qualified in F2 (proposal + record row), and the chosen keep-one-word-and-disclose policy is pinned by the new discontinuity_check_is_modular_over_the_span test. - LatestBuf handle docs, contract H2, and the record now say Send when T: Send, always !Sync — T: Copy does not imply T: Send, and the implementation already correctly refuses the transfer; only the prose over-promised. - The concurrent Loom model preserves each PublishReport in a per-generation array and correlates every report with its predecessor's fate (taken or pending => not replaced; otherwise replaced), which holds in every interleaving because once publish(k) lands, k-1 can never be taken later. The aggregate conservation check stays as an independent second assert. Mutation-verified: flipping the correlation fails immediately naming the call; 17/17 models pass. - verify.sh 'all' now runs all four cycle modes (default, block-matrix, latest-matrix, latest-block-matrix) — the release records cite the matrix probes, so the full-matrix banner must compile and run them. - .bss wording in the block module docs and LatestBuf record is static-only: fixed-size storage lives wherever the value is placed, and the 8,280-byte combined shapes are a stack budget when built as locals. Co-Authored-By: Claude Fable 5 --- README.md | 8 ++-- docs/proposals/block-buf.md | 5 ++- docs/proposals/latest-buf-contract.md | 6 ++- docs/records/block-buf.md | 2 +- docs/records/latest-buf.md | 13 ++++--- scripts/verify.sh | 8 +++- src/block.rs | 51 +++++++++++++++++++++++++- src/latest_buf.rs | 14 ++++--- src/lib.rs | 14 +++++-- src/loom_tests.rs | 53 +++++++++++++++++++-------- 10 files changed, 135 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 2f30c32..8a1d86c 100644 --- a/README.md +++ b/README.md @@ -401,9 +401,11 @@ them is a runtime step and always will be. - `EventBuf` is race-free by construction: its producer and consumer never touch the same slot, and it passes Miri with the data-race detector enabled. - `SeqRing` is a seqlock and carries a **known formal data race** — the consumer may copy a slot - the producer is overwriting, then discard the copy when the sequence re-check fails. The copy is - never returned and never becomes an invalid value, but the access is undefined behaviour by the - letter of the memory model. + the producer is overwriting, then discard the copy when the sequence re-check fails. A raced + copy is discarded and never becomes an invalid value within the whole-span bound (the re-check + compares `u32` sequences, so a consumer stalled mid-read for a full `2^32 − 1` publications can + pass both checks against a rewritten slot; see the SeqRing section above and the rustdoc). The + access itself is undefined behaviour by the letter of the memory model. - **This affects your tooling, not just ours:** if you run `cargo miri test` over a test that drives `SeqRing` from two threads, Miri will report UB pointing into this crate. That is the known deviation, not a new bug. diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index 4551bd3..06c3734 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -271,7 +271,10 @@ the same substitution. The block-only, pre-publication delta is: - **F1 Complete-only:** no public `Block` is yielded before all `N` samples are initialized. - **F2 Contiguous:** represented sequences are consecutive under the successor - that skips reserved zero. + that skips reserved zero — exact below one `2^32 - 1` sequence span; a whole + span omitted while a partial builder is held aliases the recurring value to + the expected successor (span non-promise, disclosed in the module docs with + the recovery guidance). - **F3 Explicit interruption:** reserved/discontinuous input is returned and does not silently alter the partial block. - **F4 Teardown:** clearing or dropping partial state publishes nothing. diff --git a/docs/proposals/latest-buf-contract.md b/docs/proposals/latest-buf-contract.md index 12ab44a..83d118e 100644 --- a/docs/proposals/latest-buf-contract.md +++ b/docs/proposals/latest-buf-contract.md @@ -167,8 +167,10 @@ the measured implementation, per environment. (`try_producer` / `try_consumer` returning `Option`, matching the crate's 0.2.0+ convention); a second concurrent acquisition of the same role fails. -- **H2.** Handles are `Send + !Sync`, and dropping a handle makes its role - re-acquirable. (Same model as `SeqRing`/`EventBuf`; the `!Sync` is what +- **H2.** Handles are `Send` when `T: Send` — the handle can move a payload + across contexts, so a non-`Send` payload correctly pins it; `T: Copy` alone + does not imply `T: Send` — always `!Sync`, and dropping a handle makes its + role re-acquirable. (Same model as `SeqRing`/`EventBuf`; the `!Sync` is what makes moving a handle into an ISR sound.) - **H3.** A channel is constructible in a `static` (const construction on the normal build), matching the existing primitives. diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index a5ac5c9..167f56e 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -99,7 +99,7 @@ section and contract IDs in parentheses; those texts are normative. |---|---|---| | Type identity is composition — no `LatestBlockBuf` (D3, §5.1) | Maintainer closure 2026-08-11 (contract §9, PR #37); candidate implements `Block`/`BlockBuilder` only | Closed | | Complete-only yield; teardown publishes nothing (F1, F4) | Unit pins on completion boundary; drop/clear of partial builder; Miri on `MaybeUninit` completion copy | Proven | -| Contiguous wrap-aware sequences; reserved `0` rejected (F2) | Fill-error unit set incl. wrap; `T: Copy` without `Default` | Pinned | +| Contiguous wrap-aware sequences; reserved `0` rejected (F2) | Fill-error unit set incl. wrap; the modular-identity policy is pinned by `discontinuity_check_is_modular_over_the_span`; `T: Copy` without `Default` | Pinned — exact below one sequence span: the check compares `u32` values only, so an upstream omission of exactly one whole span aliases to contiguous (module docs carry the disclosure and the `clear()`-on-outage guidance) | | Discontinuity preserves partial block and returns sample (F3) | Explicit interruption tests; no silent mutate-or-count | Proven | | Accepted/rejected publication cost measured per shape (§6.1) | Matrix `bc54a9a`: 18 instruction rows (159–8,658 accepted; rejection within 5–31); reproduced on QEMU 10.0.11 and 10.2.1 | Measured | | Flash cost bounded across gated targets | 8-target codesize matrix: 110–312 B per completion-plus-publication shape | Measured | diff --git a/docs/records/latest-buf.md b/docs/records/latest-buf.md index 5f8cb91..7b42115 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -60,10 +60,13 @@ IDs in parentheses; the clauses are the normative statements. out-of-band reset — a forced release could free a role while a live handle exists, defeating the exclusive-ownership soundness argument. Handle lifetime is the application's property. -- **Memory is three payload slots, always (B3).** Fixed and in `.bss`, - but real: measured channels are 48/84/420 B for the shipped payload - matrix, and the block-payload shapes run 136–8,280 B of combined - channel + builder RAM across the measured grid. State the per-shape +- **Memory is three payload slots, always (B3).** Fixed in size, and it + lives wherever the value is placed: a `const`-constructed `static` + lands in `.bss` (the measured no-flash/no-startup-copy claim); a local + consumes stack. Real either way: measured channels are 48/84/420 B for + the shipped payload matrix, and the block-payload shapes run + 136–8,280 B of combined channel + builder RAM across the measured + grid — charged to stack if built as locals. State the per-shape number for your payload; the docs are required to. - **Block payloads invert at small N (D3 obligation).** Publishing complete blocks through `LatestBuf>` beats per-sample @@ -89,7 +92,7 @@ IDs in parentheses; the clauses are the normative statements. | Reacquisition continues, never restarts (H4) | Drop-and-reacquire continuation tests; both cross-context role-handoff Loom models; detector-on Miri | Proven | | Empty poll costs one `Acquire` load, no RMW (A.1) | Loom equivalence model; measured: 7–8 instructions off empty polls, +5–6 on pending Cortex-M3 paths | Measured, selected | | No `Source` impl can arrive silently (D2, X7) | `compile_fail,E0277` doctest on `Consumer` | Pinned | -| Handles stay `Send + !Sync` (H2) | `compile_fail` doctests on `Producer` and `Consumer` | Pinned | +| Handles are `Send` when `T: Send`, always `!Sync` (H2) | `compile_fail` doctests on `Producer` and `Consumer` pin `!Sync`; the `Send` bound is the compiler's own (`T: Copy` does not imply `T: Send`, and a `PhantomData<*const ()>` payload correctly fails an `assert_send` probe) | Pinned | | Cost claims per target | 11-target code-size matrix incl. ESP32-S2/S3; pinned QEMU 10.0.11 cycle regions; 66-region joint block matrix reproduced across two QEMU versions | Measured | Full CI at lane acceptance (per-lane tree; the assembled 0.3.0 release matrix supersedes these totals): 76 unit tests, 12 doctests, 6 compile-fail, 94.12% diff --git a/scripts/verify.sh b/scripts/verify.sh index 3ef5a4d..63effa2 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -76,6 +76,9 @@ run_script() { miri) run sh scripts/miri.sh ;; loom) run sh scripts/loom.sh ;; cycles) run sh scripts/cycles.sh ;; + cycles-block-matrix) run sh scripts/cycles.sh block-matrix ;; + cycles-latest-matrix) run sh scripts/cycles.sh latest-matrix ;; + cycles-latest-block-matrix) run sh scripts/cycles.sh latest-block-matrix ;; # thumbv6m only inside the image; ESP rows need esp-rs and stay opt-in # (ESP=1) outside Docker — see event-flags-atomic-window.sh. atomic-window) run sh scripts/event-flags-atomic-window.sh ;; @@ -100,7 +103,10 @@ case "${1:-all}" in # Version stamp first, so any pasted output carries its environment. run sh -c 'rustc --version; rustc "+$MIRI_TOOLCHAIN" --version; qemu-system-arm --version | head -1' failed=0 - for s in ci miri loom cycles atomic-window; do + # All four cycle modes: the release records cite the matrix probes' + # numbers, so a "full matrix" that never compiled them could pass + # while a special probe was broken or a cycle claim stale. + for s in ci miri loom cycles cycles-block-matrix cycles-latest-matrix cycles-latest-block-matrix atomic-window; do printf '\n########## %s ##########\n' "$s" run_script "$s" || failed=$((failed + 1)) done diff --git a/src/block.rs b/src/block.rs index 67e3ac3..b53af18 100644 --- a/src/block.rs +++ b/src/block.rs @@ -15,14 +15,35 @@ //! Timestamps are payload policy: use a timestamped sample type for `T` when //! each sample needs a stamp. The transport does not impose one. //! +//! # Known limitation: sequence-span aliasing +//! +//! The contiguity check compares `u32` sequence values and nothing else, and +//! the successor skips reserved `0`, so sequence identity is modular over the +//! `2^32 - 1` nonzero span — the same counter-width boundary the transports +//! disclose. A partial builder held while upstream omits *exactly one whole +//! span* of sequences (or any whole multiple) sees the recurring value as the +//! expected successor and completes the block as "contiguous" despite +//! ~4.29 billion omitted samples; the gap-rejection promise (F2) is exact +//! only below one span. Reachability: the omission must span `2^32 - 1` +//! sequences while the same partial builder stays live — ~71.6 minutes of +//! outage at a sustained 1 MHz sample rate, ~5 days at 10 kHz. The chosen +//! policy is to keep the sequence one word and disclose the bound rather than +//! carry a wider epoch: recovery from outages is the application's job — +//! `clear()` the builder when your staleness watchdog or link-layer detects a +//! gap it cannot bound below one span. +//! //! # Costs and integration (measured; bound by decisions D3 and P) //! //! **RAM is multiple complete blocks, always.** The latest composition //! (`LatestBuf>`) holds three block slots plus this private //! builder — 136–8,280 bytes of combined channel + builder RAM across the //! measured 2/8/16-byte × `N = 8/32/128` grid. The queued composition -//! stores `Q` blocks plus the builder. The cost is fixed and in `.bss`, -//! but it is not small: state the number for your shape. +//! stores `Q` blocks plus the builder. The cost is fixed in size but lives +//! wherever you place the value: a `const`-constructed `static` lands in +//! `.bss` (no flash image, no startup copy); a local consumes **stack**, +//! and at up to 8,280 bytes per combined shape that is a real stack +//! budget, not a rounding error. It is not small either way: state the +//! number for your shape and charge it to the right budget. //! //! **Small windows can invert the economics.** Continuous block release //! beats `N` individual sample publications for every measured 2-byte row @@ -292,6 +313,32 @@ mod tests { assert!(fill.is_empty()); } + #[test] + fn discontinuity_check_is_modular_over_the_span() { + // The chosen policy's pin (F2 span non-promise): contiguity compares + // `u32` sequence identity and nothing else. The recurring value after + // exactly one whole `2^32 - 1` span is therefore indistinguishable + // from the true successor and is accepted — including across the + // reserved-zero wrap. If this test's expectation ever changes, the + // policy changed (an epoch was added) and the module-doc disclosure, + // record row, and proposal F2 text must change with it. + let mut fill = BlockBuilder::::new(); + assert_eq!(fill.push(u32::MAX, 1), Ok(None)); + // Successor skips reserved 0: expected is 1, and any occurrence of + // sequence 1 — the immediate successor or the wrap-aliased ordinal a + // whole span later — completes the block as contiguous. + assert!(matches!( + fill.push(2, 9), + Err(FillError::Discontinuous { + expected: 1, + received: 2, + .. + }) + )); + let block = fill.push(1, 2).unwrap().unwrap(); + assert_eq!(block.samples(), &[1, 2]); + } + #[test] fn completion_resets_for_the_next_block() { let mut fill = BlockBuilder::::new(); diff --git a/src/latest_buf.rs b/src/latest_buf.rs index bb2fb87..3afcb7b 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -330,9 +330,12 @@ impl core::fmt::Debug for LatestBuf { /// Unique, stateless write handle for a [`LatestBuf`]. /// -/// This handle is `Send + !Sync`: it may move into an ISR or another execution -/// context, but it may not be shared between contexts. Sole-producer ownership -/// is load-bearing for the exclusive-slot soundness argument (contract H2). +/// This handle is `Send` **when `T: Send`** (the handle can move a payload +/// across contexts, so a non-`Send` payload correctly pins it — `T: Copy` +/// alone does not imply `T: Send`) and always `!Sync`: it may move into an +/// ISR or another execution context, but it may not be shared between +/// contexts. Sole-producer ownership is load-bearing for the exclusive-slot +/// soundness argument (contract H2). /// /// ```compile_fail,E0277 /// use ph_eventing::latest_buf::Producer; @@ -403,8 +406,9 @@ impl core::fmt::Debug for Producer<'_, T> { /// Unique, stateless read handle for a [`LatestBuf`]. /// -/// This handle is `Send + !Sync`: it may move into a consumer context, but it -/// may not be shared between contexts (contract H2). +/// This handle is `Send` **when `T: Send`** (a non-`Send` payload correctly +/// pins it to one context) and always `!Sync`: it may move into a consumer +/// context, but it may not be shared between contexts (contract H2). /// /// ```compile_fail,E0277 /// use ph_eventing::latest_buf::Consumer; diff --git a/src/lib.rs b/src/lib.rs index 789fb8d..2dacbc2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -139,9 +139,14 @@ //! - [`EventBuf`] is race-free by construction — its producer and consumer //! never touch the same slot — and passes Miri with the data-race detector //! enabled. -//! - [`SeqRing`] is a seqlock and carries a **known formal data race**. The -//! copy is never returned and never becomes an invalid value, but the access -//! is undefined behaviour by the letter of the memory model. Practical +//! - [`SeqRing`] is a seqlock and carries a **known formal data race**. A +//! raced copy is discarded and never becomes an invalid value — within the +//! whole-span bound: the discard compares `u32` sequences, so a consumer +//! stalled mid-read for a full `2^32 - 1` publications can pass both checks +//! against a rewritten slot (the [`seq_ring`] "whole-span sequence +//! aliasing" section carries the reachability arithmetic and escape +//! hatches). The access itself is undefined behaviour by the letter of the +//! memory model. Practical //! consequence: running Miri over a test that drives this ring from two //! threads reports UB inside this crate — that is the deviation, not a new //! bug. It is a deliberate trade of formal soundness for accepting any @@ -179,7 +184,8 @@ //! - Once every `2^32 - 1` pushes the sequence counter wraps, and a few extra entries are dropped //! there because `push` skips the reserved sequence `0`: exactly one for a power-of-two `N`, //! none if `N` divides `2^32 - 1`, up to `N - 1` otherwise. Reported as ordinary drops, never a -//! stale or torn value. Prefer a power of two for `N`; see the [`seq_ring`] module docs. +//! stale or torn value within the whole-span bound stated in the [`seq_ring`] +//! module docs. Prefer a power of two for `N`. //! - `Consumer::dropped` saturates rather than wrapping; `usize` is 32 bits on //! the targets this crate ships to, so a long-lived lagging consumer can //! reach the top of the range. diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 989ff28..55129c2 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -122,14 +122,13 @@ fn latest_buf_returns_only_complete_publications() { let producer_channel = Arc::clone(&channel); let producer = thread::spawn(move || { let producer = producer_channel.try_producer().unwrap(); - // Each publication's report is preserved: `replaced_unread` is - // the producer-side half of the loss ledger, and the conservation - // assert after the joins checks it against the consumer's takes. - let mut replaced = 0u32; + // Every individual report is preserved — P4 is a per-call claim, + // and a per-call bitmap is what catches a report that is right in + // aggregate but wrong on two calls (e.g. [false, true, false] + // where [false, false, true] is correct). + let mut replaced = [false; 3]; for value in 1..=3u32 { - if producer.publish([value, value]).replaced_unread { - replaced += 1; - } + replaced[(value - 1) as usize] = producer.publish([value, value]).replaced_unread; } replaced }); @@ -143,7 +142,7 @@ fn latest_buf_returns_only_complete_publications() { // generations passed over since the previous take (C3/O2; no wrap // at these magnitudes, so the formula reduces to the difference). let mut last_generation = 0u32; - let mut taken = 0u32; + let mut taken = [false; 3]; for _ in 0..3 { if let Some(item) = consumer.take_latest() { assert!((1..=3).contains(&item.generation)); @@ -151,7 +150,7 @@ fn latest_buf_returns_only_complete_publications() { assert!(item.generation > last_generation); assert_eq!(item.skipped, item.generation - last_generation - 1); last_generation = item.generation; - taken += 1; + taken[(item.generation - 1) as usize] = true; } thread::yield_now(); } @@ -160,13 +159,37 @@ fn latest_buf_returns_only_complete_publications() { let replaced = producer.join().unwrap(); let taken = consumer.join().unwrap(); + let pending = channel + .try_consumer() + .unwrap() + .take_latest() + .map(|item| item.generation); + + // Per-publication correlation (P4): publish(k) displaced an unread + // value if and only if generation k-1 was neither taken by the + // consumer nor left pending at the end. Once publish(k) lands, + // generation k-1 is either already taken or displaced — it can never + // be taken later — so this holds in every interleaving. publish(1) + // found an empty channel and can never report a displacement. + assert!(!replaced[0]); + for k in 2..=3u32 { + let predecessor_survived = taken[(k - 2) as usize] || pending == Some(k - 1); + assert_eq!( + replaced[(k - 1) as usize], + !predecessor_survived, + "publish({k}) report disagrees with its predecessor's fate" + ); + } - // Every publication ends in exactly one bucket — taken by the - // consumer, displaced while unread (the producer's report), or still - // pending at the end. The exchange's linearization makes this hold in - // every interleaving; a report derived from stale state breaks it. - let pending = u32::from(channel.try_consumer().unwrap().take_latest().is_some()); - assert_eq!(replaced + taken + pending, 3); + // Aggregate conservation stays as a second, independent check: every + // publication ends in exactly one bucket — taken, displaced while + // unread, or pending at the end. + let replaced_count = replaced.iter().filter(|&&r| r).count(); + let taken_count = taken.iter().filter(|&&t| t).count(); + assert_eq!( + replaced_count + taken_count + usize::from(pending.is_some()), + 3 + ); }); } From c1699c0df63aebbcc82897d810da759914f3ecc9 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 03:18:07 -0400 Subject: [PATCH 75/87] Close the two review P3s: complete the atomic-shim list and the trait tables The no-atomics target guidance named only three of the five concurrent primitives; it now says every concurrent primitive (SeqRing, EventBuf, EventFlags, CountedSignal, LatestBuf) requires 32-bit atomics, so thumbv6m readers cannot infer the two new types work without a portable-atomic backend. The crate-root and traits-module tables gain LatestSink/LatestSource rows with a note that forward bridges the stream pair only and why LatestBuf stands outside it (decision D2). cargo doc builds with zero warnings. Co-Authored-By: Claude Fable 5 --- src/lib.rs | 9 ++++++++- src/traits.rs | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 2dacbc2..b9cd8c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,12 @@ //! | [`Sink`](traits::Sink) | Accept events | `RingBuf`, `seq_ring::Producer`, `event_buf::Producer` | //! | [`Source`](traits::Source) | Yield events | `seq_ring::Consumer`, `event_buf::Consumer` | //! | [`Link`](traits::Link) | Both | Blanket impl for any `Sink + Source` | +//! | [`LatestSink`](traits::LatestSink) | Publish a newest value | `latest_buf::Producer` | +//! | [`LatestSource`](traits::LatestSource) | Take the newest value with loss evidence | `latest_buf::Consumer` | +//! +//! [`forward`](traits::forward) bridges the stream pair only; `LatestBuf` +//! deliberately stands outside it (decision D2), and the signal types +//! implement neither family. //! //! The [`traits::forward`] function transfers items from any `Source` to any //! `Sink`, making it easy to bridge different buffer types. @@ -107,7 +113,8 @@ //! The crate is `#![no_std]` by default. Tests require `std`. //! //! # Targets without atomics -//! `SeqRing`, `EventBuf`, and `EventFlags` require 32-bit atomics. For targets that lack them +//! Every concurrent primitive — `SeqRing`, `EventBuf`, `EventFlags`, +//! `CountedSignal`, and `LatestBuf` — requires 32-bit atomics. For targets that lack them //! (for example `thumbv6m-none-eabi`), enable //! `portable-atomic-unsafe-assume-single-core` or `portable-atomic-critical-section`. //! The crate always compiles those modules, so no-atomic targets need one of diff --git a/src/traits.rs b/src/traits.rs index 2f3cf7f..ee5bfeb 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -8,6 +8,13 @@ //! | [`Sink`] | Accept events | [`RingBuf`], [`seq_ring::Producer`], [`event_buf::Producer`] | //! | [`Source`] | Yield events | [`seq_ring::Consumer`], [`event_buf::Consumer`] | //! | [`Link`] | Both — accept *and* yield | Blanket impl for any `Sink + Source` | +//! | [`LatestSink`] | Publish a newest value | [`latest_buf::Producer`](crate::latest_buf::Producer) | +//! | [`LatestSource`] | Take the newest value with replacement/skipped evidence | [`latest_buf::Consumer`](crate::latest_buf::Consumer) | +//! +//! [`forward`] bridges `Source` into `Sink` only — the stream pair. +//! `LatestBuf`'s handles implement the latest-value pair instead, by decision +//! D2: `try_pop` cannot report the displacement that is that channel's +//! designed overload behaviour. //! //! The free function [`forward`] transfers items from any [`Source`] to any //! [`Sink`], stopping when the source is empty or the sink rejects a value. From 5617f25aa031251181e5243b6b73e1ca062b0880 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 03:28:46 -0400 Subject: [PATCH 76/87] Close the six live legacy threads from the component-PR history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembly review counted eight unresolved threads on #34/#35/#36/#38; two were already closed by the P2 batch (verify.sh full cycle matrix; static-only .bss wording). The remaining six: - #34: the block cycle probe's builders died immediately after their measured push, so dead-store elimination could remove the completion reset a reusable production builder pays. Both builders are now pinned live past their regions (black_box after m_end) and the matrix was re-measured on the assembled tree in the reference image: accepted 150-8,651 reference instructions, rejection within 2-25 of acceptance. Live claim surfaces restate the range; the measurements document keeps its lane-time table and gains a restatement note with all 18 new rows. - #35: the producer/consumer role-handoff Loom models tried acquisition once and treated a failed claim as success, so many executions never exercised the handoff (evaluation L3). Both now gate on a Relaxed dropped-flag — deliberately no happens-before, so the handoff's own Release-drop/AcqRel-swap edge stays load-bearing — and acquire deterministically (the swap is an RMW and reads the latest role flag in modification order), making every terminating execution complete the cross-context handoff with unconditional asserts. A first attempt with try-retry spin loops blew Loom's branch budget; the flag-gate shape keeps the state space finite. 17/17 models pass. - #36: the straight-line detectors matched any b-prefixed mnemonic, so ALU forms like bic (ARM) or break (Xtensa) would fail the gate as false branches. Both regexes are now exact-mnemonic alternations (conditional branches, cbz/cbnz, bl/blx/bx, and the Xtensa branch set), verified against twelve synthetic positive/negative cases and the real thumbv6m probe (gate still 4/4, exit 0). - #38: the LatestBuf record's loss bullet claimed displacement under any rate mismatch; P4 sets replaced_unread only when an unread value was actually pending, and a consumer that keeps up sees none — restated. The deferred SlotPool document's sections 4-5 presented pre-evaluation open questions and a promotion bar as current; both now carry historical framing pointing at the banked evaluation and decision S's reopening trigger. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +- README.md | 4 +- docs/proposals/block-buf-measurements.md | 12 ++++ docs/proposals/block-buf.md | 2 +- docs/proposals/slot-pool.md | 22 ++++++- docs/records/block-buf.md | 6 +- docs/records/event-buf.md | 6 +- docs/records/latest-buf.md | 11 ++-- scripts/cycles/src/main.rs | 5 ++ scripts/event-flags-atomic-window.sh | 4 +- src/block.rs | 4 +- src/loom_tests.rs | 81 ++++++++++++++---------- 12 files changed, 106 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f95526..300fec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,8 +38,8 @@ All notable changes to this project will be documented in this file. with the overload policy you need: `EventBuf, Q>` queues and rejects when full, `LatestBuf>` retains the newest. Publication copies the complete block (decision P: Copy composition) — the measured - per-shape rows are the budget statement (159–8,658 reference instructions - across the 2/8/16-byte × N = 8/32/128 grid; rejection within 5–31 + per-shape rows are the budget statement (150–8,651 reference instructions + across the 2/8/16-byte × N = 8/32/128 grid; rejection within 2–25 instructions of acceptance; per-shape RAM disclosed), the small-`N` cost inversion is documented, and the double-copy DMA hazard is stated in the module docs: the builder's storage is deliberately private, so budget both diff --git a/README.md b/README.md index 8a1d86c..2836900 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,10 @@ newest when full; `LatestBuf>` retains only the latest complete block. **Budget the composition before choosing it.** Publication copies the -complete block, so cost scales with block bytes (159–8,658 reference +complete block, so cost scales with block bytes (150–8,651 reference instructions across the measured 2/8/16-byte × N = 8/32/128 grid), a rejected push costs nearly as much as an accepted one (the complete block -is preserved and returned, within 5–31 instructions), and RAM is multiple +is preserved and returned, within 2–25 instructions), and RAM is multiple complete blocks — `Q` slots plus the private builder. Small windows can invert the economics (per-sample publication beats blocks at the 8/16-byte `N = 8` corners), and DMA integrations currently cannot avoid diff --git a/docs/proposals/block-buf-measurements.md b/docs/proposals/block-buf-measurements.md index 0537077..ca82078 100644 --- a/docs/proposals/block-buf-measurements.md +++ b/docs/proposals/block-buf-measurements.md @@ -1,5 +1,17 @@ # BlockBuf publication-cost matrix +> **0.3.0 restatement (2026-08-12).** The committed probe now pins both +> builders live past their measured regions (`black_box(&mut fill)` after +> `m_end`), so dead-store elimination cannot remove the completion reset a +> reusable production builder pays — a review finding on the assembly PR. +> Re-measured on the assembled `release/0.3.0` tree in the same reference +> image: accepted completion-plus-publication runs **150–8,651** reference +> instructions across the grid and rejection lands **within 2–25 +> instructions** of the accepted path on every row (w2: 155/150, 605/598, +> 1372/1365; w8: 621/612, 1388/1379, 4499/4497; w16: 871/846, 2408/2406, +> 8651/8644 for N = 8/32/128). The table below is the original lane-time +> measurement, preserved as recorded. + - **Measured:** 2026-08-11 - **Source branch:** `candidate/block-buf` at `e21c7fa`, plus the isolated measurement harness recorded with this document. diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index 06c3734..a02c628 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -237,7 +237,7 @@ counts from the pinned QEMU 10.0.11 reference environment. On the reference Cortex-M3, accepted completion plus publication ranges from 159 instructions for 2-byte samples at `N = 8` to 8,658 instructions for -16-byte samples at `N = 128`. Rejection is within 5–31 instructions of the +16-byte samples at `N = 128`. Rejection is within 2–25 instructions of the accepted path because the complete rejected block is preserved and returned to the caller. Logical payload traffic ranges from 48 to 4,112 bytes per path. diff --git a/docs/proposals/slot-pool.md b/docs/proposals/slot-pool.md index 72c033a..d561916 100644 --- a/docs/proposals/slot-pool.md +++ b/docs/proposals/slot-pool.md @@ -138,7 +138,18 @@ demonstrates their need — this one. out-of-scope list — the pool transfers ownership; target-specific visibility effects belong to the integration layer. -## 4. Open questions +## 4. Open questions *(historical — preserved as written before the evaluation)* + +The questions below were the pre-evaluation state of this document. The +`candidate/slot-pool` evaluation (banked at tag +`archive/slot-pool-0.3.0-evaluation`) subsequently answered them — the +atomic representation, initialization proof, and measured rows live on that +branch — and decision S then deferred the primitive. They are kept as +written because they define what a reopened evaluation must re-derive if +the banked branch has rotted. + + +### Original questions - Ordering discipline for the state transitions: the `Free → ProducerOwned → Published → ConsumerOwned → Free` cycle is a @@ -160,7 +171,14 @@ demonstrates their need — this one. [`BlockBuf`](block-buf.md) — i.e., does it earn admission standalone or as the foundation the block primitive demonstrates? -## 5. Promotion bar to PROPOSED +## 5. Promotion bar to PROPOSED *(historical — superseded by decision S)* + +This bar described promotion into the 0.3.0 cycle. Decision S closed as +DEFERRED with the evaluation evidence banked, so the operative gate is now +the **reopening trigger** in the status header, not this list; a future +reopening still owes everything below before PROPOSED. + +### Original bar 1. Answer the ownership-model questions above; write the state machine down precisely (it is the whole primitive). diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index 167f56e..4eef93f 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -39,7 +39,7 @@ section and contract IDs in parentheses; those texts are normative. - **Publication cost scales with block bytes, not with the O(1) synchronization step (proposal §6, §6.1).** Accepted - completion-plus-publication on the reference Cortex-M3 runs 159–8,658 + completion-plus-publication on the reference Cortex-M3 runs 150–8,651 retired instructions across the measured grid (2/8/16-byte samples × `N = 8/32/128`) and grows roughly linearly with logical payload traffic (48–4,112 B per path). Synchronization being constant does @@ -48,7 +48,7 @@ section and contract IDs in parentheses; those texts are normative. handoff myth. - **Rejection is nearly as expensive as acceptance (§6.1).** Preserving and returning the complete rejected block keeps the rejected path - within 5–31 instructions of the accepted path on every measured row — + within 2–25 instructions of the accepted path on every measured row — not a cheap scalar `Err(())`. Callers that treat rejection as free error plumbing will mis-budget the ISR/task. - **Small-N publication can lose to per-sample LatestBuf (D3 @@ -101,7 +101,7 @@ section and contract IDs in parentheses; those texts are normative. | Complete-only yield; teardown publishes nothing (F1, F4) | Unit pins on completion boundary; drop/clear of partial builder; Miri on `MaybeUninit` completion copy | Proven | | Contiguous wrap-aware sequences; reserved `0` rejected (F2) | Fill-error unit set incl. wrap; the modular-identity policy is pinned by `discontinuity_check_is_modular_over_the_span`; `T: Copy` without `Default` | Pinned — exact below one sequence span: the check compares `u32` values only, so an upstream omission of exactly one whole span aliases to contiguous (module docs carry the disclosure and the `clear()`-on-outage guidance) | | Discontinuity preserves partial block and returns sample (F3) | Explicit interruption tests; no silent mutate-or-count | Proven | -| Accepted/rejected publication cost measured per shape (§6.1) | Matrix `bc54a9a`: 18 instruction rows (159–8,658 accepted; rejection within 5–31); reproduced on QEMU 10.0.11 and 10.2.1 | Measured | +| Accepted/rejected publication cost measured per shape (§6.1) | Matrix `bc54a9a`: 18 instruction rows (150–8,651 accepted; rejection within 2–25); reproduced on QEMU 10.0.11 and 10.2.1 | Measured | | Flash cost bounded across gated targets | 8-target codesize matrix: 110–312 B per completion-plus-publication shape | Measured | | Joint D3 composition cost vs per-sample (D3) | Matrix `8593b4a`: 171–6,958 instructions; 136–8,280 B combined channel+builder RAM; small-N inversion documented | Measured | | No block-layer atomics; transport evidence unchanged (§8) | Block path: unit + Miri; LatestBuf/EventBuf: detector-on Miri and Loom on selected transport | Proven (transport) | diff --git a/docs/records/event-buf.md b/docs/records/event-buf.md index e32fd8d..b5ee957 100644 --- a/docs/records/event-buf.md +++ b/docs/records/event-buf.md @@ -29,7 +29,7 @@ it outright. It refuses to be: lossy (that is `SeqRing`), freshness-first every time. A producer that cannot afford to handle "full" belongs on `SeqRing`, where overload is absorbed and counted instead of returned. With `Block` payloads the returned value is the *entire completed - block* (`#[must_use]`, within 5–31 instructions of the accepted path) + block* (`#[must_use]`, within 2–25 instructions of the accepted path) — budget rejection like acceptance, not like error plumbing. - **`N` is the backpressure threshold, chosen per application.** The point at which `push` starts failing is a sizing decision the crate @@ -70,8 +70,8 @@ it outright. It refuses to be: lossy (that is `SeqRing`), freshness-first - **D3 (closed 2026-08-11):** `EventBuf, Q>` is the queued arm of the confirmed block composition — no `QueuedBlockBuf` type exists; existing admission, rejection, and race-freedom carry over - unchanged. Decision P's publication-cost rows (159–8,658 reference - instructions; rejection within 5–31 of acceptance) are the measured + unchanged. Decision P's publication-cost rows (150–8,651 reference + instructions; rejection within 2–25 of acceptance) are the measured cost basis. - **Shared worked rejections (AGENTS):** `try_split()` rejected on the 0.2.0 measurement (+43–59% flash on constrained cores); the panic diff --git a/docs/records/latest-buf.md b/docs/records/latest-buf.md index 7b42115..8884a51 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -30,10 +30,13 @@ timestamp authority, or a multi-producer/multi-consumer bus. Ordered by how likely they are to surprise an integrator. Contract clause IDs in parentheses; the clauses are the normative statements. -- **Loss is the designed behaviour, not a fault mode (X1).** Under any - producer/consumer rate mismatch, intermediate publications are - displaced. The type is wrong for any consumer that must observe every - value — that is `EventBuf`'s niche. +- **Loss is the designed behaviour, not a fault mode (X1).** When the + producer outpaces the consumer, publications that were still pending + unread are displaced by the next publish and reported through + `replaced_unread` (P4 sets it only when an unread value was actually + pending — a consumer that keeps up, including any consumer-faster + rate mismatch, sees no displacement at all). The type is wrong for any + consumer that must observe every value — that is `EventBuf`'s niche. - **The skipped count has a documented exactness boundary (C3, X6).** `skipped` is exact while fewer than one full generation span (2³²−1 publications) separates two takes; beyond that it under-counts, and a diff --git a/scripts/cycles/src/main.rs b/scripts/cycles/src/main.rs index 9dfbec6..40b5286 100644 --- a/scripts/cycles/src/main.rs +++ b/scripts/cycles/src/main.rs @@ -476,6 +476,10 @@ macro_rules! measure_block_shape { .expect("complete"); let _ = black_box(tx.push(block)); m_end(); + // The builder outlives the region: a production builder is reused, so + // the completion reset inside the measured push must not be removable + // as a dead store just because this probe's builder dies here. + black_box(&mut accepted_fill); let mut rejected_fill = BlockBuilder::<$sample, $n>::new(); for sequence in 1..$n { @@ -489,6 +493,7 @@ macro_rules! measure_block_shape { .expect("complete"); let _ = black_box(tx.push(block)); m_end(); + black_box(&mut rejected_fill); }}; } diff --git a/scripts/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh index 7c1ccdd..128c9de 100755 --- a/scripts/event-flags-atomic-window.sh +++ b/scripts/event-flags-atomic-window.sh @@ -145,7 +145,7 @@ fi # Both portable paths must remain straight-line. Any branch in a hot function # means the claimed single critical section has acquired a retry/dispatch path. if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/thumbv6m.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*(\.n)?|c(b|bn)z|call[0-9x]*|jx?(\.n)?|loop[a-z]*)[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]]((b|b(eq|ne|cs|hs|cc|lo|mi|pl|vs|vc|hi|ls|ge|lt|gt|le|al)|bl|blx|bx|cbz|cbnz)(\.[nw])?)[[:space:]]'; then printf 'error: thumbv6m EventFlags hot path contains a branch.\n' >&2 exit 1 fi @@ -169,7 +169,7 @@ if [ "$want_esp" -eq 1 ]; then exit 1 fi if sed -n '/:/,/^$/p; /:/,/^$/p' "$tmp_dir/esp32s2.txt" \ - | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]](b[a-z]*(\.n)?|call[0-9x]*|jx?(\.n)?|loop[a-z]*)[[:space:]]'; then + | grep -Eq '^[[:space:]]*[0-9a-f]+:.*[[:space:]]((beq|bne|bge|blt|bgeu|bltu|ball|bnall|bany|bnone|bbc|bbci|bbs|bbsi|beqi|bnei|bgei|blti|bgeui|bltui|beqz|bnez|bgez|bltz)(\.n)?|call[0-9x]*|jx?(\.n)?|loop[a-z]*)[[:space:]]'; then printf 'error: ESP32-S2 EventFlags hot path contains a branch.\n' >&2 exit 1 fi diff --git a/src/block.rs b/src/block.rs index b53af18..5fb85d2 100644 --- a/src/block.rs +++ b/src/block.rs @@ -51,8 +51,8 @@ //! the 8/16-byte `N = 8` corners. For tiny windows, per-sample //! publication through a plain channel may be the cheaper shape. //! -//! **Publication cost scales with block bytes** — 159–8,658 reference -//! instructions across the measured grid — and rejection is within 5–31 +//! **Publication cost scales with block bytes** — 150–8,651 reference +//! instructions across the measured grid — and rejection is within 2–25 //! instructions of acceptance, because the complete rejected block is //! preserved and returned rather than reduced to a scalar error. Budget //! rejection like acceptance, not like error plumbing. diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 55129c2..30d55e4 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -317,35 +317,38 @@ fn latest_buf_empty_fast_path_preserves_concurrent_publication() { fn latest_buf_producer_reacquisition_continues_across_threads() { loom::model(|| { let channel = Arc::new(LatestBuf::::new()); - let first_acquired = Arc::new(AtomicBool::new(false)); + let first_dropped = Arc::new(AtomicBool::new(false)); let first_channel = Arc::clone(&channel); - let first_signal = Arc::clone(&first_acquired); + let first_signal = Arc::clone(&first_dropped); let first = thread::spawn(move || { let producer = first_channel.try_producer().unwrap(); - // Signal before mutating role state. This orders initial role - // selection but deliberately does not publish the continuation - // write; that must travel through handle drop/acquisition. - first_signal.store(true, Ordering::Release); assert_eq!(producer.publish(1).generation, 1); + drop(producer); + // Relaxed deliberately: the role handoff's own Release-drop / + // AcqRel-swap edge must carry the continuation state; this flag + // only sequences the model and supplies no happens-before for it. + first_signal.store(true, Ordering::Relaxed); }); let second = thread::spawn(move || { - if first_acquired.load(Ordering::Acquire) { - channel - .try_producer() - .map(|producer| producer.publish(2).generation) - } else { - None + while !first_dropped.load(Ordering::Relaxed) { + thread::yield_now(); } + // The acquisition swap is an RMW, so it reads the latest + // `producer_taken` value in modification order: after the + // observed drop it succeeds on the first try, and every + // terminating execution completes the cross-context handoff + // (evaluation L3) instead of treating a failed claim as a + // vacuous pass. + let producer = channel + .try_producer() + .expect("role released by the observed drop"); + producer.publish(2).generation }); first.join().unwrap(); - // Loom explores both outcomes: acquisition while the first handle is - // live fails, while acquisition after its Release drop succeeds and - // must observe the channel-resident continuation state. - if let Some(generation) = second.join().unwrap() { - assert_eq!(generation, 2); - } + // Continuation is unconditional: generation resumes, never restarts. + assert_eq!(second.join().unwrap(), 2); }); } @@ -369,6 +372,7 @@ fn latest_buf_consumer_reacquisition_continues_across_threads() { let first = thread::spawn(move || { let consumer = first_channel.try_consumer().unwrap(); assert_eq!(consumer.take_latest().unwrap().generation, 1); + drop(consumer); // Relaxed deliberately: successful reacquisition, not this test // signal, must publish the role-owned continuation state. first_signal.store(true, Ordering::Relaxed); @@ -378,31 +382,40 @@ fn latest_buf_consumer_reacquisition_continues_across_threads() { let publisher_start = Arc::clone(&first_took); let publisher_done = Arc::clone(&later_published); let publisher = thread::spawn(move || { - if publisher_start.load(Ordering::Relaxed) { - let producer = publisher_channel.try_producer().unwrap(); - let _ = producer.publish(2); - let _ = producer.publish(3); - publisher_done.store(true, Ordering::Release); + // Yield-retry until the first take happened: every terminating + // execution publishes generations 2 and 3 into the post-take + // channel state instead of skipping the scenario. + while !publisher_start.load(Ordering::Relaxed) { + thread::yield_now(); } + let producer = publisher_channel.try_producer().unwrap(); + let _ = producer.publish(2); + let _ = producer.publish(3); + publisher_done.store(true, Ordering::Release); }); let second = thread::spawn(move || { - if later_published.load(Ordering::Acquire) { - channel - .try_consumer() - .and_then(|consumer| consumer.take_latest()) - } else { - None + while !later_published.load(Ordering::Acquire) { + thread::yield_now(); } + // The first handle was dropped before this flag chain fired, and + // the acquisition swap is an RMW reading the latest role flag in + // modification order — the handoff completes in every + // terminating execution (evaluation L3), no vacuous branch. + let consumer = channel + .try_consumer() + .expect("role released by the observed drop"); + consumer + .take_latest() + .expect("generation 3 pending after the Release/Acquire handoff") }); first.join().unwrap(); publisher.join().unwrap(); - if let Some(item) = second.join().unwrap() { - assert_eq!(item.generation, 3); - assert_eq!(item.value, 3); - assert_eq!(item.skipped, 1); - } + let item = second.join().unwrap(); + assert_eq!(item.generation, 3); + assert_eq!(item.value, 3); + assert_eq!(item.skipped, 1); }); } From 3ba7cdd335b207bbb78e64b7743443a1804d0571 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 03:30:02 -0400 Subject: [PATCH 77/87] Record headers reflect acceptance: the four 0.3.0 types ship, not await The assembly review noted the engineering-record headers still said candidate/PROPOSED/awaiting-acceptance for types already merged into release/0.3.0 on maintainer acceptance calls. All four status lines now record the acceptance date, the merge, and (for BlockBuf) the promotion-time baseline bless; LatestBuf's lane-resident evidence links are restated as merged. Co-Authored-By: Claude Fable 5 --- docs/records/block-buf.md | 10 +++++----- docs/records/counted-signal.md | 9 +++++---- docs/records/event-flags.md | 8 ++++---- docs/records/latest-buf.md | 9 +++++---- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index 4eef93f..60a1592 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -1,12 +1,12 @@ # BlockBuf — engineering record -- **Status:** candidate, DECISION-COMPLETE — complete development package - on `candidate/block-buf` (draft PR #34); D3 type-identity confirmed - (composition, no `LatestBlockBuf`); **cycle decision P closed as Copy - composition** (2026-08-11, planning-record P/S closure) with the +- **Status:** **ACCEPTED — ships in 0.3.0** (maintainer acceptance + 2026-08-12; PR #34 merged into `release/0.3.0`, with the block + code-size baseline blessed and CI-gated at promotion). D3 + type-identity confirmed (composition, no `LatestBlockBuf`); **cycle + decision P closed as Copy composition** (2026-08-11) with the per-shape measured rows as the budget statement; **S closed as deferred** (SlotPool evidence banked, adopter-gated trigger). - Promotion to PROPOSED awaits the #34 acceptance review. - **Normative sources:** [proposal](../proposals/block-buf.md) (§5–§9 cited below) · LatestBuf [contract §9 D3](../proposals/latest-buf-contract.md) · diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index ffbdc31..600b7e2 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -1,9 +1,10 @@ # CountedSignal — engineering record -- **Status:** candidate, PROPOSED — complete admission package on - `candidate/counted-signal` (PR #33); accepted contract; shared - handle decision H closed on this lane's evidence; MAX short-circuit - exactness fix landed; awaiting acceptance review and release assembly. +- **Status:** **ACCEPTED — ships in 0.3.0** (maintainer acceptance + 2026-08-12; PR #33 merged into `release/0.3.0`). Accepted contract; + shared handle decision H closed on this lane's evidence; the stale-MAX + exactness fix and the sentinel-RMW refinement both landed under + review before acceptance. - **Normative sources:** [contract](../proposals/counted-signal-contract.md) (clause IDs cited below) · [proposal](../proposals/counted-signal.md) · measurements inline in proposal §3.1 (eight-target code size; pinned diff --git a/docs/records/event-flags.md b/docs/records/event-flags.md index 703fdcf..6f2d730 100644 --- a/docs/records/event-flags.md +++ b/docs/records/event-flags.md @@ -1,9 +1,9 @@ # EventFlags — engineering record -- **Status:** candidate, PROPOSED — complete admission package on - `candidate/event-flags` (draft PR #36); contract frozen - (M/R/T/C/S/B/H/W/X); shared handle decision H closed; awaiting - acceptance review and release assembly. +- **Status:** **ACCEPTED — ships in 0.3.0** (maintainer acceptance + 2026-08-12; PR #36 merged into `release/0.3.0`). Contract frozen + (M/R/T/C/S/B/H/W/X); shared handle decision H closed; nine review + rounds converged before acceptance. - **Normative sources:** [contract](../proposals/event-flags-contract.md) (clause IDs cited below) · [proposal](../proposals/event-flags.md) · admission evidence embedded in the proposal (§4) and diff --git a/docs/records/latest-buf.md b/docs/records/latest-buf.md index 8884a51..89a382c 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -1,11 +1,12 @@ # LatestBuf — engineering record -- **Status:** candidate, PROPOSED — complete admission package on - `candidate/latest-buf` (draft PR #35); every design decision closed - (D1–D3, A.1–A.3); awaiting acceptance review and release assembly. +- **Status:** **ACCEPTED — ships in 0.3.0** (maintainer acceptance + 2026-08-12; PR #35 merged into `release/0.3.0`). Every design decision + closed (D1–D3, A.1–A.3); seven review rounds converged before + acceptance. - **Normative sources:** [contract](../proposals/latest-buf-contract.md) (clause IDs cited below) · [proposal](../proposals/latest-buf.md) · - lane-resident until #35 merges (paths become repo-relative then): + merged with #35 (repo-relative on the release branch): [evaluation record](https://github.com/photon-circus/ph-eventing/blob/candidate/latest-buf/docs/proposals/latest-buf-evaluation.md) · [measurements](https://github.com/photon-circus/ph-eventing/blob/candidate/latest-buf/docs/proposals/latest-buf-measurements.md) · [joint composition matrix](https://github.com/photon-circus/ph-eventing/blob/candidate/latest-buf/docs/proposals/latest-block-composition-measurements.md). From 8ce26472b0e3f2fd17c4a749bee90ff6e4d4ea86 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 03:50:56 -0400 Subject: [PATCH 78/87] Keep the consumer-handoff model inside loom's path budget under the gate's preemption bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final gate failed where my local run passed: loom.sh sets LOOM_MAX_PREEMPTIONS=2 and under that bound the four-thread, three-gate consumer-reacquisition model exceeded loom's default per-path branch budget — my local verification ran raw cargo test without the gate's environment, which is the actual lesson here (verify with the gate's own script, not an approximation of it). Raising the budget made exploration crawl, so the fix is structural: one thread now plays publisher and reacquiring consumer, keeping a single Relaxed spin gate. The consumer handoff still travels only through the taken flag's Release-drop/AcqRel-swap edge (the Relaxed gate supplies no happens-before, and the acquisition swap is an RMW reading the latest role flag), every terminating execution completes the handoff, and skipped == 1 still discriminates continuation from restart (a reset consumer would report 2 — mutation-verified). ./scripts/loom.sh — the gate's exact configuration — passes 17/17 in seconds. Co-Authored-By: Claude Fable 5 --- src/loom_tests.rs | 52 ++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/loom_tests.rs b/src/loom_tests.rs index 30d55e4..e2f020a 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -364,55 +364,51 @@ fn latest_buf_consumer_reacquisition_continues_across_threads() { assert_eq!(producer.publish(1).generation, 1); } - let first_took = Arc::new(AtomicBool::new(false)); - let later_published = Arc::new(AtomicBool::new(false)); + let first_dropped = Arc::new(AtomicBool::new(false)); let first_channel = Arc::clone(&channel); - let first_signal = Arc::clone(&first_took); + let first_signal = Arc::clone(&first_dropped); let first = thread::spawn(move || { let consumer = first_channel.try_consumer().unwrap(); assert_eq!(consumer.take_latest().unwrap().generation, 1); drop(consumer); - // Relaxed deliberately: successful reacquisition, not this test - // signal, must publish the role-owned continuation state. + // Relaxed deliberately: the role handoff's own Release-drop / + // AcqRel-swap edge must carry the continuation state; this flag + // only sequences the model and supplies no happens-before for it. first_signal.store(true, Ordering::Relaxed); }); - let publisher_channel = Arc::clone(&channel); - let publisher_start = Arc::clone(&first_took); - let publisher_done = Arc::clone(&later_published); - let publisher = thread::spawn(move || { - // Yield-retry until the first take happened: every terminating - // execution publishes generations 2 and 3 into the post-take - // channel state instead of skipping the scenario. - while !publisher_start.load(Ordering::Relaxed) { - thread::yield_now(); - } - let producer = publisher_channel.try_producer().unwrap(); - let _ = producer.publish(2); - let _ = producer.publish(3); - publisher_done.store(true, Ordering::Release); - }); - + // One thread plays publisher and reacquiring consumer so the model + // keeps a single spin gate (more threads/gates exceeded loom's + // per-path branch budget under the gate's preemption bound). The + // consumer handoff still travels only through the taken flag's + // Release-drop / AcqRel-swap edge: the Relaxed gate gives no + // happens-before, and the acquisition swap is an RMW reading the + // latest role flag in modification order, so every terminating + // execution completes the cross-context handoff (evaluation L3). let second = thread::spawn(move || { - while !later_published.load(Ordering::Acquire) { + while !first_dropped.load(Ordering::Relaxed) { thread::yield_now(); } - // The first handle was dropped before this flag chain fired, and - // the acquisition swap is an RMW reading the latest role flag in - // modification order — the handoff completes in every - // terminating execution (evaluation L3), no vacuous branch. + { + let producer = channel.try_producer().unwrap(); + let _ = producer.publish(2); + let _ = producer.publish(3); + } let consumer = channel .try_consumer() .expect("role released by the observed drop"); consumer .take_latest() - .expect("generation 3 pending after the Release/Acquire handoff") + .expect("generation 3 pending in this thread's program order") }); first.join().unwrap(); - publisher.join().unwrap(); let item = second.join().unwrap(); + // skipped == 1 discriminates continuation from restart: a consumer + // whose channel-resident state reset would report skipped == 2 + // (distance from generation 0), a continuation from the taken + // generation 1 reports exactly one displaced publication. assert_eq!(item.generation, 3); assert_eq!(item.value, 3); assert_eq!(item.skipped, 1); From 9863d3a1fae881581d570130bc8a3eefdfee30f3 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 04:02:54 -0400 Subject: [PATCH 79/87] Documentation freshness pass: records current, 0.3.0 value statement, no stale claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of every doc surface against the verified tree: - The 0.3.0 changelog opens with its value statement, mirroring 0.2.0's convention: four primitives held to the contract/record/Loom/Miri/ measurement standard, the try_*-only breaking change, and the crate's identity — every guarantee stated with the boundary where it stops holding, so integrators can decide against a type with full information. Known issues gains the BlockBuilder span-alias boundary alongside the SeqRing and LatestBuf span items. - Records brought current with the review rounds: event-flags' cost row carries the assembled-tree take (10, state pairs enforced as a cycles gate) and the per-ISA RMW scoping, its review history reads as the completed path to acceptance, and both destructive-take records note the deliberately opaque Debug; latest-buf gains the per-report P4 claim row (per-call correlation + independent conservation, mutation-verified); tracking lines say merged, not draft. - Test totals restated at the measured 103 (README, AGENTS); LatestBuf's module doc describes the shipped channel, not a prototype. Remaining per-lane totals and the SlotPool draft-PR mention sit inside explicit historical framing and are accurate as history. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 +- CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 2 +- docs/records/block-buf.md | 2 +- docs/records/counted-signal.md | 6 +++++- docs/records/event-flags.md | 18 ++++++++++-------- docs/records/latest-buf.md | 1 + src/latest_buf.rs | 2 +- 8 files changed, 44 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de00c1d..6046a11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1046,7 +1046,7 @@ cargo test `SeqRing`), and one ordinary example each in `src/ring.rs`, `src/event_buf.rs`, `src/latest_buf.rs`, `src/block.rs`, `src/event_flags.rs`, and `src/traits.rs` (`src/counted_signal.rs` carries only its two `compile_fail` pins, no ordinary -example). Total: 101 unit tests + 13 doctests, plus 11 `compile_fail` doctests: the +example). Total: 103 unit tests + 13 doctests, plus 11 `compile_fail` doctests: the `N == 0` rejection (`E0080`) on the three ring types and `BlockBuilder`, the deliberately absent `Source` impl on `LatestBuf`'s consumer (`E0277`, decision D2 — the pin keeps a convenience impl from arriving silently), two diff --git a/CHANGELOG.md b/CHANGELOG.md index 300fec4..0ddcf9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. ## 0.3.0 - 2026-08-12 +**What this release delivers.** 0.2.0 made the costs measurable; 0.3.0 grows +the vocabulary and holds every new word to the same standard. Four primitives +ship — `LatestBuf` (freshness-first snapshot with loss evidence), +`Block`/`BlockBuilder` (complete contiguous sample windows, composed with the +transport you choose rather than adding a queue policy), `EventFlags` +(coalesced ISR-to-task conditions), and `CountedSignal` (exact saturating +count) — each with a frozen clause contract, an enduring engineering record, +exhaustive Loom models, Miri coverage, and code-size and instruction +measurements gated on the assembled release tree. The one breaking change is +the scheduled removal of the panicking constructors: `try_*` is now the only +acquisition API. And the crate stays deliberately paranoid: every guarantee +is stated with the boundary where it stops holding — the counter-width span +limits on `SeqRing` accounting, `LatestBuf::skipped`, and `BlockBuilder` +contiguity; the seqlock's formal data race, now with a reproducible model +witness; bounded polling under continuous overwrite; and source-level +boundedness with each ISA's realisation disclosed — so an integrator can +decide *against* a type with full information instead of discovering the +edge in the field. + ### Added - `LatestBuf` — a three-slot, freshness-first SPSC snapshot channel for state where the newest value beats FIFO delivery. Publication is one bounded @@ -189,6 +208,11 @@ All notable changes to this project will be documented in this file. - `LatestBuf::skipped` is exact only while the consumer's resume generation stays within one non-zero `u32` span of the newest publication (contract non-promise X6); a full-cycle gap can also report `skipped = 0`. +- `BlockBuilder` contiguity shares the same counter-width boundary: the check + compares `u32` sequence identity only, so a partial builder held across + exactly one whole omitted span accepts the recurring sequence as contiguous + (F2 span non-promise; the block module docs carry the reachability + arithmetic and the `clear()`-on-outage recovery guidance). ## 0.2.0 - 2026-08-10 diff --git a/README.md b/README.md index 2836900..ef2b557 100644 --- a/README.md +++ b/README.md @@ -475,7 +475,7 @@ on ARM and RISC-V. What backs this crate, in descending order of strength: |----------|---------------------| | [Loom](https://github.com/tokio-rs/loom) models | Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size | | [Miri](https://github.com/rust-lang/miri) | UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets | -| 101 unit + 13 doctests + 11 compile-fail | Behaviour, including threaded stress tests for all SPSC types; `N == 0` rejected at compile time on the three buffers and `BlockBuilder`; `LatestBuf`'s absent `Source` impl and handle `!Sync` pinned (D2/H2); CountedSignal and EventFlags handle `!Sync` pinned | +| 103 unit + 13 doctests + 11 compile-fail | Behaviour, including threaded stress tests for all SPSC types; `N == 0` rejected at compile time on the three buffers and `BlockBuilder`; `LatestBuf`'s absent `Source` impl and handle `!Sync` pinned (D2/H2); CountedSignal and EventFlags handle `!Sync` pinned | | 3 embedded targets | `thumbv6m` / `thumbv7em` / `riscv32imac` compile checks | | Code-size baseline | Flash cost gated in CI across 8 pinned targets; growth past +16 bytes fails | | QEMU instruction counts | Hot-path cost is constant w.r.t. occupancy, measured per instruction | diff --git a/docs/records/block-buf.md b/docs/records/block-buf.md index 60a1592..d52f13c 100644 --- a/docs/records/block-buf.md +++ b/docs/records/block-buf.md @@ -160,4 +160,4 @@ closed, BlockBuf has no remaining gate but the #34 acceptance review. matrix `bc54a9a`: 18 cycle rows, 8-target flash); joint D3 matrix on the LatestBuf lane (`latest-block-composition-measurements.md`, `8593b4a`, PR #35); proposal §6 / §6.1 (cost model and decision rule); -tracking: issue #26 decision P, draft PR #34, contract PR #37. +tracking: issue #26 decision P, PR #34 (merged), contract PR #37. diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md index 600b7e2..12292f3 100644 --- a/docs/records/counted-signal.md +++ b/docs/records/counted-signal.md @@ -49,6 +49,9 @@ IDs in parentheses; the clauses are the normative statements. contract, algorithm, and evidence case — not a weakened reading of this one. `&self` on the hot path is a receiver choice; it does not permit sharing a handle (`Send + !Sync` remains). +- **No non-clearing observation, by design.** `take_count` is the only + read and it clears; `Debug` is deliberately opaque — printing the live + count would be an advisory peek without the take's snapshot semantics. - **No payload, no per-occurrence identity (X1, X2).** Only the count in a take interval is retained. Ordering between individual increments is intentionally absent; unrelated data gains no publication fence @@ -137,7 +140,8 @@ in contract §8 and proposal §3.1–§3.2):** own caller and evidence. Width genericity would create target-dependent contracts. -**Review history:** issue #30 closed with the lane PROPOSED; PR #33 +**Review history** *(the lane's path to acceptance)*: issue #30 closed +with the lane PROPOSED; PR #33 carries the admission package. Bugbot high finding (stale MAX load drops increments) agreed and fixed with a sentinel re-read + Loom litmus — first as a bounded CAS, then (Codex P1) as the no-op RMW after review showed the diff --git a/docs/records/event-flags.md b/docs/records/event-flags.md index 6f2d730..ff7e155 100644 --- a/docs/records/event-flags.md +++ b/docs/records/event-flags.md @@ -87,7 +87,7 @@ IDs in parentheses; the clauses are the normative statements. | Raise unions into pending; take returns-and-clears exactly (M1, R1–R2, T1–T2, C1, C3) | Unit set: empty/all masks, bit 31, duplicate coalesce, multi-bit round-trip, empty raise/take no-ops | Pinned | | Racing raise partitions across take windows; never erased (C1–C3) | Loom `event_flags_raise_racing_take_is_partitioned_exactly` and `event_flags_distinct_raises_partition_across_takes`; native/Miri threaded stress | Proven | | Observed raise publishes prior application memory (S1) | Loom `event_flags_observed_raise_publishes_payload`; fails when either Release or Acquire is independently weakened to Relaxed | Proven | -| One `fetch_or` / one `swap(0)`; no CAS loop, no history-proportional work (B1–B3) | Source review; Cortex-M3 QEMU: raise **12** / take **8** instructions whether empty or set (constant w.r.t. occupancy and set-bit count) | Measured | +| One source-level `fetch_or` / one `swap(0)`; no algorithmic retry, no history-proportional work — per-ISA the single RMW may be a contention-bounded LDREX/STREX pair (B1–B3) | Source review; Cortex-M3 QEMU on the assembled 0.3.0 tree: raise **12** / take **10** instructions whether empty or set (constant w.r.t. occupancy and set-bit count — the state pairs are enforced as a cycles gate; take was 8 on the per-lane tree, merged-binary codegen) | Measured, gated | | Portable-atomic ISR windows stay small on constrained targets | `event-flags-atomic-window.sh`: thumbv6m **4** always gated in `verify.sh`; ESP32-S2 **5** / ESP32-S3 **0** (native `s32c1i`) opt-in via `ESP=1`; one masked RMW, no branch/CAS loop | Measured | | Sole-role `Send + !Sync` handles; reacquisition continues pending state (H1–H4) | Exclusivity/reuse unit test; `Send` + container-`Sync` type tests; producer/consumer `compile_fail` doctests pin `!Sync`; `const_new_works_in_static_context` | Proven | | Transparent panic-free 32-bit mask (W1–W2) | `EventMask` four-byte transparent layout; `from_index` returns `None` outside `0..32` (no shift panic) | Pinned | @@ -122,7 +122,9 @@ issue #30):** `u64` is not one native operation on the shipped 32-bit targets. - **D4 — no non-clearing peek.** An advisory snapshot invites check-then-act reasoning the concurrent take/raise pair cannot uphold; - observation is destructive `take_all` by design. + observation is destructive `take_all` by design — `Debug` is + deliberately opaque for the same reason (printing the pending mask + would be the advisory peek the frozen API rejects). - **D5 — no stream `Sink`/`Source`/`Link`; signal traits deferred.** Coalesced state is not an item stream. CountedSignal's increment/count-snapshot vocabulary disproves the original single @@ -133,14 +135,14 @@ issue #30):** (portable-atomic ISR latency on thumbv6m / ESP32-S2/S3) were sequenced behind shared decision H; once H closed, the contract freeze, secondary confirmations (width, no-peek, trait posture), and the interrupt-window -campaign landed together as the complete admission package. Draft PR #36 -carries the package for acceptance review; issue #29 is closed as -PROPOSED — ready for candidate evaluation, not yet acceptance into the -release. Evaluation may still reject the primitive on API fit or -measured cost, with the evidence retained either way. +campaign landed together as the complete admission package. PR #36 then +carried the package through nine review rounds to convergence and the +maintainer's acceptance (2026-08-12, merged into `release/0.3.0`); +issue #29 closed at PROPOSED and carries the post-closure corrections +the reviews produced. **Where the numbers live:** proposal §4 (behaviour/Loom/Miri, 11-target code size, pinned Cortex-M3 cycles beside CountedSignal's 8/7 anchor, portable-atomic interrupt windows); `scripts/event-flags-atomic-window.sh` -(disassembly probe); tracking: issues #26/#29/#30, draft PR #36; +(disassembly probe); tracking: issues #26/#29/#30, PR #36 (merged); implementation freeze commit `e9859d4`, handle decision `f26d4c3`. diff --git a/docs/records/latest-buf.md b/docs/records/latest-buf.md index 89a382c..54a9aa0 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -92,6 +92,7 @@ IDs in parentheses; the clauses are the normative statements. | No torn or mixed value — one-publication Rust value semantics (P6/C6) | Threaded patterned-payload stress; detector-on Miri | Proven | | Wait-free producer, bounded consumer, no CAS loop (B1–B3) | Algorithm review (no loops); pinned QEMU cycle regions constant w.r.t. lag and occupancy | Measured | | Orderings are necessary, not decorative | Mutation runs: all 6 exchange and 4 role-handoff weakenings detected (8 by Loom; 2 relinquish-only mutations by Miri, seeds 53/47) | Proven | +| `replaced_unread` is per-call truth, not an aggregate (P4) | Loom: every report correlated with its predecessor's fate (taken/pending ⇒ not replaced) in every interleaving, plus the aggregate taken/displaced/pending conservation as an independent check; mutation-verified both ways; deterministic per-publish asserts in the handshake model | Proven | | Exact skipped accounting within one wrap span (C3, G1–G3) | Wrap-boundary unit set mirroring `SeqRing`'s `seq_distance` tests; full-cycle pin (`full_generation_cycle_uses_documented_approximation`) | Pinned | | Reacquisition continues, never restarts (H4) | Drop-and-reacquire continuation tests; both cross-context role-handoff Loom models; detector-on Miri | Proven | | Empty poll costs one `Acquire` load, no RMW (A.1) | Loom equivalence model; measured: 7–8 instructions off empty polls, +5–6 on pending Cortex-M3 paths | Measured, selected | diff --git a/src/latest_buf.rs b/src/latest_buf.rs index 3afcb7b..7bb095a 100644 --- a/src/latest_buf.rs +++ b/src/latest_buf.rs @@ -5,7 +5,7 @@ //! [`PublishReport::replaced_unread`] reports it. The consumer takes only the //! latest value and receives its generation plus the exact number skipped. //! -//! The prototype uses three slots with exclusive ownership: one producer +//! The channel uses three slots with exclusive ownership: one producer //! slot, one consumer slot, and one slot named by an atomic exchange state. //! An endpoint accesses a slot only after acquiring it through a single //! atomic swap. Producer and consumer therefore never touch the same payload From 8c51336542b1580dcdfe2e3fb4365a6a570373a2 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 04:12:22 -0400 Subject: [PATCH 80/87] Proposal documents speak as closed design-decision records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design proposals and contracts still carried pre-acceptance status language — 'exploratory design proposal', 'draft for review', 'frozen for evaluation', 'PROPOSED, ready for evaluation', 'promotion awaits the acceptance review'. Every status header now states the decision outcome (shipped in 0.3.0 with the acceptance date and merging PR, or deferred with its trigger for SlotPool), keeps the prior statuses as history where they explain the document's shape, and names the role split explicitly: the proposal/contract is the design-decision record — what was decided, why, and what was rejected — while the enduring engineering briefing lives in docs/records/.md. The taxonomy document records the cycle outcome for the candidates it seeded, and the LatestBuf evaluation notes its surviving-artifact purpose. The contracts' clause text is untouched — only identity lines changed. Co-Authored-By: Claude Fable 5 --- docs/proposals/block-buf.md | 11 +++++++---- docs/proposals/counted-signal-contract.md | 5 ++++- docs/proposals/counted-signal.md | 9 ++++++--- docs/proposals/event-flags-contract.md | 5 ++++- docs/proposals/event-flags.md | 7 +++++-- docs/proposals/exploratory-primitives.md | 10 +++++++--- docs/proposals/latest-buf-contract.md | 9 ++++++--- docs/proposals/latest-buf-evaluation.md | 5 ++++- docs/proposals/latest-buf.md | 10 ++++++++-- 9 files changed, 51 insertions(+), 20 deletions(-) diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index a02c628..03accba 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -1,9 +1,12 @@ # BlockBuf: complete window handoff (exploratory design document) -- **Status:** DECISION-COMPLETE (2026-08-11) — D3 confirmed (composition) - and cycle decision **P** closed as **Copy composition**; every input to - promotion is settled, and promotion to PROPOSED awaits the #34 - acceptance review. Prior status: EXPLORATORY. +- **Status:** **SHIPPED in 0.3.0** — D3 confirmed (composition, no + `LatestBlockBuf`), cycle decision **P** closed as **Copy composition**, + accepted 2026-08-12 and merged via PR #34 with the block code-size + baseline blessed at promotion. Prior statuses: EXPLORATORY, then + DECISION-COMPLETE (2026-08-11). This document remains the design-decision record — what was decided, + why, and what was rejected; the enduring engineering briefing lives in + [`records/block-buf.md`](../records/block-buf.md). - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §2 (received 2026-08-11); triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md index 06c9823..9250a9f 100644 --- a/docs/proposals/counted-signal-contract.md +++ b/docs/proposals/counted-signal-contract.md @@ -1,6 +1,9 @@ # CountedSignal semantic contract -- **Status:** Frozen for evaluation on the `candidate/counted-signal` lane. +- **Status:** **Normative for the shipped 0.3.0 type** — frozen on the + `candidate/counted-signal` lane and carried unchanged through acceptance + (PR #33, merged 2026-08-12); clause IDs are load-bearing for tests, + models, and the [engineering record](../records/counted-signal.md). Promotion-bar item 3 in [`counted-signal.md`](counted-signal.md) §4 is complete. - **Rule of this document:** the clauses describe an **abstract counted diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index 6aba555..9a59540 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -1,7 +1,10 @@ # CountedSignal: multiplicity without payloads -- **Status:** PROPOSED — shared handle decision, contract, and admission - evidence complete; ready for evaluation. +- **Status:** **SHIPPED in 0.3.0** — shared handle decision, frozen + contract, and admission evidence complete; accepted 2026-08-12 and merged + via PR #33. This document remains the design-decision record — what was decided, + why, and what was rejected; the enduring engineering briefing lives in + [`records/counted-signal.md`](../records/counted-signal.md). - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §5 (received 2026-08-11); triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. @@ -214,4 +217,4 @@ type's proof. no-lost-increment accounting, saturation without wrapping, and the post-take stale-`MAX` litmus. -The promotion bar is complete; the candidate is ready for evaluation. +The promotion bar was completed, the candidate passed evaluation and nine review rounds, and the type shipped in 0.3.0 (PR #33, accepted 2026-08-12). diff --git a/docs/proposals/event-flags-contract.md b/docs/proposals/event-flags-contract.md index 19a97bf..2782bf3 100644 --- a/docs/proposals/event-flags-contract.md +++ b/docs/proposals/event-flags-contract.md @@ -1,6 +1,9 @@ # EventFlags semantic contract -- **Status:** Frozen for evaluation on the `candidate/event-flags` lane. +- **Status:** **Normative for the shipped 0.3.0 type** — frozen on the + `candidate/event-flags` lane and carried unchanged through acceptance + (PR #36, merged 2026-08-12); clause IDs are load-bearing for tests, + models, and the [engineering record](../records/event-flags.md). - **Rule of this document:** these clauses describe an abstract coalescing condition set independently of atomics, memory orderings, or the candidate algorithm. The design and evidence must satisfy the clauses; they do not diff --git a/docs/proposals/event-flags.md b/docs/proposals/event-flags.md index b64b016..8a03a56 100644 --- a/docs/proposals/event-flags.md +++ b/docs/proposals/event-flags.md @@ -1,7 +1,10 @@ # EventFlags: coalesced condition notification -- **Status:** PROPOSED — decisions, frozen contract, implementation, and - admission evidence complete; ready for evaluation. +- **Status:** **SHIPPED in 0.3.0** — decisions, frozen contract, + implementation, and admission evidence complete; accepted 2026-08-12 and + merged via PR #36. This document remains the design-decision record — what was decided, + why, and what was rejected; the enduring engineering briefing lives in + [`records/event-flags.md`](../records/event-flags.md). - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §4; triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. diff --git a/docs/proposals/exploratory-primitives.md b/docs/proposals/exploratory-primitives.md index 4f8b922..a08ff0a 100644 --- a/docs/proposals/exploratory-primitives.md +++ b/docs/proposals/exploratory-primitives.md @@ -1,8 +1,12 @@ # Exploratory Primitives: A Bounded-Handoff Taxonomy for ph-eventing -- **Status:** Exploratory design foundation — deliberately less detailed than - the [`LatestBuf` proposal](latest-buf.md); these are candidates for - triage and exploration, not designs ready for a contract. +- **Status:** Taxonomy, outcome recorded — this document seeded the 0.3.0 + candidate lanes and remains the admission framework (the four questions, + the tiering, the standing filters). Cycle outcome: `EventFlags`, + `CountedSignal`, and the Block/BlockBuilder composition **shipped in + 0.3.0**; `SlotPool` was evaluated in full and **deferred** by decision S + with an adopter-gated reopening trigger ([slot-pool.md](slot-pool.md)); + `LatestBuf` shipped from its own proposal. - **Received:** 2026-08-11 (0.3.0 cycle; triage in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4) - **Structure note (2026-08-11):** the substantive design text for the diff --git a/docs/proposals/latest-buf-contract.md b/docs/proposals/latest-buf-contract.md index 83d118e..8ed6b40 100644 --- a/docs/proposals/latest-buf-contract.md +++ b/docs/proposals/latest-buf-contract.md @@ -1,7 +1,10 @@ -# LatestBuf semantic contract (draft) +# LatestBuf semantic contract -- **Status:** Draft for review — step 1 of the adoption sequence in - [`latest-buf.md`](latest-buf.md) §21. +- **Status:** **Normative for the shipped 0.3.0 type** — clauses frozen, + every decision point closed (§9), evidence map complete. Drafted as step 1 + of the adoption sequence in [`latest-buf.md`](latest-buf.md) §21; the + clause IDs below are load-bearing for tests, models, and the + [engineering record](../records/latest-buf.md). - **Rule of this document:** the contract is written against an **abstract channel**, independently of any implementation. Nothing here may mention slots, atomics, orderings, or ownership transfer — those belong to the diff --git a/docs/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md index f94c25e..8042d76 100644 --- a/docs/proposals/latest-buf-evaluation.md +++ b/docs/proposals/latest-buf-evaluation.md @@ -1,6 +1,9 @@ # LatestBuf implementation evaluation -- **Purpose:** implementation-independent comparison record for issue #27. +- **Purpose:** implementation-independent comparison record for issue #27 + (outcome: the channel-resident/stateless-handle design below shipped in + 0.3.0; persist-on-drop was considered and not selected — §7 is its + surviving artifact). - **Inputs:** [`latest-buf.md`](latest-buf.md), [`latest-buf-contract.md`](latest-buf-contract.md), and the BlockBuf candidate. - **Status:** decision ready. Soundness, target/payload cost, A.1, diff --git a/docs/proposals/latest-buf.md b/docs/proposals/latest-buf.md index 545d16d..65026bf 100644 --- a/docs/proposals/latest-buf.md +++ b/docs/proposals/latest-buf.md @@ -1,9 +1,15 @@ # Proposal: Freshness-First SPSC Transfer Primitives for ph-eventing -- **Status:** Exploratory design proposal +- **Status:** **SHIPPED in 0.3.0** — every design decision closed (D1–D3, + A.1–A.3; closure text in the [contract](latest-buf-contract.md) §9 and + Appendix A.3 here), accepted 2026-08-12 and merged via PR #35. + This document remains the design-decision record — what was decided, + why, and what was rejected; the enduring engineering briefing lives in + [`records/latest-buf.md`](../records/latest-buf.md). - **Scope:** Additive primitives and traits only - **Compatibility:** No changes to existing `Sink`, `Source`, or `Link` traits -- **Implementation status:** Implemented on `candidate/latest-buf` (PR #35) +- **Implementation status:** shipped (implemented on `candidate/latest-buf`, + merged into `release/0.3.0` via PR #35) - **Received:** 2026-08-11 (0.3.0 cycle; see `docs/0.3.0-candidates.md` §3) ## 1. Summary From 0114394e134c53836a41da9d28d7c697ee0c1d39 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 04:26:27 -0400 Subject: [PATCH 81/87] Final freshness sweep: AGENTS.md and CONTRIBUTING.md current with the 0.3.0 tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md: the codebase-structure tree now shows the repository that exists — src/block.rs, the cycles/verify/probes script workspaces, the per-mode baseline gate files, and the docs/ split between engineering records and design-decision documents; the SeqRing polling-modes list states the frozen entry-sample window and its bound; the key-invariants list gains the do-not-reintroduce-a-live-newest-re-read guardrail; the per-type test inventories include the two new pins (frozen poll window, F2 span-alias policy); the isolated-cycles section names all three matrix modes plus the probe-layout sensitivity lesson; and the constant-push result names the three push-bearing types precisely with LatestBuf's publish constancy alongside. CONTRIBUTING.md: the codesize and cycles instructions list all four modes with the per-mode baseline gating stated; the concurrency-change rule names all five concurrent primitives instead of two; the QEMU determinism note drops the stale eighteen-region count and adds the assembled-tree context; and the verify.sh description matches what the full matrix actually runs. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 46 ++++++++++++++++++++++++++++++++++++---------- CONTRIBUTING.md | 31 ++++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6046a11..1d6e18c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,16 +139,26 @@ ph-eventing/ │ ├── ci.sh # Local CI matrix (Git Bash on Windows) │ ├── miri.sh # Miri UB/concurrency checks │ ├── loom.sh # Loom model checking -│ ├── codesize.sh # Per-target flash cost across 11 embedded targets +│ ├── codesize.sh # Per-target flash cost; default + block/latest/latest-block matrix modes +│ ├── cycles.sh # QEMU instruction-cost probe (same four modes) +│ ├── verify.sh # Runs everything inside the pinned reference image │ ├── event-flags-atomic-window.sh # EventFlags interrupt-mask disassembly gate -│ └── codesize/ # no_std probe crate it measures (own workspace) +│ ├── codesize/ # no_std probe crate (own workspace; baseline*.tsv gate files) +│ ├── cycles/ # QEMU probe crate (own workspace) +│ ├── probes/ # shared probe sources (block_shape.rs structural twin) +│ └── verify/ # Dockerfile pinning the reference environment ├── build.rs # guards the mutually exclusive portable-atomic features ├── .github/ # CI workflow (push + PR), issue/PR templates, CODEOWNERS, dependabot ├── deny.toml # cargo-deny policy: advisories, licences, bans, sources ├── RELEASING.md # release checklist (version choice, verification, publish, yank) +├── docs/ +│ ├── records/ # engineering records: the enduring per-type briefing layer +│ ├── proposals/ # design-decision documents: proposals, frozen contracts, measurements +│ └── 0.3.0-candidates.md # the 0.3.0 cycle's planning record (historical) └── src/ ├── lib.rs # Crate root, public exports, doctests ├── macros.rs # static_spsc! -- declarative static bring-up + ├── block.rs # Block / BlockBuilder complete contiguous sample windows ├── event_buf.rs # Bounded SPSC event buffer with backpressure ├── counted_signal.rs # Saturating payload-free SPSC counter ├── event_flags.rs # Coalesced SPSC condition notification @@ -157,7 +167,7 @@ ph-eventing/ ├── seq_ring.rs # Lock-free SPSC overwrite ring with sequence tracking ├── sync.rs # Atomic/cell shim — swaps in Loom's primitives under --cfg loom ├── loom_tests.rs # Exhaustive concurrency models (--cfg loom only) - └── traits.rs # Sink, Source, Link traits and forward() utility + └── traits.rs # Sink/Source/Link and LatestSink/LatestSource traits, forward() ``` ## Architecture @@ -381,7 +391,11 @@ Changes that must land together, none of which the compiler enforces: - `poll_one(hook)` - Drain one item in-order - `poll_one_value()` - Same, returning `Option<(u32, T)>` -- `poll_up_to(max, hook)` - Drain up to N items in-order +- `poll_up_to(max, hook)` - Drain up to `max` items in-order from the window + that existed at entry: the newest sequence is sampled **once** and frozen as + the drain goal, which is what bounds every call (one lag-recovery jump + + at most `N` slot walks + at most `max` reads); items published mid-poll + wait for the next call - `latest(hook)` / `latest_value()` - Read newest item (not in-order, doesn't advance cursor) - `skip_to_latest()` - Fast-forward to newest, skip backlog @@ -752,15 +766,21 @@ environment*, not universal: a 10.2 QEMU build shifted two measured regions by exactly one instruction (trace boundary attribution, not codegen). Cross-environment diffs of ±1 are noise; compare inside the image. -The BlockBuf candidate's large-payload mode is isolated from the standard -probe so its monomorphisations cannot perturb the standard LTO decisions: +The large-payload matrix modes are isolated from the standard probe so their +monomorphisations cannot perturb the standard LTO decisions: ```bash -./scripts/cycles.sh block-matrix -./scripts/verify.sh cycles block-matrix # pinned reference environment +./scripts/cycles.sh block-matrix # Block completion + publication shapes +./scripts/cycles.sh latest-matrix # LatestBuf payload matrix +./scripts/cycles.sh latest-block-matrix # LatestBuf/Block D3 composition +./scripts/verify.sh cycles # any of them, pinned reference environment ``` -Run the default probe as well when changing shared measurement infrastructure. +`./scripts/verify.sh` (the full matrix) runs all four modes. Run the default +probe as well when changing shared measurement infrastructure. Probe layout is +measurement-sensitive: keep a new region in its own `#[inline(never)]` frame +and diff the full row output against the pristine tip (a shared frame was +measured to perturb a neighbouring region by +2). | | empty | loaded | rejected/empty | |---|---:|---:|---:| @@ -801,7 +821,7 @@ with `./scripts/verify.sh cycles latest-block-matrix`; its 2/8/16-byte by Four results carry the argument: 1. **Every `push` is constant.** Empty vs loaded differs by at most one - instruction on all three types. Cost does not scale with occupancy or `N`. + instruction on all three push-bearing types (LatestBuf publish shows the same constancy, 41/40). Cost does not scale with occupancy or `N`. 2. **`SeqRing` lag recovery is O(1) in the lag.** A consumer 2×N behind and one ~2000 behind both cost **90 instructions** (re-measured after the 0.3.0 bounded-poll fix froze the drain goal at entry; it was 115 with the @@ -945,6 +965,7 @@ cargo test - `works_without_default_bound` -- `T: Copy` only, no `Default` - `default_and_capacity_match_new` -- constructor/default introspection - `event_buf_composition_queues_and_returns_a_rejected_block` -- composed FIFO/rejection policy +- `discontinuity_check_is_modular_over_the_span` -- the F2 span-alias policy pin: contiguity compares `u32` identity only **`ring::tests`:** - `new_ring_is_empty` — Fresh ring state @@ -1010,6 +1031,7 @@ cargo test - `read_seq_inner_rejects_invalidated_slot` — Slot invalidated mid-overwrite reads as absent - `read_seq_inner_detects_overwrite_during_read` — The `TEST_AFTER_READ_*` hook changes the slot seq between the copy and the re-check; the read is discarded - `consumer_skips_reserved_seq_zero_on_wrap` — Sequence wrap skips reserved `0` +- `poll_window_is_frozen_at_entry` — The bounded-poll pin: a publish from inside the hook waits for the next call, nothing lost or double-counted - `push_wraps_seq_from_zero_to_one` — Producer side of the same wrap: `next_seq = u32::MAX` yields `1`, not `0` - `lag_across_wrap_counts_drops_exactly` — Drop accounting across the `u32` wrap - `seq_distance_skips_the_reserved_zero` — Sequence distance excludes reserved `0` @@ -1133,6 +1155,10 @@ The project supports these targets (defined in `rust-toolchain.toml`): - `EventBuf`: `head.wrapping_sub(tail)` always represents the item count - `EventBuf`: `len()` never exceeds `N` and never blocks, even while both handles are active - `SeqRing`: `dropped_accum` saturates — it must never overflow, and `usize` is 32-bit on every shipped target +- `SeqRing`: `poll_up_to` freezes its drain goal at entry — the frozen window is + what bounds every call. Do not reintroduce a live `newest` re-read into the + loop: the pre-0.3.0 loop did exactly that and could be starved indefinitely + by a producer that stayed ahead - `EventBuf`: Producer and Consumer handles are `Send + !Sync` - `CountedSignal`: Producer and Consumer handles are `Send + !Sync`; producer exclusivity is load-bearing for wrap-free saturation (B1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73bfd98..5114a0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,8 +87,9 @@ decision, not a formality. ### Concurrency changes -If you touch atomics, orderings, fences, or unsafe blocks in `SeqRing` or -`EventBuf`, `cargo test` passing is not evidence — a strongly-ordered x86 host +If you touch atomics, orderings, fences, or unsafe blocks in any concurrent +primitive — `SeqRing`, `EventBuf`, `LatestBuf`, `EventFlags`, or +`CountedSignal` — `cargo test` passing is not evidence — a strongly-ordered x86 host cannot exhibit the bugs that appear on ARM and RISC-V. Run both checkers: ```bash @@ -129,10 +130,17 @@ If you change an API shape that embedded callers reach for -- especially anything touching atomics -- measure it before arguing about it: ```bash -./scripts/codesize.sh # 8 upstream targets -XTENSA=1 ./scripts/codesize.sh # plus ESP32, needs the esp-rs fork +./scripts/codesize.sh # 8 upstream targets, default API rows +./scripts/codesize.sh block-matrix # Block completion/publication shapes +./scripts/codesize.sh latest-matrix # LatestBuf payload matrix +./scripts/codesize.sh latest-block-matrix # LatestBuf/Block composition matrix +XTENSA=1 ./scripts/codesize.sh # plus ESP32, needs the esp-rs fork ``` +Each mode gates against its own committed `baseline*.tsv` and `ci.sh` runs all +of them — growth past the tolerance fails, and a re-bless is a deliberate, +reviewed act, never a side effect. + A design that looks cheaper on Cortex-M4 can be markedly worse on Cortex-M0+, where portable-atomic turns each read-modify-write into an interrupt-disable critical section, or on RISC-V, where `compare_exchange` lowers to an LR/SC @@ -146,7 +154,10 @@ that `rust-toolchain.toml` already declares. If you change a hot path, measure the time cost as well as the size cost: ```bash -./scripts/cycles.sh +./scripts/cycles.sh # default hot-path regions +./scripts/cycles.sh block-matrix # and the three matrix modes, +./scripts/cycles.sh latest-matrix # same names as codesize.sh +./scripts/cycles.sh latest-block-matrix ``` Needs `qemu-system-arm` (`sudo apt-get install qemu-system-arm`); it skips @@ -154,9 +165,10 @@ cleanly without it. It is deliberately **not** part of `./scripts/ci.sh` — every other check is satisfied by the pinned toolchain alone, and a check most contributors cannot run would make a green `ci.sh` mean less rather than more. -The counts are deterministic per QEMU build, not across builds (two of the -eighteen regions were observed to shift by one instruction between builds), so -when comparing against the documented numbers, run inside the reference +The counts are deterministic per QEMU build, not across builds (regions have +been observed to shift by one instruction between builds), and the merged +binary's layout matters too — the documented numbers are measured on the +assembled release tree. When comparing against them, run inside the reference environment below. Paste the numbers into your PR, as with `codesize.sh`. @@ -168,7 +180,8 @@ matrix with zero SKIPs — are measured in one pinned Docker image, so that anyone can reproduce the evidence rather than take the README's word for it: ```bash -./scripts/verify.sh # ci + miri + loom + cycles, all inside the image +./scripts/verify.sh # ci + miri + loom + all four cycle modes + + # the EventFlags atomic-window gate, in the image ./scripts/verify.sh cycles # just one of them ./scripts/verify.sh shell # look around ``` From 269b806f7f80f82b29a00e1ae4656041d52c4f54 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 04:34:52 -0400 Subject: [PATCH 82/87] Bring the security policy and crate metadata up to the 0.3.0 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY.md still declared 0.1.x as the supported version and scoped memory-safety concerns to a pre-0.3.0 crate. Supported versions now state the latest-minor-only policy (0.3.x), the scope names every concurrent primitive and the MaybeUninit surfaces, the bounded-per-call promise is explicitly in scope for DoS reports, and the documented deviations (the seqlock formal race and the three counter-width span boundaries) are distinguished from vulnerabilities — with the express invitation that a manifestation beyond a documented bound, or documentation that understates exposure, is exactly what to report. The Cargo.toml description said ring buffers; 0.3.0 ships five primitive families. It now reads: deterministic zero-allocation SPSC primitives — ring buffers, a latest-value snapshot channel, condition flags, saturating counters, and complete sample blocks — bounded behaviour, measured cost, Loom-verified orderings. Keywords and categories stand (crates.io caps both at 5; ring-buffer remains the high-traffic search term). cargo package --list re-verified: only allowlisted roots ship; the security policy intentionally lives on GitHub with the advisory flow, not in the package. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 2 +- SECURITY.md | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index beb873d..0a4461b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ rust-version = "1.92.0" # crates clear that bar. Lead with what is actually on offer: predictable # behaviour and a cost you can measure. See AGENTS.md § What this crate # optimises for. -description = "Deterministic zero-allocation ring buffers for no-std embedded targets: bounded behaviour, no hidden cost, Loom-verified orderings" +description = "Deterministic zero-allocation SPSC primitives for no-std embedded targets — ring buffers, a latest-value snapshot channel, condition flags, saturating counters, and complete sample blocks: bounded behaviour, measured cost, Loom-verified orderings" authors = ["Steven Giacomelli "] license = "MIT" repository = "https://github.com/photon-circus/ph-eventing" diff --git a/SECURITY.md b/SECURITY.md index 33bdc36..19b22ff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | |---------|-----------| -| 0.1.x | ✅ | +| 0.3.x | ✅ | +| < 0.3 | ❌ — upgrade; fixes land on the latest minor only | ## Reporting a Vulnerability @@ -26,9 +27,22 @@ disclosure. ph-eventing is a `#![no_std]` library with no network, filesystem, or OS interaction. Security-relevant concerns are primarily: -- **Memory safety** — unsound `unsafe` blocks, torn reads, data races. +- **Memory safety** — unsound `unsafe` blocks, torn reads, data races, in any + of the concurrent primitives (`SeqRing`, `EventBuf`, `LatestBuf`, + `EventFlags`, `CountedSignal`) or the `MaybeUninit` handling in + `Block`/`BlockBuilder` and `RingBuf`. - **Denial of service** — unbounded loops or panics in library code on - well-formed input. + well-formed input. Every hot-path operation is documented as bounded per + call; a reproducible violation of a documented bound is in scope. + +**Already-documented deviations are not vulnerabilities in themselves.** +`SeqRing` carries a deliberate, documented formal data race (the seqlock +deviation — see the `seq_ring` module docs and `docs/records/seq-ring.md`), +and the counter-width span limits on `SeqRing` accounting, +`LatestBuf::skipped`, and `BlockBuilder` contiguity are documented +boundaries with stated reachability arithmetic. A report that one of these +*manifests beyond its documented bound* — or that the documentation +understates the exposure — is very much in scope and welcome. ## Disclosure From 7f86fe8c9ec235ed4680fc07b1239c76bc21e559 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 04:43:33 -0400 Subject: [PATCH 83/87] Rustdoc example sweep: no anti-patterns, no vacuous asserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block module example (and its README twin) modelled exactly what the crate teaches against: it unwrapped the EventBuf push whose entire contract is returned backpressure, unwrapped FillError — the explicit interruption F3 exists to make handleable — and acquired handles with bare unwraps where every other example carries a reasoned expect. The example now asserts push success with a comment stating the returned-not-unwrapped policy, uses expect("contiguous")/ expect("complete") on the fill path, and matches each surface's established expect-message style. The crate-root SeqRing example ignored poll_one's delivered flag, so an empty ring would have skipped the hook and passed its assertions vacuously — the flag is now asserted with the reason stated. Sweep confirms no runnable doctest contains a bare unwrap; the compile_fail pins are untouched. Doctests remain 13 + 11 green. Co-Authored-By: Claude Fable 5 --- README.md | 14 ++++++++------ src/block.rs | 18 ++++++++++-------- src/lib.rs | 5 ++++- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index ef2b557..50132b8 100644 --- a/README.md +++ b/README.md @@ -110,15 +110,17 @@ use ph_eventing::{BlockBuilder, EventBuf}; let mut fill = BlockBuilder::::new(); for (sequence, sample) in [(10, 1), (11, 2), (12, 3)] { - assert!(fill.push(sequence, sample).unwrap().is_none()); + assert!(fill.push(sequence, sample).expect("contiguous").is_none()); } -let block = fill.push(13, 4).unwrap().unwrap(); +let block = fill.push(13, 4).expect("contiguous").expect("complete"); let queue = EventBuf::<_, 2>::new(); -let producer = queue.try_producer().unwrap(); -let consumer = queue.try_consumer().unwrap(); -producer.push(block).unwrap(); -assert_eq!(consumer.pop().unwrap().samples(), &[1, 2, 3, 4]); +let producer = queue.try_producer().expect("no producer taken yet"); +let consumer = queue.try_consumer().expect("no consumer taken yet"); +// Backpressure is returned, never unwrapped: a full queue hands the +// complete block back through `Err` for the caller's policy. +assert!(producer.push(block).is_ok()); +assert_eq!(consumer.pop().expect("one block queued").samples(), &[1, 2, 3, 4]); ``` ### RingBuf diff --git a/src/block.rs b/src/block.rs index 5fb85d2..8150b4a 100644 --- a/src/block.rs +++ b/src/block.rs @@ -83,16 +83,18 @@ //! use ph_eventing::{BlockBuilder, EventBuf}; //! //! let mut fill = BlockBuilder::::new(); -//! assert!(fill.push(10, 1).unwrap().is_none()); -//! assert!(fill.push(11, 2).unwrap().is_none()); -//! assert!(fill.push(12, 3).unwrap().is_none()); -//! let block = fill.push(13, 4).unwrap().unwrap(); +//! for (sequence, sample) in [(10, 1), (11, 2), (12, 3)] { +//! assert!(fill.push(sequence, sample).expect("contiguous").is_none()); +//! } +//! let block = fill.push(13, 4).expect("contiguous").expect("complete"); //! //! let queue = EventBuf::<_, 2>::new(); -//! let producer = queue.try_producer().unwrap(); -//! let consumer = queue.try_consumer().unwrap(); -//! producer.push(block).unwrap(); -//! assert_eq!(consumer.pop().unwrap().samples(), &[1, 2, 3, 4]); +//! let producer = queue.try_producer().expect("producer"); +//! let consumer = queue.try_consumer().expect("consumer"); +//! // Backpressure is returned, never unwrapped: a full queue hands the +//! // complete block back through `Err` for the caller's policy. +//! assert!(producer.push(block).is_ok()); +//! assert_eq!(consumer.pop().expect("one block queued").samples(), &[1, 2, 3, 4]); //! ``` //! //! A zero-sized block is rejected at compile time: diff --git a/src/lib.rs b/src/lib.rs index b9cd8c4..a6722e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,10 +71,13 @@ //! let mut consumer = ring.try_consumer().expect("consumer"); //! //! producer.push(42); -//! consumer.poll_one(|seq, v| { +//! // Assert the delivery flag, not just the hook body: an empty ring would +//! // skip the hook and the assertions inside it would pass vacuously. +//! let delivered = consumer.poll_one(|seq, v| { //! assert_eq!(seq, 1); //! assert_eq!(*v, 42); //! }); +//! assert!(delivered); //! ``` //! //! # Quick start — `EventBuf` From a4af6ab34b2f4125911546fd5e6d4997b2b572e3 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 04:49:04 -0400 Subject: [PATCH 84/87] GitHub templates current with the 0.3.0 surface The bug template's version placeholder said 0.1.3, its known-non-bugs predated the 0.3.0 boundaries (the counter-width span limitations are now listed as documented-not-bugs, with the explicit invitation that misbehaviour within a documented bound is a bug to file), and the race-free counter-example now names LatestBuf beside EventBuf. The feature template's constraints speak for all concurrent primitives, state the measured-evidence expectation for proposals, list all seven type families in the alternatives check, and route zero-copy / direct-to-slot requests to the deferred SlotPool evaluation and its adopter-gated reopening trigger. The PR template's concurrency checklist names all five concurrent modules (was two), notes that Loom runs go through the script so the gate's preemption bound applies, and adds the measurement checklist for API-shape and hot-path changes (codesize with the relevant matrix mode, cycles in the reference image, deliberate re-bless reasoning). Issue-form YAML validated. Co-Authored-By: Claude Fable 5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 12 ++++++++++-- .github/ISSUE_TEMPLATE/feature_request.yml | 17 +++++++++++++---- .github/PULL_REQUEST_TEMPLATE.md | 15 ++++++++++++--- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e539c04..878da4b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -15,7 +15,15 @@ body: This includes Miri runs in *your* project — a test that drives the ring from two threads will report UB pointing into this crate. See the `seq_ring` module docs for why, and for the alternatives. `EventBuf` - has no such caveat. + and `LatestBuf` have no such caveat — both pass Miri with the race + detector on. + + Finally, the **counter-width span boundaries** are documented + limitations, not bugs: `SeqRing` loss accounting, `LatestBuf::skipped`, + and `BlockBuilder` contiguity are each exact below one `2^32 − 1` + span and alias beyond it — see "Known issues" in the changelog and the + module docs for the reachability arithmetic. A case that misbehaves + *within* a documented bound is very much a bug; please file it. - type: textarea id: what-happened @@ -40,7 +48,7 @@ body: id: version attributes: label: ph-eventing version - placeholder: "0.1.3" + placeholder: "0.3.0" validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 5b066c0..b7efd82 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -11,9 +11,13 @@ body: Constraints any proposal has to live within: - `#![no_std]`, zero heap allocation, and **zero runtime dependencies**. - - Fixed-size, `T: Copy`, stack-allocated. - - The buffers are SPSC by design. Multi-producer or multi-consumer is a - different data structure, not a flag on this one. + - Fixed-size and zero-allocation; payload-carrying types are `T: Copy`. + - The concurrent primitives are SPSC by design. Multi-producer or + multi-consumer is a different data structure, not a flag on one of + these. + - Every guarantee ships with measured evidence: expect a proposal to be + asked for its cost story (code size, cycles) across targets, not + just its API. - type: textarea id: problem @@ -36,7 +40,12 @@ body: label: Alternatives considered description: > Including whether an existing type already covers it — `RingBuf`, - `SeqRing`, and `EventBuf` deliberately trade off differently. + `SeqRing`, `EventBuf`, `LatestBuf`, `EventFlags`, `CountedSignal`, and + the `Block`/`BlockBuilder` composition deliberately trade off + differently. If this is a zero-copy / direct-to-slot request, note + that a `SlotPool` design was evaluated in full and deferred with an + adopter-gated reopening trigger (`docs/proposals/slot-pool.md`) — a + real adopter naming that trigger reopens it. - type: checkboxes id: constraints diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e1add62..56bc3cd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,13 +14,22 @@ assuming a green check covers them. - [ ] `./scripts/ci.sh` — all checks pass, **no `SKIP` lines** (a skipped check is not a passed check; install the tool and re-run) -If this touches atomics, orderings, fences, `unsafe`, or anything in -`seq_ring.rs` / `event_buf.rs` / `sync.rs`: +If this touches atomics, orderings, fences, `unsafe`, or anything in the +concurrent modules (`seq_ring.rs`, `event_buf.rs`, `latest_buf.rs`, +`event_flags.rs`, `counted_signal.rs`, `sync.rs`): - [ ] `./scripts/miri.sh` — clean -- [ ] `./scripts/loom.sh` — all models verified +- [ ] `./scripts/loom.sh` — all models verified (run via the script: it sets + the preemption bound the gate uses) - [ ] Ordering changes are justified in a comment, not just in this PR +If this changes an API shape or a hot path: + +- [ ] `./scripts/codesize.sh` (plus the relevant matrix mode) — numbers pasted + or baseline deliberately re-blessed with the reasoning stated +- [ ] `./scripts/cycles.sh` for hot-path changes — compared inside the + reference image when measuring against documented numbers + If this touches `Cargo.toml`: - [ ] `cargo deny check` passes From 4c1ca7d96ab9239d08f5b9fffcd5b3d94197fb09 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 04:52:00 -0400 Subject: [PATCH 85/87] Script documentation sweep: headers describe the scripts that exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loom_tests header pointed at a loom.ps1 that has never existed in this tree (the repo's one-script policy is documented in ci.sh) — it now points at loom.sh with the preemption-bound note. codesize.sh's output-reading guide described four of its ten default columns; it now explains all of them, including the flags_* trio and the two gated statics (EventBuf 268 B, SeqRing 524 B) that carry the const-new claim. miri.sh's header states that pass 1 runs everything else with the detector ON — which is where LatestBuf's headline race-freedom claim lives — so the split-pass structure cannot be misread as a blanket exemption. The block_shape probe twin's header explained a constraint that no longer exists (candidate branches that must not stack); it now records the origin, the twin's current job (measuring the exact evaluated layout), and that switching to the real types is a deliberate future re-measure. The Dockerfile's pinned-image example tag moves to the 0.3.0 release tag it will publish under. Co-Authored-By: Claude Fable 5 --- scripts/codesize.sh | 13 ++++++++----- scripts/miri.sh | 4 ++++ scripts/probes/block_shape.rs | 13 +++++++++---- scripts/verify/Dockerfile | 2 +- src/loom_tests.rs | 2 +- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/scripts/codesize.sh b/scripts/codesize.sh index 3f8f36d..0bc36e0 100755 --- a/scripts/codesize.sh +++ b/scripts/codesize.sh @@ -34,11 +34,14 @@ # ./scripts/codesize.sh latest-block-matrix # LatestBuf sample/block D3 matrix # XTENSA=1 ./scripts/codesize.sh # add ESP32 rows (needs esp-rs fork) # -# Reading the output: each number is the byte size of one function, so it -# attributes to a single API shape rather than to a whole binary. `cs_incr` and -# `cs_take` isolate the two CountedSignal hot paths. `bss` is the `static -# EventBuf`; it should be identical on every target and `data` should -# be 0 -- that pair is the const-`new` claim. +# Reading the default output: each number is the byte size of one function, so +# it attributes to a single API shape rather than to a whole binary. `cs_incr` +# and `cs_take` isolate the two CountedSignal hot paths; `flags_acq`, +# `flags_raise`, and `flags_take` do the same for EventFlags role acquisition +# and its two operations. `bss` is the `static EventBuf` (268 B) and +# `seq_bss` the `static SeqRing` (524 B); both should be identical on +# every target and `data` should be 0 -- that trio is the const-`new` claim. +# The matrix modes print their own column legends. set -u diff --git a/scripts/miri.sh b/scripts/miri.sh index 7c505ad..d81491d 100755 --- a/scripts/miri.sh +++ b/scripts/miri.sh @@ -13,6 +13,10 @@ # its logic (no stale or torn payload, exact drop accounting) is still # verified under Miri's scheduler. # +# Everything else runs in pass 1 WITH the detector on -- that includes +# LatestBuf, whose race-freedom under the detector is its headline soundness +# claim (contract P6/C6), and EventBuf's race-free-by-construction claim. +# # Cross-target passes catch pointer-width and endianness bugs. The bare-metal # targets cannot be run: Miri needs `std` for the test harness, and no_std # targets have none. The 32-bit std targets stand in for them -- they share the diff --git a/scripts/probes/block_shape.rs b/scripts/probes/block_shape.rs index 17200a3..263e0be 100644 --- a/scripts/probes/block_shape.rs +++ b/scripts/probes/block_shape.rs @@ -1,8 +1,13 @@ -//! Probe-only structural twins of BlockBuf at `bc54a9a`. +//! Probe-only structural twins of `Block`/`BlockBuilder`, pinned at the +//! evaluation revision (`bc54a9a`). //! -//! Candidate branches must not stack. Sharing this source between the code-size -//! and cycle probes keeps the measured `Block` layout and final -//! `BlockBuilder::push` path identical without importing the BlockBuf branch. +//! Origin: during the 0.3.0 cycle the LatestBuf and BlockBuf candidates lived +//! on separate branches that were not allowed to stack, so the D3 composition +//! probes measured against this twin instead of importing the BlockBuf +//! branch. Both types now ship from one tree, so the twin's remaining job is +//! stability: the composition rows keep measuring the exact evaluated layout. +//! Switching these probes to the real `ph_eventing::Block` types is a +//! deliberate future re-measure (new rows, new bless), not a cleanup. #![allow(dead_code)] diff --git a/scripts/verify/Dockerfile b/scripts/verify/Dockerfile index 86b5fb2..ca56581 100644 --- a/scripts/verify/Dockerfile +++ b/scripts/verify/Dockerfile @@ -19,7 +19,7 @@ # A prebuilt copy is published at hub.docker.com/r/stevegiacomelli/ph-eventing-verify; # each 0.x.y tag is the frozen environment of that release's evidence: # -# VERIFY_IMAGE=stevegiacomelli/ph-eventing-verify:0.2.0 ./scripts/verify.sh +# VERIFY_IMAGE=stevegiacomelli/ph-eventing-verify:0.3.0 ./scripts/verify.sh # # Build locally instead (from the repo root -- the context must include # rust-toolchain.toml and the lockfiles): diff --git a/src/loom_tests.rs b/src/loom_tests.rs index e2f020a..a1b366d 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -11,7 +11,7 @@ //! of executions grows exponentially. A bug that needs four items to show up is //! almost always visible with two. //! -//! Run with `./scripts/loom.ps1` or `./scripts/loom.sh`. +//! Run with `./scripts/loom.sh` (it sets the preemption bound the gate uses). //! //! [Loom]: https://github.com/tokio-rs/loom From 4d3ae427eec8654e51330bd2e50592c4ceb1441f Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Wed, 12 Aug 2026 04:56:35 -0400 Subject: [PATCH 86/87] Release checklist carries the reference-image publish; record links repo-relative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frozen-tag-per-release image practice (one immutable stevegiacomelli/ph-eventing-verify:X.Y.Z tag per release, evidence gathered inside the published tag) was followed for 0.2.0 but never written into RELEASING.md — exactly the kind of step that gets forgotten because it lives in nobody's checklist. Step 6 now carries it: validate the exact image first (build-guard firing plus the strict offline check — --network none AND a fresh CARGO_TARGET_DIR, since a warm mounted target silently invalidates the test), the maintainer tags and pushes like cargo publish, the final matrix re-runs against the published tag so the recorded evidence names the immutable pin, and the after-publishing list confirms the tag exists on Docker Hub because the release's evidence cites it. Also from the link sweep: the latest-buf record's five evidence links still pointed at blob/candidate/latest-buf/ for documents that merged into this tree with #35 — now repo-relative, so they hold on the branch, in the tag, and on master after merge-back. The planning record's archive-tag links were verified correct as-is (the tag exists on origin at the documented 2c3d37f; an immutable tag is the stable pattern for evidence that deliberately lives only there). 29 markdown files scanned; no broken relative links remain. Co-Authored-By: Claude Fable 5 --- RELEASING.md | 34 ++++++++++++++++++++++++++++++++++ docs/records/latest-buf.md | 10 +++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index bd5476c..0868ca2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -165,6 +165,37 @@ gathered with (see `scripts/verify/Dockerfile`): Record the printed toolchain and QEMU versions in the release PR alongside the results — a verdict without its environment is not reproducible evidence. +### The reference image is part of the release + +The evidence above is only reproducible if the image that produced it stays +reachable, so every release publishes the image under **one frozen tag, +never retagged**: `stevegiacomelli/ph-eventing-verify:X.Y.Z`. A Dockerfile +rebuild is NOT guaranteed to reproduce a published tag (apt rotates +versions) — the published tag is the pin, which is why forgetting this step +quietly destroys the release's reproducibility story. + +1. **Validate the exact image first** (agent step): the build-time version + guards fired cleanly, and the strict offline check passes — `--network + none` **and** a fresh `CARGO_TARGET_DIR`; a warm mounted `target/` + silently invalidates the test. +2. **Tag and push** (maintainer step, like `cargo publish`): + + ```bash + docker tag ph-eventing-verify stevegiacomelli/ph-eventing-verify:X.Y.Z + docker push stevegiacomelli/ph-eventing-verify:X.Y.Z + ``` + +3. **Re-run the final matrix against the published tag** so the recorded + evidence names the immutable pin, not a local build: + + ```bash + VERIFY_IMAGE=stevegiacomelli/ph-eventing-verify:X.Y.Z ./scripts/verify.sh + ``` + +4. The `VERIFY_IMAGE` example in `scripts/verify/Dockerfile` names the + current release's tag — step 5 of this checklist already covers keeping + it fresh. + ## 7. Check what will actually ship ```bash @@ -217,6 +248,9 @@ Needs a crates.io token (`cargo login`). This is the irreversible step. docs.rs's target, for example. Watch ; a failure shows in the build log there, not in your terminal. - **Create the GitHub release** against the tag, pasting the changelog section. +- **Confirm the reference image tag is on Docker Hub** + (`stevegiacomelli/ph-eventing-verify:X.Y.Z`, from step 6) — the release's + evidence cites it, so a missing tag is a broken citation. - **Merge the release branch back into `master`** via its PR. Do not skip or defer this. Until it merges, the released state exists only on a branch: the tag is unreachable from `master`, `master`'s `Cargo.toml` still names the diff --git a/docs/records/latest-buf.md b/docs/records/latest-buf.md index 54a9aa0..a8626c3 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -7,9 +7,9 @@ - **Normative sources:** [contract](../proposals/latest-buf-contract.md) (clause IDs cited below) · [proposal](../proposals/latest-buf.md) · merged with #35 (repo-relative on the release branch): - [evaluation record](https://github.com/photon-circus/ph-eventing/blob/candidate/latest-buf/docs/proposals/latest-buf-evaluation.md) · - [measurements](https://github.com/photon-circus/ph-eventing/blob/candidate/latest-buf/docs/proposals/latest-buf-measurements.md) · - [joint composition matrix](https://github.com/photon-circus/ph-eventing/blob/candidate/latest-buf/docs/proposals/latest-block-composition-measurements.md). + [evaluation record](../proposals/latest-buf-evaluation.md) · + [measurements](../proposals/latest-buf-measurements.md) · + [joint composition matrix](../proposals/latest-block-composition-measurements.md). ## 1. Value statement @@ -147,7 +147,7 @@ overclaimed stall-only trigger — every finding confirmed, none disputed). The corrected text is *stricter* than the drafts it replaced. **Where the numbers live** (lane-resident until #35 merges): -[`latest-buf-measurements.md`](https://github.com/photon-circus/ph-eventing/blob/candidate/latest-buf/docs/proposals/latest-buf-measurements.md) +[`latest-buf-measurements.md`](../proposals/latest-buf-measurements.md) (11-target code size, pinned cycles, A.1 comparison, RAM); -[`latest-block-composition-measurements.md`](https://github.com/photon-circus/ph-eventing/blob/candidate/latest-buf/docs/proposals/latest-block-composition-measurements.md) +[`latest-block-composition-measurements.md`](../proposals/latest-block-composition-measurements.md) (the joint D3 matrix); tracking: issues #26/#27, PRs #35/#37. From d3d4585eca2aa47cb31fa23830e3087013633693 Mon Sep 17 00:00:00 2001 From: Steven Giacomelli Date: Thu, 13 Aug 2026 11:10:35 -0400 Subject: [PATCH 87/87] docs/ gets a map: README at the root, planning record in docs/planning/ - docs/README.md: lightweight map of the three documentation layers (records = enduring briefing, proposals = closed design decisions, planning = per-cycle candidate triage) and where the API contract lives. - docs/0.3.0-candidates.md -> docs/planning/0.3.0-candidates.md: the planning record is a per-cycle artifact, not a docs-root resident; the new directory is the pattern for future cycles. - All six proposal references and the doc's own 13 relative links rewritten for the new depth; AGENTS.md structure tree updated. - Proof: repo-wide relative-link check passes (31 md files, 0 broken). Co-Authored-By: Claude Fable 5 --- AGENTS.md | 3 ++- docs/README.md | 22 ++++++++++++++++++++ docs/{ => planning}/0.3.0-candidates.md | 26 ++++++++++++------------ docs/proposals/block-buf.md | 2 +- docs/proposals/counted-signal.md | 2 +- docs/proposals/event-flags.md | 2 +- docs/proposals/exploratory-primitives.md | 2 +- docs/proposals/latest-buf.md | 2 +- docs/proposals/slot-pool.md | 2 +- 9 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 docs/README.md rename docs/{ => planning}/0.3.0-candidates.md (97%) diff --git a/AGENTS.md b/AGENTS.md index 1d6e18c..c15222a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,9 +152,10 @@ ph-eventing/ ├── deny.toml # cargo-deny policy: advisories, licences, bans, sources ├── RELEASING.md # release checklist (version choice, verification, publish, yank) ├── docs/ +│ ├── README.md # map of the documentation layers below │ ├── records/ # engineering records: the enduring per-type briefing layer │ ├── proposals/ # design-decision documents: proposals, frozen contracts, measurements -│ └── 0.3.0-candidates.md # the 0.3.0 cycle's planning record (historical) +│ └── planning/ # per-cycle planning records (0.3.0-candidates.md, historical) └── src/ ├── lib.rs # Crate root, public exports, doctests ├── macros.rs # static_spsc! -- declarative static bring-up diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c317ec3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# Documentation map + +Three layers, split by the question they answer and how long the answer +stays true: + +- [`records/`](records/) — **engineering records**, one per shipped type. + The enduring briefing layer: value statement, risks, every load-bearing + claim mapped to the evidence that keeps it true. Read these first when + deciding whether to use a type. What a record is (and is not) is defined + in [`records/README.md`](records/README.md). +- [`proposals/`](proposals/) — **design-decision documents**: proposals, + frozen contracts, evaluations, and measurement reports. These record how + and why a decision was made, in the state the decision was made in; each + header states its closed outcome (shipped, deferred, rejected). They are + history — corrections land in the records, not here. +- [`planning/`](planning/) — **per-cycle planning records** + (e.g. [`planning/0.3.0-candidates.md`](planning/0.3.0-candidates.md)): + candidate triage, evidence bars, and kill criteria for a release cycle. + Historical once the cycle closes. + +Rustdoc (`cargo doc`) is the API contract surface; `AGENTS.md` at the repo +root is the working guidance for changing any of this. diff --git a/docs/0.3.0-candidates.md b/docs/planning/0.3.0-candidates.md similarity index 97% rename from docs/0.3.0-candidates.md rename to docs/planning/0.3.0-candidates.md index c3177bb..98dc2c7 100644 --- a/docs/0.3.0-candidates.md +++ b/docs/planning/0.3.0-candidates.md @@ -78,7 +78,7 @@ publish; listed only so the 0.3.0 notes account for it. ## 3. `LatestBuf` — freshness-first SPSC snapshot channel — PROTOTYPE / EVALUATING Design document received 2026-08-11 and captured in full at -[`docs/proposals/latest-buf.md`](proposals/latest-buf.md). This section is +[`docs/proposals/latest-buf.md`](../proposals/latest-buf.md). This section is the cycle-tracking summary; the proposal is the reference. **What it is.** A three-slot exclusive-ownership SPSC channel that retains @@ -138,7 +138,7 @@ decision → traits only with the primitive that needs them → harness integration. **Cycle state (2026-08-11):** step 1 is drafted — the semantic contract -lives at [`proposals/latest-buf-contract.md`](proposals/latest-buf-contract.md) +lives at [`proposals/latest-buf-contract.md`](../proposals/latest-buf-contract.md) with clause IDs, an evidence map, and three decision points (D1 wrap policy, D2 `Source` policy, D3 sample-vs-block), initially **deferred open by maintainer decision (2026-08-11)** and each since closed on the record the @@ -169,11 +169,11 @@ review caveats are all resolved: **A.1** measured and selected (the wrap-boundary unit set, and **A.3** closed as above. Development is no longer paused: the lane's prototype and complete admission evidence live on `candidate/latest-buf` (draft PR #35). The live comparison record is -[`proposals/latest-buf-evaluation.md`](proposals/latest-buf-evaluation.md); +[`proposals/latest-buf-evaluation.md`](../proposals/latest-buf-evaluation.md); the 11-target code-size, pinned-cycle, A.1, and RAM results are in -[`proposals/latest-buf-measurements.md`](proposals/latest-buf-measurements.md); +[`proposals/latest-buf-measurements.md`](../proposals/latest-buf-measurements.md); the joint complete-block matrix is in -[`proposals/latest-block-composition-measurements.md`](proposals/latest-block-composition-measurements.md) +[`proposals/latest-block-composition-measurements.md`](../proposals/latest-block-composition-measurements.md) (171-6,958 reference instructions across the 2/8/16-byte by 8/32/128 grid, 136-8,280 bytes combined channel/builder RAM). The lane now waits on acceptance review, not on decisions or development. Traits `ObservedSource` @@ -206,7 +206,7 @@ draining of a backlog), which `LatestBuf` deliberately does not provide. ## 4. Bounded-handoff taxonomy — EXPLORATORY Comprehensive exploratory document received 2026-08-11 and captured in full -at [`docs/proposals/exploratory-primitives.md`](proposals/exploratory-primitives.md). +at [`docs/proposals/exploratory-primitives.md`](../proposals/exploratory-primitives.md). It frames the crate's broader opportunity — a small collection of explicitly bounded handoff mechanisms, never a general event bus or executor — and requires every primitive to answer four questions: what is retained, what @@ -236,7 +236,7 @@ position for maintainer triage, not a decision. **Tier 1 — nearest-term, candidate for the next design document:** - **`BlockBuf`** (latest/queued/drop complete-block handoff) — design - document: [`proposals/block-buf.md`](proposals/block-buf.md). Strongest + document: [`proposals/block-buf.md`](../proposals/block-buf.md). Strongest near-term addition per the document, and it *intersects deferred decision D3* on the LatestBuf contract (sample-vs-block first deliverable): a `LatestBlockBuf` is close to "LatestBuf where `T` is a block," so its @@ -247,19 +247,19 @@ position for maintainer triage, not a decision. nearly wholesale-reusable for the `Latest` variant. - **`EventFlags`** (coalesced condition bitset) — **PROPOSED** on `candidate/event-flags`. The frozen semantic clauses are in - [`proposals/event-flags-contract.md`](proposals/event-flags-contract.md) and + [`proposals/event-flags-contract.md`](../proposals/event-flags-contract.md) and the implementation, ordering proof, code-size/cycle tables, and exact interrupt-masked windows are in - [`proposals/event-flags.md`](proposals/event-flags.md). The candidate keeps + [`proposals/event-flags.md`](../proposals/event-flags.md). The candidate keeps SPSC `Send + !Sync` role handles, uses one Release `fetch_or` and one Acquire `swap(0)`, and deliberately does not implement the stream traits. Measured portable paths mask interrupts for 4 instructions on thumbv6m and 5 on ESP32-S2; the measured ESP32-S3 target instead emits native `s32c1i` and masks for 0 instructions. - **`CountedSignal`** (saturating per-condition counter) — design - document: [`proposals/counted-signal.md`](proposals/counted-signal.md); + document: [`proposals/counted-signal.md`](../proposals/counted-signal.md); clause-numbered contract: - [`proposals/counted-signal-contract.md`](proposals/counted-signal-contract.md). + [`proposals/counted-signal-contract.md`](../proposals/counted-signal-contract.md). Same niche and same triage notes as `EventFlags` (cross-referenced, not duplicated); the saturate-don't-wrap rule matches the `dropped_accum` convention already in the codebase. Separate design documents by @@ -274,7 +274,7 @@ position for maintainer triage, not a decision. **Tier 2 — high value, hard problems named, needs design work:** - **`SlotPool`** (zero-copy ownership-state transfer) — design document: - [`proposals/slot-pool.md`](proposals/slot-pool.md). Named as the + [`proposals/slot-pool.md`](../proposals/slot-pool.md). Named as the foundation `BlockBuf` and future primitives could build on. *(Outcome: evaluated in full on `candidate/slot-pool`, then **deferred** by decision S below — evidence banked, adopter-gated trigger @@ -599,7 +599,7 @@ candidate while integrating cleanly. Every rule below traces to a specific added beside the stale text it should have replaced.)* 12. **Every type carries an engineering record at `docs/records/.md`** (maintainer decision, 2026-08-11; template and rules in - [`records/README.md`](records/README.md)). The record is the executive + [`records/README.md`](../records/README.md)). The record is the executive briefing over the normative sources — a short value statement, then risks and integration concerns in integrator terms, then each technical claim mapped to its validating evidence, then the working diff --git a/docs/proposals/block-buf.md b/docs/proposals/block-buf.md index 03accba..75be4fc 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -9,7 +9,7 @@ [`records/block-buf.md`](../records/block-buf.md). - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §2 (received - 2026-08-11); triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. + 2026-08-11); triaged Tier 1 in [`../planning/0.3.0-candidates.md`](../planning/0.3.0-candidates.md) §4. - **Taxonomy row:** retention *latest complete block* · overload *replace/reject whole block* · representative use *DMA, IMU/DSP windows*. - **Related:** [`latest-buf.md`](latest-buf.md) §11 (the complete-block diff --git a/docs/proposals/counted-signal.md b/docs/proposals/counted-signal.md index 9a59540..1b78280 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -7,7 +7,7 @@ [`records/counted-signal.md`](../records/counted-signal.md). - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §5 (received - 2026-08-11); triaged Tier 1 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. + 2026-08-11); triaged Tier 1 in [`../planning/0.3.0-candidates.md`](../planning/0.3.0-candidates.md) §4. - **Taxonomy row:** retention *count per condition* · overload *counter saturates* · representative use *pulse/event accumulation*. - **Contract:** [`counted-signal-contract.md`](counted-signal-contract.md) — diff --git a/docs/proposals/event-flags.md b/docs/proposals/event-flags.md index 8a03a56..451845f 100644 --- a/docs/proposals/event-flags.md +++ b/docs/proposals/event-flags.md @@ -7,7 +7,7 @@ [`records/event-flags.md`](../records/event-flags.md). - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §4; triaged Tier 1 in - [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. + [`../planning/0.3.0-candidates.md`](../planning/0.3.0-candidates.md) §4. - **Contract:** [`event-flags-contract.md`](event-flags-contract.md) — stable M/R/T/C/S/B/H/W/X clause IDs and the evidence map. - **Related:** [`counted-signal.md`](counted-signal.md) — same bounded diff --git a/docs/proposals/exploratory-primitives.md b/docs/proposals/exploratory-primitives.md index a08ff0a..1dc5227 100644 --- a/docs/proposals/exploratory-primitives.md +++ b/docs/proposals/exploratory-primitives.md @@ -8,7 +8,7 @@ with an adopter-gated reopening trigger ([slot-pool.md](slot-pool.md)); `LatestBuf` shipped from its own proposal. - **Received:** 2026-08-11 (0.3.0 cycle; triage in - [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4) + [`../planning/0.3.0-candidates.md`](../planning/0.3.0-candidates.md) §4) - **Structure note (2026-08-11):** the substantive design text for the Tier 1/2 primitives has moved to per-primitive design documents — [`block-buf.md`](block-buf.md), [`slot-pool.md`](slot-pool.md), diff --git a/docs/proposals/latest-buf.md b/docs/proposals/latest-buf.md index 65026bf..104e34a 100644 --- a/docs/proposals/latest-buf.md +++ b/docs/proposals/latest-buf.md @@ -10,7 +10,7 @@ - **Compatibility:** No changes to existing `Sink`, `Source`, or `Link` traits - **Implementation status:** shipped (implemented on `candidate/latest-buf`, merged into `release/0.3.0` via PR #35) -- **Received:** 2026-08-11 (0.3.0 cycle; see `docs/0.3.0-candidates.md` §3) +- **Received:** 2026-08-11 (0.3.0 cycle; see `docs/planning/0.3.0-candidates.md` §3) ## 1. Summary diff --git a/docs/proposals/slot-pool.md b/docs/proposals/slot-pool.md index d561916..c7f3755 100644 --- a/docs/proposals/slot-pool.md +++ b/docs/proposals/slot-pool.md @@ -15,7 +15,7 @@ - **Prior status:** EXPLORATORY — design exploration vehicle, not yet PROPOSED. - **Origin:** substantive design text moved from the [bounded-handoff taxonomy](exploratory-primitives.md) §3 (received - 2026-08-11); triaged Tier 2 in [`../0.3.0-candidates.md`](../0.3.0-candidates.md) §4. + 2026-08-11); triaged Tier 2 in [`../planning/0.3.0-candidates.md`](../planning/0.3.0-candidates.md) §4. - **Taxonomy row:** retention *every admitted owned buffer* · overload *reservation fails* · representative use *large zero-copy payloads*. - **Related:** [`block-buf.md`](block-buf.md) (named candidate foundation