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 diff --git a/AGENTS.md b/AGENTS.md index 6441d2e..c15222a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,10 +26,19 @@ prose when it disagrees. **ph-eventing** provides stack-allocated ring buffers for no-std embedded targets. -It ships three primitives: +It ships five 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`** — a saturating SPSC count for identical, + payload-free events. The sole producer handle makes exact bounded saturation + possible without a CAS retry loop. +- **`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. +- **`LatestBuf`** — a freshness-first SPSC snapshot channel that retains one newest unread publication and reports replacement/skipped evidence. + +`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. @@ -38,6 +47,8 @@ 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 +- `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` @@ -128,21 +139,36 @@ 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/ # no_std probe crate it measures (own workspace) +│ ├── 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 (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/ +│ ├── 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 +│ └── 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 + ├── 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 + ├── 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 ├── 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 @@ -151,14 +177,23 @@ 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 | | `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()` | +| `CountedSignal` | Saturating count for payload-free SPSC events | +| `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 | +| `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` | @@ -175,6 +210,27 @@ 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 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: 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. +- 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) `EventBuf` uses a classic Lamport SPSC queue pattern: @@ -184,6 +240,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 @@ -211,6 +287,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 `swap` 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 @@ -300,7 +392,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 @@ -358,10 +454,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 @@ -585,7 +688,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 @@ -628,6 +731,9 @@ 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 +./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 ``` @@ -657,31 +763,88 @@ 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 -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. +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 # 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 +``` + +`./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 | |---|---:|---:|---:| | `EventBuf::push` | 25 | **25** (7 of 8) | 19 (full, rejected) | | `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 | | | +| `CountedSignal::increment` (below `MAX` / saturated arm) / `take_count` | 8, 9 / 9 | | | +| `EventFlags::raise` | 12 (clear) | **12** (already set) | | +| `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: + +| 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: +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`. + +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 **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 + 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. +4. **EventFlags is constant across condition state.** Raise is 12 instructions + 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 + 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. @@ -698,7 +861,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. @@ -783,13 +946,28 @@ 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`, `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 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 +- `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 - `push_and_get` — Basic push/get/latest @@ -822,6 +1000,25 @@ 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) +**`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 +- `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 @@ -835,6 +1032,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` @@ -860,11 +1058,24 @@ 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: 67 unit tests + 11 doctests, plus 3 `compile_fail` -doctests pinning the `N == 0` rejection (`E0080`) on all three types. +`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: 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 +pins that `LatestBuf` producer/consumer handles are `!Sync` (contract H2), and +two pins that the CountedSignal handles are `!Sync`, and two pins that the +EventFlags handles are `!Sync`. ## Code Conventions @@ -881,6 +1092,20 @@ 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 +- `CountedSignal`: `increment` uses `fetch_add` below `MAX` and confirms the + 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 +- `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 @@ -931,14 +1156,37 @@ 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` -- `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 - acquisition path — the refusal case is pinned by the `try_producer_and_try_consumer` tests +- `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 + 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) +- `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()` 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 +- `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 @@ -953,12 +1201,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 1f2dd62..0ddcf9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,146 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 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 + 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 (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 + 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 +- `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 + 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. @@ -16,69 +155,64 @@ All notable changes to this project will be documented in this file. shipped orderings. ### 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 (make the builder the DMA target, or publish from task context). **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. + +### 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`. +- `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/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 ``` 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..0a4461b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [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 # 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" @@ -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] @@ -54,6 +56,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/README.md b/README.md index 0ba3671..50132b8 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,23 @@ [![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 | 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. | +| [`CountedSignal`](#countedsignal) | Saturating SPSC count for identical, payload-free events. | +| [`EventFlags`](#eventflags) | Coalesced SPSC condition set — 32 payload-free conditions, one atomic hot-path operation. | +| [`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 types are fixed-size, `#![no_std]`, and zero-allocation. The buffers are +generic over `T: Copy`; `CountedSignal` carries no payload and `EventFlags` +carries an `EventMask(u32)`. ## What this optimises for @@ -25,9 +31,11 @@ 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 - cannot be observed either — every drop is reported (`SeqRing`) or prevented - (`EventBuf`). `RingBuf` is the deliberate exception: it is a single-owner + panic reachable from a hot path. For the concurrent types, no data loss that + 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`), 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. @@ -47,7 +55,9 @@ 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. +- Complete contiguous sample blocks with an explicit fill-side builder. +- `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 +67,8 @@ 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. +- `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) @@ -69,6 +80,49 @@ 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; `LatestBuf>` retains only the +latest complete block. + +**Budget the composition before choosing it.** Publication copies the +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 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 +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 +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).expect("contiguous").is_none()); +} +let block = fill.push(13, 4).expect("contiguous").expect("complete"); + +let queue = EventBuf::<_, 2>::new(); +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 A straightforward, single-owner ring buffer for collecting values when you @@ -109,6 +163,39 @@ 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 +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 +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; + +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` @@ -131,10 +218,69 @@ assert_eq!(consumer.pop(), Some(1)); 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 +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 algorithmic retry; +the contract discloses how each single RMW is realised per ISA. + +```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()); +``` + +### 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`, -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. Signal types such as `CountedSignal` are outside that +stream vocabulary (no `T` payload), and `EventFlags` is condition +signalling, not a payload stream — its handles deliberately implement +neither (see its section above). `LatestBuf` deliberately stands +outside it as well: 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}; @@ -160,6 +306,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 @@ -201,8 +349,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. @@ -211,21 +364,50 @@ 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. +- Count operations do not publish unrelated application memory; payload data + 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 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). + +### 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 - active. Handle acquisition is `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 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. +- `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`/`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, 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. @@ -242,8 +424,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 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. There is no way to get a second `Producer` while one is live: @@ -278,14 +461,15 @@ 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. ## 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 +477,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 | -| 67 unit + 11 doctests + 3 compile-fail | Behaviour, including threaded stress tests for both SPSC types; `N == 0` rejected at compile time | +| 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/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/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 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 89% rename from docs/0.3.0-candidates.md rename to docs/planning/0.3.0-candidates.md index 6544625..98dc2c7 100644 --- a/docs/0.3.0-candidates.md +++ b/docs/planning/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. --- @@ -75,10 +75,10 @@ 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 +[`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 @@ -168,11 +168,18 @@ review caveats are all resolved: **A.1** measured and selected (the `Acquire`-load empty-poll fast path), **A.2** validated by 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, through the joint composition -matrix at `8593b4a`), and the lane now waits on acceptance review, not on -decisions or development. Traits `ObservedSource` and the payload metadata -traits remain explicitly gated on a *second* implementation proving the -vocabulary honest — they are not part of the initial acceptance question. +on `candidate/latest-buf` (draft PR #35). The live comparison record is +[`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); +the joint complete-block matrix is in +[`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` +and the payload metadata traits remain explicitly 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 @@ -199,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 @@ -207,17 +214,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) @@ -228,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 @@ -237,28 +245,36 @@ 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). + 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 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:** - **`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 @@ -310,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, @@ -583,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-measurements.md b/docs/proposals/block-buf-measurements.md new file mode 100644 index 0000000..ca82078 --- /dev/null +++ b/docs/proposals/block-buf-measurements.md @@ -0,0 +1,118 @@ +# 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. +- **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. + +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 + 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. 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 e23649b..75be4fc 100644 --- a/docs/proposals/block-buf.md +++ b/docs/proposals/block-buf.md @@ -1,19 +1,22 @@ # BlockBuf: complete window handoff (exploratory design document) -- **Status:** EXPLORATORY — design exploration vehicle, not yet PROPOSED. +- **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. + 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 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 is closed as the convergent answer — - payload-agnostic `LatestBuf`, blocks as payloads, composition as the - type identity (contract §9). The developed candidate on - `candidate/block-buf` carries the confirmed design; this seed document is - historical. + **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) @@ -92,16 +95,13 @@ 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) > **Superseded (2026-08-11):** the type-identity questions below are -> answered by D3's closure — composition over payload-agnostic -> primitives, no new named types (contract §9). The fill-side, -> `Copy`-vs-`SlotPool`, and stamp questions are answered in the developed -> document on `candidate/block-buf` (`BlockBuilder`, decision **P** — -> closed 2026-08-11 as Copy composition, see the planning record's P/S -> closure — and stamps-inside-`T` respectively). Retained unedited as the -> seed record. +> answered by §5 and by D3's closure (contract §9): composition, no new +> named types; the stamp question resolves as stamps-inside-`T`, and +> `Copy`-vs-`SlotPool` closed as decision **P** — Copy composition +> (2026-08-11, the planning record's P/S closure). - Is `LatestBlockBuf` a new type or `LatestBuf>` plus a fill-side helper? What, concretely, does a separate type buy? @@ -119,7 +119,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). **Done 2026-08-11** — closed as composition; see the contract §9. @@ -132,3 +132,188 @@ 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. + +**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. + +### 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. + +### 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 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. + +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 — **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 | +| 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 — 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. +- **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. + **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. **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. + **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 — 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). diff --git a/docs/proposals/counted-signal-contract.md b/docs/proposals/counted-signal-contract.md new file mode 100644 index 0000000..9250a9f --- /dev/null +++ b/docs/proposals/counted-signal-contract.md @@ -0,0 +1,166 @@ +# CountedSignal semantic contract + +- **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 + 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:** 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 + +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 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) + +- **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 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 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 +independent LatestBuf A.3 choice later becomes one crate-wide doctrine remains +a separate maintainer decision on issue #26. + +## 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 | +| 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) / 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` | +| H4 | `const_new_works_in_static_context` | + +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. + +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 +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 210e0b3..1b78280 100644 --- a/docs/proposals/counted-signal.md +++ b/docs/proposals/counted-signal.md @@ -1,11 +1,17 @@ -# CountedSignal: multiplicity without payloads (exploratory design document) +# CountedSignal: multiplicity without payloads -- **Status:** EXPLORATORY — design exploration vehicle, not yet PROPOSED. +- **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. + 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) — + 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 @@ -58,35 +64,157 @@ 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. Resolved candidate decisions + +- 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 + 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 + 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 +listed, but only under a deliberately narrow handle model: + +```rust +let observed = count.load(Relaxed); +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); +``` + +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 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 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. + +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 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. + +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 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. | + +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, 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 +(after the sentinel-RMW fix) expose the architecture split for review: + +| Target family | `increment` | `take_count` | +|---|---:|---:| +| Cortex-M0 (`thumbv6m`) | 46 B | 24 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 +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 fix. The probe brackets the common below-`MAX` path +(`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 path | Retired guest instructions | +|---|---:| +| `increment` (below `MAX`, the hot path) | 8 | +| `increment` (saturated sentinel arm) | 9 | +| `take_count` | 9 | + +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 +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 +`-icount shift=0`; these are deterministic instruction/tick counts for that +environment, not a universal microarchitectural cycle claim. + +## 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. 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 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. +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 three Loom models cover atomic take, + no-lost-increment accounting, saturation without wrapping, and the + post-take stale-`MAX` litmus. + +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 new file mode 100644 index 0000000..2782bf3 --- /dev/null +++ b/docs/proposals/event-flags-contract.md @@ -0,0 +1,135 @@ +# EventFlags semantic contract + +- **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 + 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` 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` | + +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 215f2c4..451845f 100644 --- a/docs/proposals/event-flags.md +++ b/docs/proposals/event-flags.md @@ -1,120 +1,197 @@ -# EventFlags: coalesced condition notification (exploratory design document) +# EventFlags: coalesced condition notification -- **Status:** EXPLORATORY — design exploration vehicle, not yet PROPOSED. +- **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 (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 + [`../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 + 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. 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. +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 + +- 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- + 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 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 + +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 | 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. 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 + +`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 | 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 + +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 141bd9f..1dc5227 100644 --- a/docs/proposals/exploratory-primitives.md +++ b/docs/proposals/exploratory-primitives.md @@ -1,10 +1,14 @@ # 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) + [`../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), @@ -101,8 +105,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) @@ -114,10 +119,16 @@ 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 -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 @@ -317,7 +328,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/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-contract.md b/docs/proposals/latest-buf-contract.md index eb20c3e..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 @@ -67,8 +70,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) @@ -106,9 +110,9 @@ and all clauses below are stated against the sequence of those instants. wrap span**: an observed generation may repeat or appear to regress relative to one taken a full cycle earlier, and consumers must not treat generation comparison as a total order across such a gap (X6). -- **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 @@ -166,8 +170,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/proposals/latest-buf-evaluation.md b/docs/proposals/latest-buf-evaluation.md new file mode 100644 index 0000000..8042d76 --- /dev/null +++ b/docs/proposals/latest-buf-evaluation.md @@ -0,0 +1,237 @@ +# LatestBuf implementation evaluation + +- **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, + 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 + +These defaults make development possible without pretending the deferred +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 — **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 — **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 +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 +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 — empty-poll fast path + +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 + +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 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, +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 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 +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: + +| 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 | 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 | 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. + +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 +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/docs/proposals/latest-buf-measurements.md b/docs/proposals/latest-buf-measurements.md new file mode 100644 index 0000000..5a05d4f --- /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. The subsequent joint `Block` campaign is recorded in +[`latest-block-composition-measurements.md`](latest-block-composition-measurements.md). + +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 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. + +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/docs/proposals/latest-buf.md b/docs/proposals/latest-buf.md index 4cf9a28..104e34a 100644 --- a/docs/proposals/latest-buf.md +++ b/docs/proposals/latest-buf.md @@ -1,10 +1,16 @@ # 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:** Not implemented -- **Received:** 2026-08-11 (0.3.0 cycle; see `docs/0.3.0-candidates.md` §3) +- **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/planning/0.3.0-candidates.md` §3) ## 1. Summary diff --git a/docs/proposals/slot-pool.md b/docs/proposals/slot-pool.md index 72c033a..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 @@ -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/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/block-buf.md b/docs/records/block-buf.md new file mode 100644 index 0000000..d52f13c --- /dev/null +++ b/docs/records/block-buf.md @@ -0,0 +1,163 @@ +# BlockBuf — engineering record + +- **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). +- **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 — +cycle decision P closed as Copy composition, and the grant branch is +deferred behind S's adopter-gated trigger. + +## 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 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 + **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 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 + 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 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, 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 + 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; 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 (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) | +| 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 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. + +## 4. The record + +**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 = + `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: 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. 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 +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, PR #34 (merged), contract PR #37. diff --git a/docs/records/counted-signal.md b/docs/records/counted-signal.md new file mode 100644 index 0000000..12292f3 --- /dev/null +++ b/docs/records/counted-signal.md @@ -0,0 +1,160 @@ +# CountedSignal — engineering record + +- **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 + 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 + +`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 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. + +## 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 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 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 + 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` 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. +- **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 | +| 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) / 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 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 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. + +## 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 `fetch_add` with stale-MAX + confirmation.** Below `MAX`, one Relaxed load and one Relaxed + `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 + 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** *(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 +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 +— do not renumber. + +**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 — 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 +`f26d4c3` (H closed, promotion finalized). diff --git a/docs/records/event-buf.md b/docs/records/event-buf.md new file mode 100644 index 0000000..b5ee957 --- /dev/null +++ b/docs/records/event-buf.md @@ -0,0 +1,83 @@ +# 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 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 + 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 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 + 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` 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 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 + +- **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 (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 + 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/event-flags.md b/docs/records/event-flags.md new file mode 100644 index 0000000..ff7e155 --- /dev/null +++ b/docs/records/event-flags.md @@ -0,0 +1,148 @@ +# EventFlags — engineering record + +- **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 + `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 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 | +| 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 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, +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 + +**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 — `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 + 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. 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, 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 1ceb11b..a8626c3 100644 --- a/docs/records/latest-buf.md +++ b/docs/records/latest-buf.md @@ -1,14 +1,15 @@ # 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): - [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). + merged with #35 (repo-relative on the release branch): + [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 @@ -30,10 +31,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 @@ -60,10 +64,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 @@ -85,13 +92,15 @@ 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 | | No `Source` impl can arrive silently (D2, X7) | `compile_fail,E0277` doctest on `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 for the lane: 78 unit tests, 12 doctests, 4 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 @@ -138,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. diff --git a/docs/records/seq-ring.md b/docs/records/seq-ring.md new file mode 100644 index 0000000..db55cf7 --- /dev/null +++ b/docs/records/seq-ring.md @@ -0,0 +1,126 @@ +# 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 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 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 +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). + +## 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. +- **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 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 + 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, 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` 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. +- **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, + 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 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 | 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) | + +## 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. diff --git a/scripts/ci.sh b/scripts/ci.sh index 39c8dbe..4c5668c 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -138,6 +138,22 @@ 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 + + # 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 4665d38..0bc36e0 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 @@ -28,12 +29,19 @@ # 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 +# ./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 -# 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. +# 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 @@ -46,9 +54,24 @@ export CARGO_INCREMENTAL PROBE_FEATURES="" BLESS=0 +BLOCK_MATRIX=0 +LATEST_MATRIX=0 +LATEST_BLOCK_MATRIX=0 for arg in "$@"; do case "$arg" in split) PROBE_FEATURES="split" ;; + block-matrix) + PROBE_FEATURES="block-matrix" + BLOCK_MATRIX=1 + ;; + latest-matrix) + 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 @@ -57,6 +80,23 @@ 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" +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 # exists to catch was +60 on Cortex-M0+ and +78 on ESP32-S2. Shrinkage never @@ -111,14 +151,42 @@ 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 [ "$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' \ + '------------------------------' '----------' '------' '-------' '----------' '----------' +elif [ "$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 %-16s %10s %10s %10s %6s\n' \ + '------------------------------' '----------------' '---------' '------' '---------' '----' +else + 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 failed=0 +matrix_missing=0 +matrix_not_bss=0 for entry in $TARGETS; do target="$(printf '%s' "$entry" | cut -d'|' -f1)" @@ -160,19 +228,190 @@ 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" + # 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 + + 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 + # 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 + 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:--}" + # 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)" + 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:--}" '-' '-' + 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 + + 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 + # 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 + 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:--}" + 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 + 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)" + 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 }')" + 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 %6s\n' \ - "$target" "${two:--}" "${spl:--}" "${bss:--}" "${dat:-0}" + 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:--}" "${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. 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 "$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 @@ -182,6 +421,60 @@ 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' + 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 + +if [ "$LATEST_MATRIX" = "1" ] || [ "$LATEST_BLOCK_MATRIX" = "1" ]; then + if [ "$matrix_missing" -gt 0 ]; then + printf '%s LatestBuf matrix section(s) had no code-size measurement.\n' \ + "$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' + 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 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 + printf 'Run scripts/cycles.sh latest-block-matrix for state-dependent paths.\n' + fi + 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 # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Baseline gate @@ -204,8 +497,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 '# @@ -240,8 +533,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 @@ -259,8 +552,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 @@ -315,8 +608,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/scripts/codesize/Cargo.lock b/scripts/codesize/Cargo.lock index 4dc9291..4b99971 100644 --- a/scripts/codesize/Cargo.lock +++ b/scripts/codesize/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "ph-eventing" -version = "0.2.0" +version = "0.3.0" dependencies = [ "portable-atomic", ] diff --git a/scripts/codesize/Cargo.toml b/scripts/codesize/Cargo.toml index 2d9cd93..b02e8c2 100644 --- a/scripts/codesize/Cargo.toml +++ b/scripts/codesize/Cargo.toml @@ -27,6 +27,12 @@ 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 = [] +# 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/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 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/scripts/codesize/baseline.tsv b/scripts/codesize/baseline.tsv index 5c1c565..646ff3d 100644 --- a/scripts/codesize/baseline.tsv +++ b/scripts/codesize/baseline.tsv @@ -8,26 +8,74 @@ # 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 64 +armv7a-none-eabi counted_take 28 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 +armv7r-none-eabi counted_take 28 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 +riscv32imac-unknown-none-elf counted_take 8 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 +thumbv6m-none-eabi counted_take 24 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 +thumbv7em-none-eabi counted_take 22 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 seq_bss 524 +thumbv7em-none-eabi two_calls 172 thumbv7m-none-eabi bss 268 +thumbv7m-none-eabi counted_increment 44 +thumbv7m-none-eabi counted_take 22 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 seq_bss 524 +thumbv7m-none-eabi two_calls 172 thumbv8m.base-none-eabi bss 268 +thumbv8m.base-none-eabi counted_increment 44 +thumbv8m.base-none-eabi counted_take 22 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 +thumbv8m.main-none-eabi counted_take 22 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 36f38d3..2ce8a49 100644 --- a/scripts/codesize/src/lib.rs +++ b/scripts/codesize/src/lib.rs @@ -5,8 +5,28 @@ //! 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; +use ph_eventing::counted_signal::{Consumer as CountConsumer, Producer as CountProducer}; +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, SeqRing}; +#[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) -> ! { @@ -17,6 +37,16 @@ fn panic(_: &PanicInfo) -> ! { /// no flash and no startup code. Measured as `.bss.*BUF`. 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(); @@ -40,6 +70,59 @@ 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() +} + +/// 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)] @@ -56,3 +139,243 @@ 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, + } + } + }; +} + +// 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(); + +#[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(any(feature = "latest-matrix", feature = "latest-block-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]); + +#[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 = "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); +#[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. +#[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..46d804c 100755 --- a/scripts/cycles.sh +++ b/scripts/cycles.sh @@ -31,13 +31,16 @@ # 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/cycles.sh latest-matrix +# ./scripts/cycles.sh latest-block-matrix # ./scripts/verify.sh cycles # same, inside the reference image set -u @@ -47,6 +50,16 @@ 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" ;; + 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 + PROBE_DIR="scripts/cycles" ELF="$PROBE_DIR/target/thumbv7m-none-eabi/release/ph-eventing-cycles" LOG="$PROBE_DIR/target/qemu-exec.log" @@ -89,9 +102,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 +203,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 +228,15 @@ 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" + else if (group == "ef") printf "\nEventFlags (coalesced SPSC conditions)\n" + else if (group == "bp") { + printf "\nBlock completion + EventBuf publication\n" + printf " %-30s %7s %8s %8s\n", \ + "SHAPE", "instr", "block_B", "logical_B" + } + else if (group == "lb") printf "\nLatestBuf (freshness-first SPSC)\n" + else if (group == "lc") printf "\nLatestBuf sample/block composition\n" } } next @@ -221,12 +262,40 @@ 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. 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' 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.lock b/scripts/cycles/Cargo.lock index c9b8b30..7165343 100644 --- a/scripts/cycles/Cargo.lock +++ b/scripts/cycles/Cargo.lock @@ -111,7 +111,7 @@ checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" [[package]] name = "ph-eventing" -version = "0.2.0" +version = "0.3.0" [[package]] name = "ph-eventing-cycles" diff --git a/scripts/cycles/Cargo.toml b/scripts/cycles/Cargo.toml index 56e76cb..04825ca 100644 --- a/scripts/cycles/Cargo.toml +++ b/scripts/cycles/Cargo.toml @@ -10,11 +10,23 @@ 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" +[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 = [] +# 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" lto = true diff --git a/scripts/cycles/src/main.rs b/scripts/cycles/src/main.rs index 72c6b31..40b5286 100644 --- a/scripts/cycles/src/main.rs +++ b/scripts/cycles/src/main.rs @@ -26,7 +26,40 @@ use core::hint::black_box; use cortex_m_rt::entry; use cortex_m_semihosting::debug; -use ph_eventing::{EventBuf, RingBuf, SeqRing}; +#[cfg(not(any(feature = "latest-matrix", feature = "latest-block-matrix")))] +use ph_eventing::EventBuf; +#[cfg(any(feature = "latest-matrix", feature = "latest-block-matrix"))] +use ph_eventing::LatestBuf; +#[cfg(feature = "block-matrix")] +use ph_eventing::{Block, BlockBuilder}; +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] +use ph_eventing::{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"); + +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] +use ph_eventing::CountedSignal; +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] +use ph_eventing::{EventFlags, EventMask}; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { @@ -37,10 +70,12 @@ 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. +/// 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),* $(,)?) => { $( @@ -51,7 +86,7 @@ macro_rules! markers { core::arch::asm!( "mov r12, {tag}", tag = const $idx, - options(nomem, nostack, preserves_flags) + options(nostack, preserves_flags) ) }; } @@ -59,6 +94,11 @@ macro_rules! markers { }; } +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] markers! { 0 => m_end, 1 => m_overhead, @@ -83,8 +123,191 @@ 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, + 22 => m_cs_increment_saturated, + // EventFlags -- coalesced SPSC conditions + 23 => m_ef_raise_clear, + 24 => m_ef_raise_already_set, + 25 => m_ef_take_nonempty, + 26 => m_ef_take_empty, } +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] +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(); +} + +/// 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. +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] +#[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(); +} + +#[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(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, + 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(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] fn event_buf_costs() { let buf = EventBuf::::new(); let tx = buf.try_producer().expect("producer"); @@ -125,6 +348,11 @@ fn event_buf_costs() { m_end(); } +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] fn seq_ring_costs() { let ring = SeqRing::::new(); let tx = ring.try_producer().expect("producer"); @@ -173,6 +401,11 @@ fn seq_ring_costs() { m_end(); } +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] fn ring_buf_costs() { let mut ring = RingBuf::::new(); @@ -197,6 +430,419 @@ fn ring_buf_costs() { m_end(); } +#[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" +)))] +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(); +} + +#[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(); + // 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 { + 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(); + black_box(&mut rejected_fill); + }}; +} + +#[cfg(any(feature = "latest-matrix", feature = "latest-block-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 = "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 + ); +} + +#[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!( + 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")); +} + +// 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 +850,25 @@ fn main() -> ! { m_overhead(); m_end(); - event_buf_costs(); - seq_ring_costs(); - ring_buf_costs(); + #[cfg(not(any( + feature = "block-matrix", + feature = "latest-matrix", + feature = "latest-block-matrix" + )))] + { + event_buf_costs(); + seq_ring_costs(); + ring_buf_costs(); + counted_signal_costs(); + counted_signal_saturated_costs(); + event_flags_costs(); + } + #[cfg(feature = "block-matrix")] + block_publication_costs(); + #[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/event-flags-atomic-window.sh b/scripts/event-flags-atomic-window.sh new file mode 100755 index 0000000..128c9de --- /dev/null +++ b/scripts/event-flags-atomic-window.sh @@ -0,0 +1,211 @@ +#!/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. +# +# 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 + +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 + +want_esp=0 +case "${ESP:-}" in + 1|true|yes|YES) want_esp=1 ;; +esac + +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 + 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')" +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 || 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 + +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" +} + +# 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" + 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) { sections++; masked = 1; count = 0; next } + if (masked) { + count++ + 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" +} + +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')" + +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 + +# 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|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 + +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:]]((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 + # 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 + 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 + +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' +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 '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 2d5c325..166f155 100755 --- a/scripts/loom.sh +++ b/scripts/loom.sh @@ -35,9 +35,39 @@ export LOOM_MAX_PREEMPTIONS printf '==> loom (max_preemptions=%s)\n' "$LOOM_MAX_PREEMPTIONS" -if RUSTFLAGS='--cfg loom' cargo test --lib loom_tests "$@"; then - printf '\nAll Loom models verified.\n' +# 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 + case "$1" in + -*) ;; + *) + filter="loom_tests::$1" + shift + ;; + esac +fi + +# 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 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/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 new file mode 100644 index 0000000..263e0be --- /dev/null +++ b/scripts/probes/block_shape.rs @@ -0,0 +1,119 @@ +//! Probe-only structural twins of `Block`/`BlockBuilder`, pinned at the +//! evaluation revision (`bc54a9a`). +//! +//! 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)] + +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 e27eb66..63effa2 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -5,19 +5,23 @@ # 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 cycles block-matrix +# ./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. @@ -66,30 +70,56 @@ 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 ;; + 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 ;; + *) + 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 ;; + # Matrix modes need their arguments forwarded; every other single script + # routes through run_script below. + 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' failed=0 - for s in ci miri loom cycles; 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 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..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): @@ -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/block.rs b/src/block.rs new file mode 100644 index 0000000..8150b4a --- /dev/null +++ b/src/block.rs @@ -0,0 +1,429 @@ +//! 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; +//! - `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 +//! 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. +//! +//! # 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 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 +//! 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** — 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. +//! +//! **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, +//! 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}; +//! +//! let mut fill = BlockBuilder::::new(); +//! 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().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: +//! +//! ```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)] +#[must_use = "a completed block is the publishable window; dropping it discards N samples"] +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)] +#[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 { + /// 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 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(); + 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/counted_signal.rs b/src/counted_signal.rs new file mode 100644 index 0000000..fcd5efd --- /dev/null +++ b/src/counted_signal.rs @@ -0,0 +1,387 @@ +//! A saturating count for payload-free events. +//! +//! [`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. +//! +//! 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 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 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 +//! 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; + +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), + } + } + + /// 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. + #[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() + } +} + +// 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").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 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: +/// +/// ```compile_fail,E0277 +/// use ph_eventing::counted_signal::Producer; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` +pub struct Producer<'a> { + signal: &'a CountedSignal, + _not_sync: PhantomData>, +} + +impl Producer<'_> { + /// Record one occurrence. + /// + /// Below `u32::MAX` this is one Relaxed load and one Relaxed `fetch_add`. + /// 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 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 + // to zero. A plain skip on MAX can be stale after take returns, so the + // 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.fetch_or(0, Ordering::Relaxed) == u32::MAX { + 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); + } +} + +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. +/// +/// ```compile_fail,E0277 +/// use ph_eventing::counted_signal::Consumer; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` +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() { + // Contract I1, T1, T4, and A1. + 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() { + // 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(); + 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() { + // 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); + 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::>(); + } + + #[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(); + producer.increment(); + assert_eq!(consumer.take_count().count(), 1); + } + + #[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(); + 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/event_flags.rs b/src/event_flags.rs new file mode 100644 index 0000000..43e4171 --- /dev/null +++ b/src/event_flags.rs @@ -0,0 +1,461 @@ +//! 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() + } +} + +// 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").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,E0277 +/// 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 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); + } +} + +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,E0277 +/// 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_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. + 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/latest_buf.rs b/src/latest_buf.rs new file mode 100644 index 0000000..7bb095a --- /dev/null +++ b/src/latest_buf.rs @@ -0,0 +1,733 @@ +//! 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 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 +//! 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. +//! +//! 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. +//! +//! # Decision status +//! +//! 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 under-count — +//! [`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`]. +//! +//! 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). +//! +//! 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 +//! +//! ``` +//! 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; +// 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 { + generation: u32, + value: T, +} + +#[derive(Clone, Copy)] +struct ProducerState { + back_encoded: u32, + next_generation: u32, +} + +#[derive(Clone, Copy)] +struct ConsumerState { + front_encoded: 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. + /// + /// 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). + /// + /// 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, +} + +/// 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 load, at most 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_encoded: 0, + next_generation: 0, + }), + consumer_state: TrackedCell::new(ConsumerState { + front_encoded: 0, + 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_encoded: 0, + next_generation: 0, + }), + consumer_state: TrackedCell::new(ConsumerState { + front_encoded: 0, + 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. + /// + /// 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) { + 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. + /// + /// 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) { + 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 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) { + 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`]. +/// +/// 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; +/// +/// fn assert_sync() {} +/// assert_sync::>(); +/// ``` +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; + ( + LatestBuf::::producer_slot(state.back_encoded), + 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_encoded = LatestBuf::::encode_producer_slot(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`]. +/// +/// 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; +/// +/// 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 +/// 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>, +} + +impl Consumer<'_, T> { + /// Claim and copy the latest unread publication. + /// + /// Returns `None` when no publication is pending. The operation performs + /// 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 { LatestBuf::::consumer_slot((*state).front_encoded) }); + + 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_encoded = LatestBuf::::encode_consumer_slot(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 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 — 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; + }); + // 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. + (*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] + 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 6123a1a..a6722e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,20 @@ -//! Stack-allocated ring buffers for no-std embedded targets. +//! Deterministic handoff primitives for no-std embedded targets. //! //! # Primitives //! //! | 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. | +//! | [`CountedSignal`] | Saturating SPSC count for identical, payload-free events. | +//! | [`EventFlags`] | Coalesced SPSC condition set — one bit per condition, one atomic operation per hot path. | +//! | [`LatestBuf`] | Freshness-first SPSC snapshot — retains one newest unread value. | //! -//! All three are fixed-size, zero-allocation, and generic over `T: Copy`. +//! All are fixed-size and zero-allocation. The buffer types are generic +//! over `T: Copy`; [`CountedSignal`] carries no payload and [`EventFlags`] +//! provides exactly 32 payload-free conditions. //! //! # Common traits //! @@ -17,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. @@ -59,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` @@ -101,7 +116,8 @@ //! 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 +//! 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 @@ -112,12 +128,12 @@ //! - `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 must be active. Handle acquisition is `try_producer()` / -//! `try_consumer()`, which return `None` rather than panicking — on a -//! microcontroller a panic is a reset. (The panicking `producer()` / -//! `consumer()`, deprecated since 0.2.0, were removed in 0.3.0.) Using -//! unsafe to bypass these constraints is undefined behavior. +//! - `SeqRing`, `EventBuf`, and `EventFlags` are SPSC by design: exactly one +//! producer and one consumer must be active. Handle acquisition is +//! `try_producer()` / `try_consumer()`, which return `None` rather than +//! panicking — on a microcontroller a panic is a reset. (The panicking +//! `producer()` / `consumer()`, deprecated since 0.2.0, were removed in +//! 0.3.0.) Using unsafe to bypass these constraints is undefined behavior. //! //! The examples here use `.expect(...)` for brevity, which is a panic. That //! is fine in a doctest on a host; in firmware, branch on the `None`: @@ -133,9 +149,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 @@ -147,8 +168,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 @@ -172,7 +194,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. @@ -182,6 +205,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,16 +230,24 @@ enable either the portable-atomic-unsafe-assume-single-core or portable-atomic-c #[macro_use] mod macros; +pub mod block; +pub mod counted_signal; pub mod event_buf; +pub mod event_flags; +pub mod latest_buf; pub mod ring; pub mod seq_ring; pub(crate) mod sync; pub mod traits; +pub use block::{Block, BlockBuilder, FillError}; +pub use counted_signal::{CountSnapshot, CountedSignal}; pub use event_buf::EventBuf; +pub use event_flags::{EventFlags, EventMask}; +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 32c004d..a1b366d 100644 --- a/src/loom_tests.rs +++ b/src/loom_tests.rs @@ -11,14 +11,410 @@ //! 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 -use crate::{EventBuf, SeqRing}; +use crate::{CountedSignal, EventBuf, EventFlags, EventMask, LatestBuf, SeqRing}; use loom::sync::Arc; +use loom::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use loom::thread; +/// Concurrent takes partition increments between snapshots without losing or +/// duplicating them (CountedSignal contract T1-T3 and A1). +#[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 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(|| { + 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) + ); + }); +} + +/// 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); + }); +} + +/// 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(); + // 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 { + replaced[(value - 1) as usize] = producer.publish([value, value]).replaced_unread; + } + replaced + }); + + let consumer_channel = Arc::clone(&channel); + let consumer = thread::spawn(move || { + 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 = [false; 3]; + 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; + taken[(item.generation - 1) as usize] = true; + } + thread::yield_now(); + } + taken + }); + + 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" + ); + } + + // 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 + ); + }); +} + +/// 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(|| { + 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(); + // 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(); + } + assert!(!producer.publish([2, 2]).replaced_unread); + producer_phase.store(3, Ordering::Relaxed); + while producer_phase.load(Ordering::Relaxed) < 4 { + thread::yield_now(); + } + assert!(!producer.publish([3, 3]).replaced_unread); + }); + + let consumer_channel = Arc::clone(&channel); + let consumer_phase = Arc::clone(&phase); + let consumer = thread::spawn(move || { + 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 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(); + } + 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); + }); +} + +/// 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] +fn latest_buf_producer_reacquisition_continues_across_threads() { + loom::model(|| { + let channel = Arc::new(LatestBuf::::new()); + let first_dropped = Arc::new(AtomicBool::new(false)); + let first_channel = Arc::clone(&channel); + let first_signal = Arc::clone(&first_dropped); + let first = thread::spawn(move || { + let producer = first_channel.try_producer().unwrap(); + 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 || { + 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(); + // Continuation is unconditional: generation resumes, never restarts. + assert_eq!(second.join().unwrap(), 2); + }); +} + +/// 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_dropped = Arc::new(AtomicBool::new(false)); + + let first_channel = Arc::clone(&channel); + 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: 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); + }); + + // 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 !first_dropped.load(Ordering::Relaxed) { + thread::yield_now(); + } + { + 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 in this thread's program order") + }); + + first.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); + }); +} + /// Every item the producer pushes is popped exactly once, in order, with no /// duplicates and no losses — under every interleaving. /// @@ -129,6 +525,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. @@ -210,3 +673,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(); + }); +} diff --git a/src/seq_ring.rs b/src/seq_ring.rs index afaa052..29f2551 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 //! @@ -57,13 +60,23 @@ //! 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 //! 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 @@ -100,8 +113,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. @@ -112,6 +127,49 @@ //! 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 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 +//! 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, 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 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 +//! 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, +//! [`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. @@ -177,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, } @@ -483,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 { @@ -507,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 { @@ -521,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, @@ -533,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) { @@ -875,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/src/traits.rs b/src/traits.rs index d77ff5b..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. @@ -49,6 +56,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