ph-eventing 0.1.4

Stack-allocated ring buffers for no-std embedded targets
Documentation

ph-eventing

Crates.io docs.rs CI: local License: MIT MSRV no_std

Stack-allocated ring buffers for no-std embedded targets.

What's in the box

Type Use case
RingBuf<T, N> Single-owner ring buffer — simple, no atomics, &mut access.
SeqRing<T, N> Lock-free SPSC ring that overwrites old entries (lossy, high-throughput).
EventBuf<T, N> Lock-free SPSC ring with backpressure — rejects pushes when full.

All three are fixed-size, #![no_std], zero-allocation, and generic over T: Copy.

Features

  • Three ring buffer flavours: single-owner, lossy SPSC, and backpressure SPSC.
  • Common Sink/Source/Link traits for writing generic event-processing code.
  • forward(src, snk, max) utility to bridge any SourceSink.
  • No heap, no dynamic dispatch, no required dependencies.
  • Optional portable-atomic support for targets without native 32-bit atomics.
  • Designed for #![no_std] environments (std only for tests).

Compatibility

  • MSRV: Rust 1.92.0.
  • SeqRing::new() and EventBuf::new() assert N > 0.
  • SeqRing and EventBuf require 32-bit atomics by default.
  • For thumbv6m-none-eabi (and other no-atomic targets), enable one of:
    • portable-atomic-unsafe-assume-single-core
    • portable-atomic-critical-section (requires a critical-section implementation in the binary)
  • Those two are mutually exclusive — they select different portable-atomic backends, and enabling both fails inside portable-atomic. Cargo features are additive, so this cannot be expressed in the manifest; build.rs detects the combination and explains it.
  • Consequently --all-features does not work for this crate and cannot be made to. Check combinations individually; scripts/ci.sh enumerates the supported set.

Usage

RingBuf

A straightforward, single-owner ring buffer for collecting values when you don't need cross-thread access. When full, new pushes silently overwrite the oldest entry.

use ph_eventing::RingBuf;

let mut ring = RingBuf::<u32, 4>::new();
ring.push(1);
ring.push(2);
ring.push(3);
assert_eq!(ring.latest(), Some(3));
assert_eq!(ring.get(0),  Some(1)); // oldest

// iterate oldest → newest
for val in ring.iter() {
    // 1, 2, 3
}

SeqRing

A lock-free SPSC ring for high-rate telemetry. The producer never blocks; the consumer reports drops when it lags behind by more than N.

use ph_eventing::SeqRing;

let ring = SeqRing::<u32, 64>::new();
let producer = ring.producer();
let mut consumer = ring.consumer();

producer.push(123);
assert_eq!(consumer.poll_one_value(), Some((1, 123)));
// hook form still available:
// consumer.poll_one(|seq, v| { ... });

EventBuf

A bounded SPSC queue with backpressure. When the buffer is full, push returns Err(val) so the producer can decide what to do — no data is silently lost.

use ph_eventing::EventBuf;

let buf = EventBuf::<u32, 2>::new();
let producer = buf.producer();
let consumer = buf.consumer();

assert!(producer.push(1).is_ok());
assert!(producer.push(2).is_ok());
assert_eq!(producer.push(3), Err(3)); // full — value returned

assert_eq!(consumer.peek(), Some(1));  // copy, no advance
assert_eq!(consumer.pop(), Some(1));
assert!(producer.push(3).is_ok());     // space freed

Common Traits

All producers implement Sink<T> and all consumers implement Source<T>, so you can write generic code that works with any combination:

use ph_eventing::{SeqRing, EventBuf};
use ph_eventing::traits::{Source, Sink, forward};

// bridge a SeqRing producer → EventBuf consumer
let seq = SeqRing::<u32, 8>::new();
let sp = seq.producer();
let mut sc = seq.consumer();

sp.push(1); sp.push(2);

let eb = EventBuf::<u32, 8>::new();
let mut ep = eb.producer();

let (n, err) = forward(&mut sc, &mut ep, 10);
assert_eq!(n, 2);
assert!(err.is_none());
Trait Role Implementors
Sink<T> Accept events RingBuf, seq_ring::Producer, event_buf::Producer
Source<T> Yield events seq_ring::Consumer, event_buf::Consumer
Link<In,Out> Both Blanket impl for Sink<In> + Source<Out>

Semantics

RingBuf

  • Single-owner (&mut self to push).
  • get(i) returns the i-th element where 0 is the oldest.
  • latest() returns the most recently pushed element.
  • iter() yields elements oldest → newest.

