nexus-rt
Single-threaded, event-driven runtime primitives with pre-resolved dispatch.
nexus-rt provides the building blocks for constructing runtimes where
user code runs as handlers dispatched over shared state. It is not an
async runtime — there is no task scheduler, no work stealing, no Future
polling. Your main() is the executor.
Design
nexus-rt is heavily inspired by Bevy ECS.
Handlers as plain functions, Param for declarative dependency
injection, Res<T> / ResMut<T> wrappers, change detection via
sequence stamps, the Plugin trait for composable registration — these
are Bevy's ideas, and in many cases the implementation follows Bevy's
patterns closely (including the HRTB double-bound trick that makes
IntoHandler work). Credit where it's due: Bevy's system model is
an excellent piece of API design.
Where nexus-rt diverges is the target workload. Bevy is built for
simulation: many entities mutated per frame, parallel schedules,
component queries over archetypes. nexus-rt is built for event-driven
systems: singleton resources, sequential dispatch, and monotonic sequence
numbers instead of frame ticks. There are no entities, no components,
no archetypes — just a typed resource store where each event advances
a sequence counter and causality is tracked per-resource.
The result is a much smaller surface area tuned for low-latency event processing rather than game-world state management.
Architecture
Build Time Dispatch Time
┌──────────────────┐ ┌──────────────────────┐
│ │ │ │
│ WorldBuilder │ │ World │
│ │ │ │
│ ┌────────────┐ │ build() │ ┌────────────────┐ │
│ │ Registry │──┼──────────────►│ │ ResourceSlot[] │ │
│ │ TypeId→Idx │ │ │ │ ptr+changed_at │ │
│ └────────────┘ │ │ └───────┬────────┘ │
│ │ │ │ │
│ install_plugin │ │ get(id) ~3 cyc │
│ install_driver │ │ │ │
└──────────────────┘ └──────────┼───────────┘
│ │
│ returns Handle │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ Driver Handle │ │ poll(&mut World) │
│ │ │ │
│ Pre-resolved │──────────────►│ 1. next_sequence() │
│ ResourceIds │ │ 2. get resources │
│ │ │ 3. poll IO source │
│ Owns pipeline │ │ 4. dispatch events │
│ or handlers │ │ via pipeline │
└──────────────────┘ └──────────────────────┘
Flow
- Build — Register resources into
WorldBuilder. Install plugins (fire-and-forget resource registration) and drivers (returns a handle). - Freeze —
builder.build()produces an immutableWorld. AllResourceIdvalues are dense indices, valid for the lifetime of the World. - Poll loop — Your code calls
driver.poll(&mut world)in a loop. Each driver owns its event lifecycle internally: poll IO, decode events, dispatch through its pipeline, mutate world state. - Sequence — Each event gets a monotonic sequence number via
world.next_sequence(). Change detection is causal:changed_atrecords which event caused the mutation.
Dispatch tiers
| Tier | Purpose | Overhead |
|---|---|---|
| Pipeline | Pre-resolved step chains inside drivers. The workhorse. | ~2 cycles p50 |
| Callback | Dynamic per-instance context + pre-resolved params. | ~2 cycles p50 |
| Handler | Box<dyn Handler<E>> for type-erased dispatch. |
~2 cycles p50 |
| Template | Pre-resolved handler stamping for re-registration. | ~1 cycle p50 (generate) |
| DAG | Monomorphized fan-out / merge data-flow graphs. | ~1-3 cycles p50 |
| FanOut / Broadcast | Static or dynamic fan-out by reference. | ~2 cycles p50 |
All tiers resolve Param state at build time. Dispatch-time cost is
a bounds-checked index into a Vec — no hashing, no searching.
Quick Start
use ;
let mut builder = new;
builder.;
let mut world = builder.build;
let mut handler = tick.into_handler;
handler.run;
assert_eq!;
Driver Model
Drivers are event sources. The Driver trait handles installation;
the returned handle is a concrete type with its own poll() signature.
use ;
// Handle defines its own poll signature — NOT a trait method.
The executor is your main():
let mut wb = new;
wb.install_plugin;
let timer = wb.install_driver;
let io = wb.install_driver;
let mut world = wb.build;
loop
Features
World — typed singleton store
Type-erased resource storage with dense ResourceId indexing. ~3 cycles
per dispatch-time access. Frozen after build — no inserts, no removes.
(Bevy analogy: World, but singletons only — no entities, no components,
no archetypes.)
Res / ResMut — resource parameters
Declare resource dependencies in function signatures. Res<T> for shared
reads, ResMut<T> for exclusive writes. ResMut stamps changed_at on
DerefMut — the act of writing is the change signal.
(Bevy analogy: Res<T> and ResMut<T>, same names and semantics.)
Optional resources
Option<Res<T>> and Option<ResMut<T>> resolve to None if the type
was not registered, rather than panicking at build time. Useful for
handlers that can operate with or without a particular resource.
Param — build-time / dispatch-time resolution
The Param trait is the mechanism behind Res<T>, ResMut<T>,
Local<T>, and all other handler parameters. Two-phase resolution:
- Build time —
Param::init(registry)resolves opaque state (e.g. aResourceId) and panics if the required type isn't registered. - Dispatch time —
Param::fetch(world, state)uses the cached state to produce a reference in ~3 cycles.
(Bevy analogy: SystemParam.)
Built-in impls: Res<T>, ResMut<T>, Option<Res<T>>,
Option<ResMut<T>>, Local<T>, RegistryRef, (), and tuples up to
8 params.
Handler / IntoHandler — fn-to-handler conversion
IntoHandler converts a plain fn into a Handler trait object.
Event E is always the last parameter; everything before it is resolved
as Param from a Registry.
(Bevy analogy: IntoSystem / System trait.)
let mut handler = tick.into_handler;
handler.run;
Named functions only — closures do not work with IntoHandler due to
Rust's HRTB inference limitations with GATs (same limitation as Bevy).
Pipeline — pre-resolved processing chains
Typed composition chains where each step is a named function with
Param dependencies resolved at build time.
let mut pipeline = new
.then // Order → Result<Order, Error>
.and_then // Order → Result<Order, Error>
.catch // Error → () (side effect)
.map // Order → Receipt
.build; // → Pipeline<Order, _> (concrete)
pipeline.run;
Option and Result combinators (.map(), .and_then(), .catch(),
.filter(), .unwrap_or(), etc.) enable typed flow control without
runtime overhead. Pipeline implements Handler<In>, so it can be
boxed or stored alongside other handlers.
Batch pipeline — per-item processing over a buffer
build_batch(capacity) produces a BatchPipeline that owns a
pre-allocated input buffer. Each item flows through the same chain
independently — errors are handled per-item, not per-batch.
let mut batch = new
.then // Order → Result<Order, Error>
.catch // handle error, continue batch
.map // runs for valid items only
.then
.build_batch;
// Driver fills input buffer
batch.input_mut.extend_from_slice;
batch.run; // drains buffer, no allocation
No intermediate buffers between steps. The compiler monomorphizes the per-item chain identically to the single-event pipeline.
DAG Pipeline — fan-out, merge, and data-flow graphs
DagStart builds a monomorphized data-flow graph where topology is
encoded in the type system. After monomorphization the entire DAG is
a single flat function — all values are stack locals, no arena, no
vtable dispatch.
use ;
use DagStart;
let mut wb = new;
wb.;
let mut world = wb.build;
let reg = world.registry;
let mut dag = new
.root
.fork
.arm
.arm
.merge
.then
.build;
dag.run;
// root: 10, arm_a: 11, arm_b: 30, merge: 41
assert_eq!;
Fan-out arms borrow the fork output by reference — no Clone needed.
Option and Result combinators (.map(), .and_then(), .catch(),
etc.) work on both the main chain and within arms. Dag implements
Handler<E>, so it can be boxed or stored alongside other handlers.
For linear chains without fan-out, prefer Pipeline.
FanOut / Broadcast — handler-level fan-out
FanOut dispatches the same event by reference to a fixed set of
handlers. Zero allocation, concrete types, monomorphizes to direct
calls. Macro-generated for arities 2-8.
Broadcast is the dynamic variant — stores Vec<Box<dyn RefHandler<E>>>
for runtime-determined handler counts.
use ;
use ;
let mut builder = new;
builder.;
builder.;
let mut world = builder.build;
let h1 = write_a.into_handler;
let h2 = write_b.into_handler;
let mut fan = fan_out!;
fan.run;
assert_eq!;
assert_eq!;
Handlers inside combinators receive &E. Use Cloned or Owned
adapters for handlers that expect owned events.
For fan-out with merge (data flowing back together), use DagStart.
Change detection
Each resource tracks a changed_at sequence number. Drivers call
world.next_sequence() before each event dispatch to advance the
sequence counter.
// Read side — check if a resource was modified this sequence
// Write side — ResMut stamps changed_at on DerefMut automatically
Plugin — composable registration
Fire-and-forget resource registration units. Consumed by WorldBuilder.
(Bevy analogy: Plugin.)
wb.install_plugin;
Local — per-handler state
Local<T> is state stored inside the handler instance, not in World.
Initialized with Default::default() at handler creation time. Each
handler instance gets its own independent copy — two handlers created
from the same function have separate Local values.
(Bevy analogy: Local<T>.)
let mut handler_a = count_events.into_handler;
let mut handler_b = count_events.into_handler;
handler_a.run; // handler_a local=1
handler_b.run; // handler_b local=1 (independent)
handler_a.run; // handler_a local=2
Callback — context-owning handlers
Callback<C, F, Params> is a handler with per-instance owned context.
Use it when each handler instance needs private state that isn't shared
via World — per-timer metadata, per-connection codec state, protocol
state machines.
Convention: fn handler(ctx: &mut C, params..., event: E) — context
first, Param-resolved resources in the middle, event last.
let mut cb = on_timeout.into_callback;
cb.run;
// Context is pub — accessible outside dispatch
assert_eq!;
HandlerTemplate / CallbackTemplate — resolve once, stamp many
When handlers are created repeatedly on the hot path — IO readiness
re-registration, timer rescheduling, connection accept loops — each
into_handler(registry) call pays for HashMap lookups to resolve the
same ResourceId values every time.
Templates resolve parameters once, then generate() stamps out
handlers by copying pre-resolved state. ~1 cycle vs ~20-70 cycles
for into_handler.
A [Blueprint] declares the event and parameter types. The template
resolves them against the registry once:
use ;
use ;
;
let mut builder = new;
builder.;
let mut world = builder.build;
let template = new;
// Stamp out handlers — no HashMap lookups, just Copy.
let mut h1 = template.generate;
let mut h2 = template.generate;
h1.run;
h2.run;
assert_eq!;
For context-owning handlers, CallbackTemplate works the same way —
each generate(ctx) takes an owned context value:
;
# let mut builder = new;
# builder.;
# let mut world = builder.build;
let cb_template = new;
let mut cb = cb_template.generate;
cb.run;
assert_eq!;
Convenience macros reduce Blueprint boilerplate:
use handler_blueprint;
handler_blueprint!;
Constraints:
P::State: Copy— excludesLocal<T>with non-Copy state (incompatible with template stamping). All World-backed params (Res,ResMut,Optionvariants) haveState = ResourceIdwhich isCopy.- Zero-sized callables only — named functions and captureless closures. Capturing closures and function pointers are rejected at compile time.
RegistryRef — runtime handler creation
RegistryRef is a Param that provides read-only access to the
Registry during handler dispatch. Enables handlers to create new
handlers at runtime via IntoHandler::into_handler or
IntoCallback::into_callback.
Installer — event source installation
Installer is the install-time trait for event sources. The installer
registers its resources into WorldBuilder and returns a concrete
poller whose poll() method drives the event lifecycle. See the
Driver Model section for the full pattern.
Timer driver (feature: timer)
Integrates nexus_timer::Wheel as a driver. TimerInstaller registers
the wheel into WorldBuilder and returns a TimerPoller.
TimerPoller::poll(world, now)drains expired timers and fires handlers- Handlers reschedule themselves via
ResMut<TimerWheel<S>> Periodichelper for recurring timers- Inline storage variants behind
smartptrfeature:InlineTimerWheel,FlexTimerWheel
Mio driver (feature: mio)
Integrates mio as an IO driver. MioInstaller registers the
MioDriver (wrapping mio::Poll + handler slab) and returns a
MioPoller.
MioPoller::poll(world, timeout)polls for readiness and fires handlers- Move-out-fire pattern: handler is removed from slab, fired, and must re-insert itself to receive more events
- Stale tokens (already removed) are silently skipped
- Inline storage variants behind
smartptrfeature:InlineMio,FlexMio
Virtual / FlatVirtual / FlexVirtual — storage aliases
Type aliases for type-erased handler storage:
use Virtual;
// Heap-allocated (default)
let handler: = Boxnew;
// Behind "smartptr" feature — inline storage via nexus-smartptr
// use nexus_rt::FlatVirtual;
// let handler: FlatVirtual<Event> = flat!(my_handler.into_handler(registry));
Virtual<E> for heap-allocated. FlatVirtual<E> for fixed inline
(panics if handler doesn't fit). FlexVirtual<E> for inline with
heap fallback.
When to Use What
| Situation | Use | Why |
|---|---|---|
| One-time setup, test harness | IntoHandler / IntoCallback |
Simple, direct. Construction cost paid once. |
| Pipeline steps inside a driver | Pipeline / BatchPipeline |
Zero-cost monomorphized chains, typed flow control. |
| IO re-registration (accept, echo) | HandlerTemplate / CallbackTemplate |
Handler recreated every event — template eliminates per-event HashMap lookups. |
| Timer rescheduling | HandlerTemplate / CallbackTemplate |
Same pattern — recurring handlers should not pay construction cost repeatedly. |
| Type-erased handler storage | Box<dyn Handler<E>> / Virtual<E> |
When you need heterogeneous collections (driver slabs, timer wheels). |
| Per-instance private state | Callback (via IntoCallback) or CallbackTemplate |
Context-owning handlers for connection state, timer metadata, etc. |
| Composable resource registration | Plugin |
Fire-and-forget, consumed by WorldBuilder. |
| Fan-out with merge | DagStart → Dag |
Monomorphized data-flow graph. Zero vtable, all stack locals. |
| Static fan-out (known count) | FanOut / fan_out! |
Dispatch &E to N handlers. Zero allocation, concrete types. |
| Dynamic fan-out (runtime count) | Broadcast |
Vec<Box<dyn RefHandler>>. One heap alloc per handler, zero clones. |
Rule of thumb: If a handler is created once, use IntoHandler. If
it's created repeatedly on every event (move-out-fire pattern), use a
template. For data that must fan out and merge back, use DagStart.
For fire-and-forget fan-out, use FanOut (static) or Broadcast
(dynamic).
Performance
All measurements in CPU cycles, pinned to a single core with turbo boost disabled.
Dispatch (hot path)
| Operation | p50 | p99 | p999 |
|---|---|---|---|
| Baseline hand-written fn | 2 | 3 | 4 |
| 3-stage pipeline (bare) | 2 | 2 | 4 |
| 3-stage pipeline (Res<T>) | 2 | 3 | 5 |
| Handler + Res<T> (read) | 2 | 4 | 5 |
| Handler + ResMut<T> (write) | 3 | 8 | 8 |
| Box<dyn Handler> | 2 | 9 | 9 |
Pipeline dispatch matches hand-written code — zero-cost abstraction confirmed.
Batch throughput
Total cycles for 100 items through the same pipeline chain.
| Operation | p50 | p99 | p999 |
|---|---|---|---|
| Batch bare (100 items) | 130 | 264 | 534 |
| Linear bare (100 calls) | 196 | 512 | 528 |
| Batch Res<T> (100 items) | 390 | 466 | 612 |
| Linear Res<T> (100 calls) | 406 | 550 | 720 |
Batch dispatch amortizes to ~1.3 cycles/item for compute-heavy chains (~1.5x faster than individual calls).
Construction (cold path)
| Operation | p50 | p99 | p999 |
|---|---|---|---|
| into_handler (1 param) | 21 | 30 | 79 |
| into_handler (4 params) | 45 | 86 | 147 |
| into_handler (8 params) | 93 | 156 | 221 |
| .then() (2 params) | 28 | 48 | 96 |
Construction cost is paid once at build time, never on the dispatch hot path.
Template generation (hot path handler creation)
| Operation | p50 | p99 | p999 |
|---|---|---|---|
| generate (1 param) | 1 | 1 | 2 |
| generate (2 params) | 1 | 1 | 2 |
| generate (4 params) | 1 | 1 | 1 |
| generate (8 params) | 1 | 1 | 1 |
| generate callback (2 params) | 1 | 2 | 2 |
| generate callback (4 params) | 1 | 1 | 1 |
generate() copies pre-resolved ResourceId values — flat 1 cycle
at every arity. Compare with into_handler above: 24-70x faster for
handlers created on every event (IO re-registration, timer rescheduling).
Running benchmarks
Limitations
Named functions only
IntoHandler, IntoCallback, and IntoStep (arity 1+) require named
fn items — closures do not work due to Rust's HRTB inference limitations
with GATs. This is the same limitation as Bevy's system registration.
Arity-0 pipeline steps (no Param) do accept closures:
// Works — arity-0 closure
pipeline.then;
// Does NOT work — arity-1 closure with Param
// pipeline.then(|config: Res<Config>, x: u32| x, registry);
// Works — named function
pipeline.then;
Single-threaded
World is !Sync by design. All dispatch is single-threaded, sequential.
This is intentional — for latency-sensitive event processing, eliminating
coordination overhead matters more than parallelism.
Frozen after build
No resources can be added or removed after WorldBuilder::build(). All
registration happens at build time. This enables dense indexing and
eliminates runtime bookkeeping.
Examples
mock_runtime— Complete driver model: plugin registration, driver installation, explicit poll looppipeline— Pipeline composition: bare value, Option, Result with catch, build into Handlerlocal_state— Per-handler state withLocal<T>, independent across handler instancesoptional_resources— Optional dependencies withOption<Res<T>>/Option<ResMut<T>>perf_pipeline— Dispatch latency benchmarks with codegen inspection probesperf_construction— Construction-time latency benchmarks at various aritiesperf_template— Template generation vsinto_handlerconstruction benchmarksperf_fetch— Fetch dispatch strategy benchmarksmio_timer— Echo server combining mio and timer drivers with template construction benchmarks
License
See workspace root for license details.