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#asyncbecauseasyncis a Rust keyword —use rsignals::r#async;thenr#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 returnsOk(true); every later call returnsOk(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()returnsErr(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 thread;
use create;
let = create;
let rx2 = rx.clone;
let waiter = spawn; // blocks
assert_eq!; // first signal wins
assert_eq!; // waiter released
assert_eq!; // one-shot: already fired
With a timeout:
use Duration;
use create;
use WaitTimeoutError;
let = create;
assert_eq!;
tx.signal.unwrap;
assert_eq!;
Async
Runtime-agnostic — works under Tokio, async-std, smol, or any executor:
let = r#create;
spawn;
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 create;
use ;
// All signalers dropped -> waiters learn the event will never fire.
let = create;
drop;
assert_eq!;
// All waiters dropped -> a signal has nowhere to go.
let = create;
drop;
assert_eq!;
Design & correctness
The full design rationale is in plan.md. Highlights:
- The
Pending -> {Fired, Disconnected}transition is a single atomic compare-exchange (Releaseon 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-freeAcquirefast path. - Async: parked tasks register their
Wakerin a mutex-guarded map; a terminal transition drains and wakes them after releasing the lock.pollre-checks the terminal state while holding the registry lock before parking (the async analog of the condvar predicate check), and a droppedWaitfuture 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 makesNoReceiversreliable.
Tests
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.