Outbound (D13, ratified 2026-08-15): a module-private table node → dedicated Sender<Frame>, populated/torn down by the manager inside the same serialized handlers that own connection lifetime (Register / Disconnect / reap / terminate), on RuntimeInner beside the exposure state. remote::send is one leaf-lock lookup + one channel send: no gen_server on the data plane (rejected send-through-manager — worse than the c8 argument, it is the data plane), no published channel a leaked conn pid could inject raw frames into (rejected send_dyn-at-conn-pid — module privacy cannot fence a published channel). Ok(()) = handed to the connection's inbox, local knowledge only (RFC §3); missing entry / closed channel = NotConnected. The outbound sender is a SEPARATE channel from cmd_tx on purpose: a clone would let the table hold lifetime authority (D9 violation); its closure is not a stop signal. Buffering toward a slow peer is unbounded (BEAM busy_dist_port shape); backpressure out of scope, documented not silently absent. Inbound: remote::deliver_named is THE resolution seam (RFC v2) — exposed-set check (unexposed = unreachable, the safety) → hash check against what the name was exposed with → registry whereis → c8's decode_deliver. Verdicts are local-only (InboundVerdict, discarded by the conn actor for now; a trace hook is the place). The conn actor gains a third select arm (outbound inbox → wire) and interprets SendNamed; Send/Monitor/Down are consumed for liveness and ignored until c10/c11. Public surface: RemoteName<M> (node, Name<M>), RemoteSendError, NotConnected, send, send_remote_raw (untyped escape hatch so tests can put deliberately wrong frames on the wire — the typed API cannot express a hash mismatch). CORE (found the hard way this chunk): publishing a second channel of the same message type on one live actor silently replaced and CLOSED the first, so a select/recv on it returned 'closed' immediately forever — a hot loop starving the single-threaded scheduler. publish_channel now asserts when an existing same-type channel's receiver is still alive AND the new sender is not a clone of it (Sender::same_channel via Arc::ptr_eq); replacing a dead-receiver channel stays silent (an actor re-registering after dropping its inbox is legit). Message names the sanctioned shapes. Three registry tests pin panic / cloned-sender-ok / dead-receiver-ok. Full default suite clean. Harness: wait_line drains stderr before dumping on timeout/EOF (eprintln! diagnostics no longer vanish); Node::transcript() accessor for ordering-proof assertions. tests/cluster_remote_send.rs 1/0, 10/10 flake runs, subprocess harness: cross-node name-send delivers; unexposed name unreachable (registered locally, never delivered); wrong hash never misroutes (unknown-hash and known-hash-wrong-channel flavours); send to an unconnected node = NotConnected locally; every frame-bearing send is Ok — 'handed to transport', asserted and documented at the test. Negatives are proven by STREAM ORDERING (they precede the positive on one in-order connection), not by sleeping. All cluster suites regression-clean (envelope 15, handshake 11, transport 11, lifecycle 1, liveness 3, connect 9, two_node 3, membership 4, mesh 2, expose 5); clippy --lib green both configs; fmt clean; default build compiles.
smarm
SMARM — Smarm, Marks Actor Runtime Machinery. A proof-of-concept green-thread actor runtime for Rust.
Implements the core ideas in Achitecture.md: green-thread actors on a
shared heap, scheduled cooperatively, communicating only by Send messages.
Erlang's isolation model without Erlang's copying GC, Rust's zero-copy
ownership transfers without async's function colouring.
The scheduler is multi-threaded — one OS thread per available CPU, all drawing
from a shared run queue. The single-threaded run() entry point is kept as a
convenience wrapper around runtime::init(Config::exact(1)).run(f).
What's here
| Module | What it does |
|---|---|
stack |
mmap'd growable stack with guard page; SIGSEGV on overflow |
context |
#[naked] x86-64 context-switch shims, callee-saved regs only |
preempt |
Allocator-driven preemption; check!() macro for no-alloc loops |
pid |
(index, generation) PIDs; stale handles are detectable, not silent |
actor |
Trampoline + catch_unwind boundary at the actor entry point |
scheduler |
Run queue, slot table, spawn/join, parking, idle path |
channel |
Unbounded MPSC channel; recv parks the actor; recv_timeout bounds it; select/select_timeout park on many receivers at once (ready-index, priority order) |
mutex |
Mutex<T> with mandatory timeout; FIFO waiters; parks the green thread |
timer |
Min-heap of (deadline, reason); Sleep and WaitTimeout reasons |
io |
block_on_io for blocking work; wait_readable/wait_writable + read/write via epoll |
supervisor |
Signal::Exit/Panic/Stopped funnelled to a parent; OneForOne/OneForAll/RestForOne strategies + restart-intensity cap |
monitor |
monitor(pid) → Monitor { id, target, rx }; one-shot Down via rx; demonitor(&m) tears one registration down; unidirectional death notice |
link |
bidirectional link/unlink; abnormal death propagates (cooperative stop, or an ExitSignal message under trap_exit) |
gen_server |
call/call_timeout (sync request-reply) / cast (async) over one inbox; handle_info over static info arms + handle_down via Watcher-fed monitors, selected ahead of the inbox; ServerRef/ServerBuilder + init/terminate hooks; server-down via channel closure |
registry |
register/whereis/name_of: name ↔ pid bimap; lazy generation-checked cleanup |
Quick taste
use smarm::{run, spawn, channel};
run(|| {
let (tx, rx) = channel::<i64>();
let h = spawn(move || {
for _ in 0..3 {
let v = rx.recv().unwrap();
println!("got {v}");
}
});
for v in 1..=3i64 {
tx.send(v).unwrap();
}
h.join().unwrap();
});
Layout
src/
stack.rs context.rs preempt.rs pid.rs actor.rs
scheduler.rs channel.rs mutex.rs timer.rs io.rs
supervisor.rs monitor.rs link.rs runtime.rs
gen_server.rs lib.rs
tests/
per-module integration tests
benches/
primes.rs fan-out/fan-in compute, vs tokio current_thread
Building and running
Standard Cargo. Requires Rust 1.95 or newer (the #[naked] attribute went stable
in 1.88; we use a few unrelated post-1.88 features). master is x86-64 Linux
only. An experimental, untested aarch64 context-switch backend lives on the
arm-port branch (extracted into a target_arch-gated src/arch/); it has not
been validated on hardware yet. macOS remains on the deferred list because of the
epoll dependency.
cargo test # all tests
cargo test --test mutex # one module
cargo bench # primes benchmark vs tokio
What's not here
See the Defer section of Architecture.md.
join! for handle groups, stack growth via remap,
hierarchical timer wheel, fd-wait timeouts, Signal::Timeout. Each is
mechanism we know how to add; none belongs in this iteration.
Docs
| Document | What it covers |
|---|---|
Architecture.md |
Design intent, runtime model, and deferred work |
smarm - Deep Dive.html |
Generated walkthrough of the system; good starting point |
BENCHMARKS_AND_TUNING.md |
Where smarm wins and loses vs tokio, preemption knob recommendations |
benchmarks.md |
Raw benchmark results, methodology, and tuning experiment log |
Contributing
This is a personal proof-of-concept. There's no PR workflow. If you fork it and do something interesting, just send me an email. If it's nice, I'll upstream the changes.