Claude 1282c3a08d feat(cluster): RFC 010 c7b — discovery Strategy, static seeds, connector dial loop
Phase 2 gate: 3-node mesh under the subprocess harness, repeatable (10/10).

Strategy (ratified): push-based, spawned as its own actor by the connector —
it emits Discovery events into a channel whenever it learns something and
may run forever; the connector owns all retry/backoff state. StaticSeeds
announces its list once and exits. Discovery is #[non_exhaustive] and
additive-only (candidates announced, never withdrawn) so expiry can land
later without breaking strategies.

One-viable correction to the ratified Discovery shape, flagged: a candidate
is a (name, addr) PAIR, not a bare address. The dial path and the D7
tie-break are keyed by peer name (the dial intent must be registered before
connecting so a crossing inbound Hello sees it), so an anonymous dial would
reintroduce exactly the simultaneous-connect flap D7 exists to prevent.
Discovery mechanisms know names — that is what they discover.

Connector: plain select-loop actor (the c6 shape) folding cmd inbox,
discovery stream, membership stream, and the earliest retry deadline into
one wait. It tracks who is up by SUBSCRIBING TO MEMBERSHIP like any
consumer — first consumer of c7a's snapshot-then-stream surface, no
privileged channel into the manager. Backoff: 250ms doubling to a 5s cap
(the c6c class of one-viable constants), reset on node_up; node_down
schedules a prompt redial with a fresh sequence. A candidate bearing the
local name is parked (that seed is us); every other failure retries — in
particular NameTaken can be our own ghost at the peer, not yet reaped by
its liveness timer, so it must not park. Dials run inline in the loop, the
acceptor's deliberate serialization (each attempt bounded by the connect +
handshake deadlines).

cluster::start(Config {node_name, meta, listen_addr, strategy}) is now the
integrated node start: supervised manager + acceptor + connector. It
completes the node identity: build_hash = cluster::BUILD_HASH (first
consumer, closing the c6d loose end) and incarnation = self_incarnation()
— unix-epoch MILLIS truncated to u32, not seconds: a supervised
crash-and-restart inside one second is routine, and seconds would collide
the ghost with its successor. Cluster handle: local_addr()/local()/
shutdown(); drop stops acceptor+connector loops, manager subtree detaches
(same split as AcceptorHandle alone).

Roadmap-binding, asserted in review: no consumer touches the connection
table — Manager.conns and ConnEntry stay private; the only exposures are
Call::Peers (sorted names, pre-existing) and the membership surface.

tests/cluster_mesh.rs 2/0, 10/10 flake runs: (1) 3-node mesh forms; kill
one (SIGKILL via Drop, per the retractable-state trap: roles park forever)
=> node_down at both survivors; restart same name => new incarnation at
every observer, distinguishable from the ghost; (2) seed unreachable at
start (pre-reserved closed port; accepted micro steal-window, documented)
then arriving later => edge forms via the retry path. All cluster suites
regression-clean (envelope 15, handshake 11, transport 11, lifecycle 1,
liveness 3, connect 9, two_node 3, membership 4); clippy --lib green both
configs; fmt clean; default build compiles.
2026-08-15 07:14:13 +00:00
2026-05-26 23:14:46 +02:00

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.


S
Description
SMARM - Smarm, Marks Actor Runtime Machinery
Readme MIT
1.5 MiB
Languages
Rust 98.7%
Python 1%
Shell 0.2%