rsignals 0.1.0

One-shot multi-signaler / multi-waiter fail-safe broadcast event for thread and task coordination (MPMC, sync + async, zero deps, no unsafe)
Documentation
  • Coverage
  • 100%
    57 out of 57 items documented5 out of 45 items with examples
  • Size
  • Source code size: 124.5 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 1.25 MB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 3s Average build duration of successful builds.
  • all releases: 3s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • wushilin/rsignals
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • wushilin

rsignals

A one-shot, multi-signaler / multi-waiter, fail-safe broadcast event for Rust.

Why

rsignals exists to provide a performant, reliable, and easy-to-use signal service for multi-thread (and multi-task) coordination.

The usual Rust one-shot primitives are SPSC — single producer, single consumer: one oneshot::Sender, one Receiver, and cloning either side is not part of the model. Real coordination problems are often wider than that: any of several workers may be the one to report "ready" / "shutdown" / "found it", and many threads or tasks need to hear about it at once.

rsignals is MPMC by design: any number of signalers, any number of waiters, all sharing one event. The first signal wins and releases everyone — with clear, mpsc-style errors on every edge (all signalers gone, all waiters gone) so nothing ever blocks forever or fires into the void. Signaling is wait-free, waiting has a lock-free fast path, and the whole crate is dependency-free std with no unsafe.

One shared event, any number of senders and receivers. The first signal wins and releases every waiter; late waiters observe the fired state immediately; and the event is fail-safe on both sides — if all senders vanish, waiters are told; if all receivers vanish, the sender is told.

Both a blocking (rsignals::sync) and a runtime-agnostic async (rsignals::r#async) variant are provided, with identical semantics and error types. Zero dependencies.

The async module is spelled r#async because async is a Rust keyword — use rsignals::r#async; then r#async::create().

The model

An event is a tiny state machine with three states:

State Meaning
Pending No signal yet, and at least one signaler still exists.
Fired Some signaler called signal(); every waiter is released.
Disconnected Every signaler was dropped without firing — no signal can ever arrive, so every waiter is released and told so.

Fired and Disconnected are terminal and mutually exclusive: exactly one transition out of Pending ever succeeds.

Rules

  • One-shot: the first signal() wins and returns Ok(true); every later call returns Ok(false).
  • Broadcast: all current waiters wake; any waiter arriving after the event is already terminal returns immediately with the same outcome.
  • Fail-safe for waiters: if the last signaler is dropped while pending, waiters are released with Err(Disconnected) instead of blocking forever.
  • Fail-safe for signalers: if every waiter has been dropped, signal() returns Err(NoReceivers) and does not fire (there is no one to receive it).
  • Shared state: handles are cheap to clone; every clone shares one underlying event. The state lives as long as any handle does.

API at a glance

create() -> (Signaler, Waiter)

Signaler::signal()          -> Result<bool, NoReceivers>   // Ok(true)=won, Ok(false)=already fired
Signaler::is_signaled()     -> bool
Signaler / Waiter :: signaler_count() / waiter_count() -> usize   // live-handle snapshots

// sync (blocking)
Waiter::wait()              -> Result<(), Disconnected>
Waiter::wait_timeout(dur)   -> Result<(), WaitTimeoutError>        // Timeout | Disconnected
Waiter::try_wait()          -> Result<(), TryWaitError>            // Pending | Disconnected

// future (async) — same as above, but:
Waiter::wait()              -> impl Future<Output = Result<(), Disconnected>>
// (no wait_timeout: wrap `wait()` with your runtime's timeout combinator)

The wait side mirrors std::sync::mpsc::Receiver: Ok(()) means the event fired, and each non-fired outcome has its own error variant so timeout, disconnect, and still-pending are never conflated. signal() mirrors std::sync::mpsc::Sender::send, which fails once the receiver is gone.

Usage

Blocking

use std::thread;
use rsignals::sync::create;

let (tx, rx) = create();
let rx2 = rx.clone();

let waiter = thread::spawn(move || rx2.wait()); // blocks

assert_eq!(tx.signal(), Ok(true));          // first signal wins
assert_eq!(waiter.join().unwrap(), Ok(()));  // waiter released
assert_eq!(tx.signal(), Ok(false));          // one-shot: already fired

With a timeout:

use std::time::Duration;
use rsignals::sync::create;
use rsignals::WaitTimeoutError;

let (tx, rx) = create();
assert_eq!(rx.wait_timeout(Duration::ZERO), Err(WaitTimeoutError::Timeout));
tx.signal().unwrap();
assert_eq!(rx.wait_timeout(Duration::from_secs(1)), Ok(()));

Async

Runtime-agnostic — works under Tokio, async-std, smol, or any executor:

let (tx, rx) = rsignals::r#async::create();

tokio::spawn(async move {
    match rx.wait().await {
        Ok(())              => println!("fired!"),
        Err(_disconnected)  => println!("all signalers gone"),
    }
});

tx.signal().unwrap();

// Timeouts are a runtime concern — compose with your executor:
// let _ = tokio::time::timeout(Duration::from_secs(1), rx.wait()).await;

Fail-safe edges

use rsignals::sync::create;
use rsignals::{Disconnected, NoReceivers};

// All signalers dropped -> waiters learn the event will never fire.
let (tx, rx) = create();
drop(tx);
assert_eq!(rx.wait(), Err(Disconnected));

// All waiters dropped -> a signal has nowhere to go.
let (tx, rx) = create();
drop(rx);
assert_eq!(tx.signal(), Err(NoReceivers));

Design & correctness

The full design rationale is in plan.md. Highlights:

  • The Pending -> {Fired, Disconnected} transition is a single atomic compare-exchange (Release on success), so exactly one signaler — or the last signaler drop — wins. signal() is wait-free.
  • Sync: a mutex + condvar handle blocking. The winner takes the mutex before notify_all, and waiters re-check the predicate under the mutex before sleeping — the standard handshake that avoids lost wakeups. wait() has a lock-free Acquire fast path.
  • Async: parked tasks register their Waker in a mutex-guarded map; a terminal transition drains and wakes them after releasing the lock. poll re-checks the terminal state while holding the registry lock before parking (the async analog of the condvar predicate check), and a dropped Wait future deregisters its waker — so cancellation is clean and leak-free.
  • Live-handle counts are maintained in each handle's Clone/Drop. The waiter count is monotonic-to-zero (a waiter can only be cloned from a live one), which is what makes NoReceivers reliable.

Tests

cargo test        # unit, integration, doc-tests
cargo clippy --all-targets

Coverage includes: the full plan.md checklist, many-signaler/many-waiter races under barriers, disconnect and no-receiver edges, a model-based fuzzer (thousands of random op-sequences checked against a reference state machine), randomized wait_timeout timing, and async tests (including manual-poll waker-wiring and future cancellation) driven by a tiny dependency-free block_on.

License

Apache-2.0 — see LICENSE.