SeqRing

  • Sequence numbers are monotonically increasing u32 values; 0 is reserved for "empty".
  • When the producer wraps the ring, old values are overwritten.
  • poll_one and poll_up_to drain in-order and return PollStats (read, dropped, newest).
  • poll_one_value / latest_value return (seq, T) without a hook.
  • latest reads the newest value without advancing the consumer cursor.
  • skip_to_latest discards the backlog so the next poll returns the newest item.
  • 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.

EventBuf

  • FIFO order: pop always returns the oldest item.
  • peek copies the oldest item without advancing the cursor.
  • push returns Ok(()) on success or Err(val) when the buffer is full.
  • 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.

Safety and Concurrency

  • RingBuf is a plain struct with no interior mutability — standard Rust borrow rules apply.
  • SeqRing and EventBuf are SPSC by design: exactly one producer and one consumer may be active. producer()/consumer() will panic if called while another handle of the same kind is active. Using unsafe to bypass these constraints (or sharing handles concurrently) is undefined behavior.
  • T: Copy is required by all types to avoid allocation and return values by copy.
  • EventBuf is race-free by construction: its producer and consumer never touch the same slot, and it passes Miri with the data-race detector enabled.
  • SeqRing is a seqlock and carries a known formal data race — the consumer may copy a slot 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.
    • 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.
    • It is a deliberate trade. A ring restricted to a word-sized payload could store it in an atomic and be fully race-free; accepting any T: Copy is what rules that out. Generality was chosen over formal soundness.
    • EventBuf has no such caveat and passes Miri with the detector on — but it is not a drop-in, since it applies backpressure instead of overwriting.
    • Full reasoning, including the alternatives and why each was rejected, is in the seq_ring module docs.

Using it across contexts

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<T, N> and EventBuf<T, N> are Sync when T: Send, so &buf can be handed to both contexts. Producer and Consumer are Send + !Sync — move each one into the context that owns it, and never share a single handle between contexts. There is no way to get a second Producer while one is live: producer() panics rather than handing out a duplicate; try_producer / try_consumer return None instead.
  • The buffer must outlive both handles. The handles borrow it, so the usual answer is to own the buffer where it lives longest.
  • new() is not a const fn, so you cannot write static BUF: EventBuf<u32, 64> = EventBuf::new(); directly. Use a StaticCell, a OnceCell, or a binding in main that outlives the tasks borrowing it. This is the one ergonomic wrinkle on embedded targets and it is worth knowing before you design around it.

Choosing N

N is the slot count, fixed at compile time, and the whole buffer lives inline — N * size_of::<T>() bytes of stack or static, with no allocation.

  • For EventBuf, N is your backpressure threshold: the point at which push starts returning Err. Size it for the largest burst you are willing to absorb between drains.

  • For SeqRing, N is how far the consumer may lag before it starts losing entries. Size it for the worst-case gap between polls, not for the average.

  • N need not be a power of two — no indexing or capacity logic requires it — but for SeqRing a power of two is still the better default. SeqRing addresses slots by (seq - 1) % N while push skips the reserved sequence 0, so a full cycle is 2^32 - 1 sequences and the slot walk only lines up across the wrap when N divides 2^32 - 1. What that costs, once per wrap:

    N Entries dropped at the wrap
    A power of two Exactly 1
    A divisor of 2^32 - 1 (3, 5, 15, 17, 51, 85, 255, 257, 65537, …) 0
    Anything else Up to N - 1N = 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 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 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:

Evidence What it establishes
Loom models Exhaustive: every interleaving and every legal relaxed-load value, for the modelled size
Miri UB, data races, and weak-memory behaviour; also run on 32-bit and big-endian targets
58 unit + 7 doctests Behaviour, including threaded stress tests for both SPSC types
3 embedded targets thumbv6m / thumbv7em / riscv32imac compile checks

One known deviation. SeqRing is a seqlock and carries a formal data race — see Safety and Concurrency above. EventBuf is race-free by construction and passes Miri with the detector enabled.

Coverage is around 93% of lines, though it is a weak signal here: what matters is ordering and interleaving, which line coverage cannot see.

Contributors: CONTRIBUTING.md has the commands for running all of the above locally. CI runs on every PR, but it covers only part of that list — coverage, Miri, and Loom are local-only, so a green check is not a clean matrix.

License

MIT. See LICENSE.