The BEAM (Bogdan/Bjorn's Erlang Abstract Machine) and OTP (Open Telecom Platform) define the gold standard for fault-tolerant distributed systems primitives, refined over 35 years of production use at Ericsson, WhatsApp, and thousands of other systems. This document maps the full BEAM/OTP primitive surface to Nulang's architecture, categorizing each primitive as Adopt, Adapt, Replace, or Omit based on Nulang's existing design.
Document status. Sections 1–16 are the original design map: ADOPT/ADAPT/REPLACE/OMIT are design decisions, not implementation claims, and several rows (originally marked ADOPTED) overstated what exists. Those rows have been corrected inline. For the verified, source-checked state of the runtime — what is implemented, what is stubbed, and what does not exist — see Section 17: Implementation Status (Ground Truth).
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
spawn(Fun) |
IMPLEMENTED | spawn ActorType { field = value } |
Parser + Spawn opcode (0x80) → ActorVmCallbacks::spawn_actor. Field-init record, not a function. |
spawn(Module, Fun, Args) |
ADAPT | spawn ActorType(args) |
Nulang uses typed actor constructors rather than dynamic module references. Positional-arg spawn is not implemented (only field init). |
spawn_link(Fun) |
IMPLEMENTED | spawn link ActorType { field = value } |
Parser desugar to spawn + perform Actor.link(child) on the spawner. Bidirectional fault propagation for supervision trees. |
spawn_monitor(Fun) |
IMPLEMENTED | spawn monitor ActorType { field = value } |
Same desugar with Actor.monitor(child); DOWN notification on exit. (No MonitorRef return — Actor.demonitor(target) takes the target.) |
spawn_opt(Fun, Options) |
ADAPT — NOT IMPLEMENTED | spawn ActorType with options { ... } |
Nulang should support: priority, scheduler_hint, max_heap_size, link, monitor. None of these options exist today. |
Design Note: Nulang's typed actors eliminate the need for apply/3 and dynamic function calls. As implemented, spawn returns an ActorRef directly (a NaN-boxed actor id), not Result[ActorRef, SpawnError] — spawn cannot fail in the current runtime.
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
self() |
IMPLEMENTED | self keyword → SelfOp opcode (0x83) |
Returns the current actor's ActorId as an ActorRef. |
pid_to_list(Pid) |
OMIT | — | Not needed; actor ids are plain integers. |
list_to_pid(String) |
OMIT | — | Unsafe in typed system. |
is_process_alive(Pid) |
ADOPT — NOT IMPLEMENTED | actor.is_alive(actor_ref) |
Essential for liveness checks. No such builtin exists. |
process_info(Pid) |
ADAPT — NOT IMPLEMENTED | actor.info(actor_ref) |
Returns typed ActorInfo record: mailbox size, memory, reductions, links, monitors, current behavior. Fields exist on the Rust Actor struct; no language-level accessor. |
processes() |
ADAPT — NOT IMPLEMENTED | actor.list() |
Returns list of all actor refs on the node. (Runtime.actors keys in Rust.) |
register(Name, Pid) |
IMPLEMENTED (runtime API) | Runtime::registry.register(name, id) |
Local name registry with name validation. Returns Result[Unit, RegisterError]. No Nulang-level actor.register builtin. |
unregister(Name) |
IMPLEMENTED (runtime API) | Runtime::registry.unregister(name) |
Remove from local registry. Also auto-removed on actor exit (unregister_by_actor). |
whereis(Name) |
IMPLEMENTED (runtime API) | Runtime::registry.whereis(name) |
Returns Option<u64>. |
registered() |
IMPLEMENTED (runtime API) | Runtime::registry.registered() |
Returns list of registered names. |
Design Note: OTP's global registry (global:register_name/2) is planned to be subsumed by Nulang's virtual actor system (Orleans-style identity-based addressing), which is not yet implemented — see §6.3. Local registration (register/2) exists today as the ActorRegistry (src/runtime/registry.rs) and is exercised by the test suite, but it is not reachable from Nulang source code.
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
exit(Pid, Reason) |
IMPLEMENTED (runtime API) | Runtime::exit_actor(id, reason) / Runtime::kill_actor(id) |
Terminates the actor and runs exit handling (DOWN messages, link propagation, supervisor notification). No language-level actor.exit builtin; the Exit opcode (0x89) is defined but unhandled by the VM. |
exit(Reason) |
ADOPT — NOT IMPLEMENTED | exit(reason) |
Exit the current actor from Nulang source. |
kill (reason) |
IMPLEMENTED (with caveat) | ExitReason::Kill via Runtime::kill_actor |
Documented as untrappable in src/types.rs, but handle_actor_exit treats it like any abnormal reason — a trap_exits actor currently converts it to a message. The Killed variant is defined but never constructed. |
normal (reason) |
IMPLEMENTED | ExitReason::Normal |
Normal termination, no link propagation, no supervisor restart for Transient children. |
process_flag(trap_exit, true) |
IMPLEMENTED (runtime API) | Actor.trap_exits field |
Convert exit signals to System-priority messages. Honored in Runtime::handle_actor_exit; settable only from Rust (no language builtin). |
process_flag(priority, Level) |
IMPLEMENTED (see §17.5 item 7) | perform Actor.set_priority(0|1|2) |
Actor priority field exists since v0.13; scheduler drains High → Normal → Low (FIFO within level). MessagePriority is stored on messages but not consulted by the mailbox. See erratum in §17.5 item 7. |
Design Note: ExitReason is implemented in src/types.rs as:
type ExitReason =
| Normal
| Kill -- untrappable
| Killed -- a Kill that propagated to this actor
| Shutdown(Option[Duration])
| Error(String)
| Custom(String)
Kill maps to DOWN reason code 2, Killed to 3 (see §17 for the DOWN message encoding).
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
Pid ! Message |
IMPLEMENTED | send actor_ref behavior(args) |
Non-blocking, asynchronous → Send opcode (0x81) → Runtime::send_message_by_id. Note: the <- operator shown in earlier drafts is not Nulang syntax (the lexer tokenizes <- but the parser never uses it); ! is unary-not. |
Name ! Message |
ADAPT — NOT IMPLEMENTED | registered_name <- message |
Send to registered name. Requires an explicit whereis lookup today (registry is Rust-only). |
{Name, Node} ! Message |
IMPLEMENTED (runtime API) | Runtime::send_distributed(ActorAddress, behavior, args) |
ActorAddress::Local/Remote give location-transparent routing. Remote sends carry the behavior name on the wire; the receiver resolves it to a behavior id on delivery (unknown names fall back to behavior 0, mirroring local sends). |
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
receive ... end |
IMPLEMENTED (non-blocking) | receive { | Behavior(params) => expr } |
Selective receive is wired end-to-end: MIR lowering (lower_receive in src/mir_lower.rs) resolves arm names to behavior-table indices and emits OpCode::ReceiveMatch (0x8F); the VM scans the mailbox in FIFO order via ActorVmCallbacks::try_receive_match (Mailbox::receive_match), skips non-matching messages (left queued), binds payload values to arm params (missing → nil, extras ignored), and dispatches to the arm body. Non-blocking: no-match falls back to pop-any (nil when empty). No after timeout yet. |
Nulang receive Design:
-- Basic receive with pattern matching
let msg = receive {
| TemperatureReading(temp) => temp
| ErrorSignal(reason) => {
perform io.println("Error: " ++ reason)
0.0
}
}
-- Receive with timeout (after)
let result = receive {
| Response(data) => Ok(data)
| Error(reason) => Error(reason)
} after Duration.milliseconds(5000) {
Error("Timeout waiting for response")
}
-- Nested/repeated receive (server loop)
behavior server_loop(state: ServerState) {
receive {
| Get(key, reply_to) => {
let value = Map.get(state.store, key)
reply_to <- Reply(value)
server_loop(state)
}
| Put(key, value) => {
let new_state = { state .. store = Map.insert(state.store, key, value) }
server_loop(new_state)
}
| Stop => {
perform io.println("Server stopping")
exit(Normal)
}
}
}
Design Note: Unlike Erlang's receive (which scans the entire mailbox), Nulang's receive should compile to efficient mailbox matching. Messages that don't match are left in the mailbox for future receives. The after clause provides timeout semantics for request-response patterns.
Reality check: the basic-receive example above is implemented today (behavior-name arms, payload binding, selective mailbox scan via OpCode::ReceiveMatch / Mailbox::receive_match). Two differences from Erlang remain: receive never blocks — when no queued message matches, a legacy fallback pops the next message and yields its first payload value (nil when empty) — and the after clause is not in the grammar. Message delivery to running actors still primarily happens through behavior dispatch in Runtime::step_actor; the legacy pop-any Receive opcode (0x84) remains as the no-match fallback path.
OTP's selective receive is both powerful and problematic — it can cause mailbox bloat when messages don't match any pattern. Nulang should:
- Support selective receive (required for Erlang compatibility patterns) — implemented (mailbox-order scan, non-matching messages stay queued; non-blocking)
- Provide mailbox inspection (
actor.mailbox_size(self)) for monitoring — exists asMailbox::len()in Rust; no language builtin - Warn at compile time if a behavior has a
receivewith no catch-all pattern (potential mailbox leak) — not implemented - Support
receivewithflushto clear non-matching messages after timeout — not implemented
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
link(Pid) |
IMPLEMENTED (runtime API) | Runtime::link_actors(a, b) |
Bidirectional fault propagation. Abnormal exit of either side terminates the other (or delivers a System message if it traps exits). Link opcode (0x87) is defined but unhandled by the VM; no language builtin. |
unlink(Pid) |
IMPLEMENTED (runtime API) | Runtime::unlink_actors(a, b) |
Remove link. |
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
erlang:monitor(process, Pid) |
IMPLEMENTED (runtime API) | Runtime::monitor(watcher, target) |
Monitoring a dead/unknown target immediately delivers DOWN with reason Error("noproc"). Monitor opcode (0x85) is defined but unhandled by the VM; no language builtin. |
erlang:demonitor(Ref) |
IMPLEMENTED (runtime API) | Runtime::demonitor(watcher, target) |
Remove monitor. Identified by (watcher, target) actor-id pair — there is no MonitorRef type. |
erlang:demonitor(Ref, [flush]) |
ADOPT — NOT IMPLEMENTED | actor.demonitor(monitor_ref, flush: true) |
Remove and flush pending DOWN message. The implemented demonitor has no flush variant. |
Design Note: Monitors are stored as watcher-id lists on the target actor (Actor.monitors). On death, Runtime::handle_actor_exit sends each watcher a System-priority DOWN message with behavior_id = 0 and payload [target_id, watcher_id, reason_code] where reason codes are Normal=0, Error=1, Kill=2, Killed=3, Shutdown=4, Custom=5. The typed MonitorMessage/MonitorRef surface below remains the design target:
type MonitorMessage =
| Down { monitor_ref: MonitorRef, actor_ref: ActorRef, reason: ExitReason }
-- Monitoring pattern
let monitor_ref = actor.monitor(worker)
receive {
| Down { monitor_ref = m, reason, .. } if m == monitor_ref => {
perform io.println("Worker died: " ++ reason.to_string())
-- Restart worker
let new_worker = spawn_link Worker
server_loop(new_worker)
}
}
Status: ADAPT as behavior patterns
Erlang's gen_server callback module pattern should be available as a Nulang behavior mixin:
actor KeyValueStore {
use GenServer -- OTP gen_server pattern
state durable store: Map[String, String] = Map.empty()
-- handle_call: synchronous request-response
behavior get(key: String): Option[String] {
Map.get(store, key)
}
-- handle_cast: asynchronous fire-and-forget
behavior put(key: String, value: String) {
store = Map.insert(store, key, value)
}
-- handle_info: catch-all for non-behavior messages
on_info message {
match message {
| SystemMessage(ReloadConfig) => reload_config()
| _ => perform io.println("Unknown message: " ++ message.to_string())
}
}
}
Key gen_server primitives:
| gen_server Function | Nulang Status | Nulang Form |
|---|---|---|
gen_server:start_link/4 |
NOT IMPLEMENTED | spawn link Store {..} exists; no gen_server behavior mixin yet (Phase 2) |
gen_server:call/2 |
IMPLEMENTED | ask store get("key") — Ask opcode (0x82) → Runtime::ask_actor_sync (synchronous, single-threaded runtime) |
gen_server:cast/2 |
IMPLEMENTED | send store put("key", "value") (not <-) |
gen_server:reply/2 |
OMIT | Built into ask/behavior return |
gen_server:stop/1 |
IMPLEMENTED (runtime API) | Runtime::exit_actor(id, ExitReason::Normal); no actor.stop builtin |
gen_server:abcast/2 |
ADAPT — NOT IMPLEMENTED | Distributed broadcast via cluster.broadcast/2 — no broadcast API exists |
The use GenServer mixin, state durable sugar, and on_info catch-all shown in the example above are design sketches — none of them parse today. Actor state is declared per-field and given a StateModel (Local/Durable/EventSourced/Crdt) through the runtime API.
Status: IMPLEMENTED — state_machine keyword desugars to an ordinary actor; see src/ast.rs:desugar_state_machine. The desugared actor carries a _sm_state string field, one behavior per event, and inline on_entry/on_exit dispatch. Event targets must be declared state names (the parser rejects handler-function targets like gen_statem's handle_data).
State machines are critical for protocol handling, workflow engines, and AI agent state management:
state_machine TcpConnection {
state Closed
state Connecting
state Connected
event connect(address): Connecting
event connection_established: Connected
event disconnect: Closed
on_entry Connected {
perform IO.print("up")
}
on_exit Connected {
perform IO.print("down")
}
}
Key gen_statem primitives:
| gen_statem Function | Nulang Status | Nulang Form |
|---|---|---|
gen_statem:call/2 |
IMPLEMENTED | ask fsm event works on the desugared actor |
gen_statem:cast/2 |
IMPLEMENTED | send fsm event works on the desugared actor |
| Event actions | IMPLEMENTED | Declarative event handlers with state transitions via desugared actor behaviors |
| State enter/exit | IMPLEMENTED | on_entry / on_exit hooks inlined into transition bodies; self-transitions run both exit and entry hooks |
| Handler-function targets | REJECTED | Event targets must be declared state names; the parser rejects handler-function targets with a clear error |
Status: ADAPT as event_bus behavior — NOT IMPLEMENTED (use EventBus does not parse)
Event buses are essential for pub/sub patterns, logging pipelines, and metrics collection:
actor MetricsBus {
use EventBus
behavior add_handler(handler: ActorRef) {
add_event_handler(handler)
}
behavior report(metric: Metric) {
notify(metric)
}
}
Status: IMPLEMENTED (runtime API + language surface) — supervision trees exist in src/runtime/supervisor.rs and are exercised by unit and stress tests. Nulang-level supervisor DSL available via perform Otp.create_supervisor/set_template/start_child/terminate_child/child_count built-in effects (dispatched through Runtime::perform_otp_builtin).
OTP supervisor primitives to ensure are complete:
| Supervisor Primitive | Nulang Status | Nulang Form |
|---|---|---|
supervisor:start_link/2 |
IMPLEMENTED (language surface) | perform Otp.create_supervisor(name, strategy) |
supervisor:start_child/2 |
IMPLEMENTED (language surface) | perform Otp.supervise_child(sup, child, policy) / perform Otp.start_child(sup) (dynamic) |
supervisor:terminate_child/2 |
IMPLEMENTED (language surface) | perform Otp.terminate_child(sup, child) (clean Normal exit, bypasses restart policy) |
supervisor:restart_child/2 |
IMPLEMENTED (runtime API) | Supervisor::restart_child(actor_id, runtime) |
supervisor:delete_child/2 |
IMPLEMENTED (internal) | Supervisor::remove_child (private; invoked on Temporary/normal-Transient exits) |
supervisor:which_children/1 |
IMPLEMENTED (language surface) | perform Otp.child_count(sup) / Supervisor::children field |
| Restart strategies | IMPLEMENTED: 4 of 4 | one_for_one, one_for_all, rest_for_one, simple_one_for_one (template-based dynamic children spawned on demand) |
Restart semantics: four restart strategies (now including SimpleOneForOne), three restart policies (Permanent, Temporary, Transient), per-child rate limiting (max_restarts = 5 within restart_window_secs = 60 by default, tracked per child-spec id), and escalation — exceeding the limit returns SupervisorAction::Shutdown, which cascades to the parent supervisor. Bytecode/behavior restoration for restarted children works via RestartTemplate (state snapshot + behavior table + bytecode module captured at supervise_child time); dynamic children (SimpleOneForOne) are spawned from the template module via spawn_from_module, so they always start from the actor type's declared state defaults.
Status: ADAPT as actor.local_table — NOT IMPLEMENTED (no Table type, no capability table; the example below is aspirational)
ETS is critical for fast in-memory key-value access within a node. Nulang should provide ETS-like tables as a capability-gated feature:
actor CacheService {
capability table -- Grants access to local tables
state local cache: Table[String, CachedItem] = Table.new(
type = Set, -- Set, OrderedSet, Bag, DuplicateBag
keypos = 1, -- Position of key element
read_concurrency = true,
write_concurrency = true
)
behavior get(key: String): Option[CachedItem] {
Table.lookup(cache, key)
}
behavior put(key: String, value: CachedItem) {
Table.insert(cache, (key, value))
}
}
Key ETS primitives:
| ETS Function | Nulang Status | Nulang Form |
|---|---|---|
ets:new/2 |
ADAPT | Table.new(options) |
ets:insert/2 |
ADAPT | Table.insert(table, row) |
ets:lookup/2 |
ADAPT | Table.lookup(table, key) |
ets:delete/1,2 |
ADAPT | Table.delete(table) / Table.delete(table, key) |
ets:match/1,2,3 |
OMIT | Use Table.filter() with Nulang lambdas |
ets:foldl/3 |
ADAPT | Table.fold(table, init, fn) |
ets:tab2list/1 |
ADAPT | Table.to_list(table) |
ets:info/1 |
ADAPT | Table.info(table) |
ets:select/1,2,3 |
OMIT | Replaced by typed Table.filter() and Table.query() |
Design Note: ETS's match_spec (a mini-query language) is powerful but untyped. Nulang should replace it with typed filter/query functions. ETS tables should be actor-local (not globally accessible) to maintain capability safety.
Status: ADOPT as persistent_term — NOT IMPLEMENTED
persistent_term (OTP 21.2+) provides zero-copy global immutable terms, perfect for configuration and compiled patterns:
-- Store: O(1), no copying
persistent_term.put(http_config, config)
-- Read: O(1), no copying, no GC impact
let config = persistent_term.get(http_config)
| persistent_term Primitive | Nulang Status |
|---|---|
persistent_term:put/2 |
ADOPT |
persistent_term:get/1,2 |
ADOPT |
persistent_term:erase/1 |
ADOPT |
Status: REPLACE with persistent actors + CRDTs
Mnesia (Erlang's distributed database) is largely subsumed by Nulang's design:
| Mnesia Feature | Nulang Replacement |
|---|---|
| In-memory tables | Persistent actors with local state |
| Disk-backed tables | Persistent actors with durable state |
| Distributed replication | Persistent actors with crdt state |
| Transactions | Workflow step with compensation |
| Schema management | Actor type definitions |
dirty_read/write |
Direct behavior calls |
qlc queries |
Nulang's List/Array operations |
Mnesia's complex transaction semantics and schema evolution are pain points. Nulang's actor-centric persistence is simpler and more robust.
Implementation status of the replacement: the persistent-actor layer is real and tested — PersistenceStore (src/runtime/persistence.rs) with MemoryStore, JsonFileStore, and LibsqlStore (libsql/Turso) backends; per-field StateModel (Local/Durable/EventSourced/Crdt); journal + snapshot checkpointing; and an 8-variant WorkflowEvent journal for event-sourced workflow actors with deterministic replay on recovery. The CRDT row is also implemented — 8 CRDT types (GCounter, PNCounter, GSet, ORSet, AWORSet, LWWRegister, MVRegister, RGA) behind CrdtManager, synced over CrdtSync packets. The workflow step with compensation row is implemented as saga compensation for workflow steps.
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
node() |
IMPLEMENTED (runtime API) | NodeId opcode (0xD0); Runtime::node_id |
Returns current NodeId. No cluster.this_node() builtin. |
nodes() |
IMPLEMENTED (runtime API) | ClusterState::all_members() |
Returns list of known nodes. |
nodes(connected) |
IMPLEMENTED (runtime API) | ClusterState::healthy_members() |
Explicit connected filter. |
nodes(visible) |
ADOPT — NOT IMPLEMENTED | cluster.visible_nodes() |
No visible/hidden node distinction; NodeStatus is Joining/Healthy/Suspicious/Failed/Leaving. |
is_alive() |
IMPLEMENTED (runtime API) | Runtime::distributed_enabled |
Whether distribution is enabled (enable_distribution binds the transport). |
net_kernel:connect_node/1 |
IMPLEMENTED (runtime API) | Runtime::join_cluster(seed_addr) |
Gossip-based cluster join (ClusterState::join_cluster). |
erlang:monitor_node/2 |
ADAPT — NOT IMPLEMENTED | cluster.monitor_node(node_id) |
Receive nodedown / nodeup messages. Node failure produces ClusterAction::NodeFailed internally but is not delivered to actors as messages. |
erlang:set_cookie/2 |
OMIT | — | Replaced by capability-based authentication (planned; no auth on the wire today). |
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
{Name, Node} ! Message |
IMPLEMENTED (runtime API) | Runtime::send_distributed(ActorAddress, behavior, args) |
Location-transparent routing via AddressResolver + LRU RemoteActorCache (10,000 entries). Remote ActorMessage packets carry the behavior name; the receiver resolves it via Runtime::behavior_id_for on delivery (unknown names fall back to behavior 0, mirroring local sends). The RSend opcode (0xD2) is a no-op in the VM. |
rpc:call/4 |
IMPLEMENTED (partial) | RAsk opcode (0xD3) → DistributedVmCallbacks::remote_ask(target, behavior, args, 5000ms) |
Type-safe RPC. Only through the VM callback; returns nil when no distributed runtime is attached. |
rpc:multicall/4 |
ADAPT — NOT IMPLEMENTED | cluster.multicall(nodes, behavior, args) |
Parallel RPC to multiple nodes. |
rpc:cast/4 |
ADAPT — NOT IMPLEMENTED | cluster.cast(node, behavior, args) |
Fire-and-forget remote call. |
rpc:abcast/3 |
ADAPT — NOT IMPLEMENTED | cluster.broadcast(behavior, args) |
Broadcast to all connected nodes. |
rpc:sbcast/3 |
ADAPT — NOT IMPLEMENTED | cluster.broadcast_sync(behavior, args) |
Synchronous broadcast. |
spawn(Node, ...) |
STUB | distributed::spawn_on_node sends Packet::SpawnRequest |
The receiver never handles SpawnRequest (dropped in process_network_packets); RSpawn opcode (0xD4) returns actor_ref(0). Remote spawn does not work end-to-end. |
Status: REPLACE with virtual actors — NOT IMPLEMENTED (no virtual keyword; the example below is aspirational)
Erlang's global module provides cluster-wide name registration. Nulang's virtual actors (identity-based, location-transparent) provide the same capability more elegantly:
-- Instead of global:register_name(KeyValueService, Pid)
-- Nulang uses virtual actor identity:
let store = virtual KeyValueStore("user-cart-123")
store <- add_item(item) -- Routes to whichever node hosts it
| global Primitive | Nulang Replacement |
|---|---|
global:register_name/2 |
virtual Actor("identity") |
global:unregister_name/1 |
Actor lifecycle management |
global:whereis_name/1 |
Transparent routing (no explicit lookup needed) |
global:re_register_name/2 |
Virtual actor reactivation on new node |
global:sync/0 |
CRDT-based state convergence |
| BEAM Primitive | Nulang Status | Nulang Form | Rationale |
|---|---|---|---|
erlang:send_after/3 |
IMPLEMENTED (runtime API) | TimerWheel::send_after(delay, target, behavior_id, payload) → TimerId |
Critical for timeouts, retries, scheduled tasks. Min-heap wheel, driven by Runtime::tick_timers on every scheduler loop iteration. No timer.* language builtins. |
erlang:start_timer/3 |
IMPLEMENTED (runtime API) | TimerWheel::send_after(...) returns TimerId |
Same as send_after in Nulang's model. |
erlang:cancel_timer/1 |
IMPLEMENTED (runtime API) | TimerWheel::cancel(TimerId) |
Lazy cancellation (flag checked at fire time); returns bool, not remaining time. |
erlang:read_timer/1 |
IMPLEMENTED (runtime API) | TimerWheel::remaining(TimerId) |
Returns Option[Duration]. |
timer:apply_after/4 |
OMIT | — | Use send_after with behavior message. |
timer:exit_after/2 |
IMPLEMENTED (runtime API) | TimerWheel::exit_after(delay, target, reason) |
Exits actor with ExitReason::Error(reason) after timeout. |
timer:kill_after/1 |
IMPLEMENTED (runtime API) | TimerWheel::kill_after(delay, target) |
Unconditional kill (ExitReason::Kill) after timeout. |
timer:sleep/1 |
IMPLEMENTED (workflow-scoped) | perform Timer.sleep(name, duration_ms) inside a workflow step |
A durable workflow timer: journaled (TimerSet/TimerFired events) and re-armed on recovery. There is no general blocking time.sleep; the Time effect exists for tracking only. |
| BEAM Primitive | Nulang Status | Nulang Form |
|---|---|---|
erlang:yield/0 |
REPLACE | Automatic: the scheduler preempts an actor after max_reductions = 1000 reductions (Actor::should_yield) and re-enqueues it if the mailbox is non-empty. The Yield opcode (0x8A) is defined but never emitted or handled; no scheduler.yield() builtin. |
erlang:hibernate/3 |
ADAPT — NOT IMPLEMENTED | actor.hibernate() |
erlang:garbage_collect/0,1 |
ADAPT — NOT IMPLEMENTED | gc.collect() / gc.collect(actor_ref) |
erlang:system_monitor/2 |
ADAPT — NOT IMPLEMENTED | system.set_monitor(callback) |
Status: ADAPT for module reloading — NOT IMPLEMENTED. Nulang compiles to its own register bytecode (interpreted + Cranelift JIT), not to WASM, so the WASM-shaped examples below are design sketches. Persistent actors do survive runtime restarts via snapshot/journal recovery (Runtime::recover_actor + register_recovery_module), which is the only "code reload" adjacency that exists today.
Hot code reloading is one of Erlang's killer features. Nulang should support it at the module level:
-- Load new version of actor module
perform code.load("MyActor", wasm_bytes)
-- Upgrade running actors (OTP gen_server code_change pattern)
behavior code_change(old_version: String, state: State): State {
-- Migrate state from old version to new version
migrate_state(state, from: old_version, to: CURRENT_VERSION)
}
| Code Loading Primitive | Nulang Status | Nulang Form |
|---|---|---|
code:load_file/1 |
ADAPT | code.load(module_name, wasm_module) |
code:purge/1 |
ADAPT | code.purge(module_name) |
code:soft_purge/1 |
ADAPT | code.soft_purge(module_name) |
code:delete/1 |
ADAPT | code.unload(module_name) |
code:which/1 |
ADAPT | code.which(module_name) |
code:get_path/0 |
ADAPT | code.load_path() |
code:add_path/1 |
ADAPT | code.add_load_path(path) |
erlang:check_old_code/1 |
ADAPT | code.has_old_version(module_name) |
erlang:check_process_code/2 |
ADAPT | code.actor_running_old_version(actor_ref, module) |
sys:suspend/1 |
ADAPT | system.suspend(actor_ref) |
sys:resume/1 |
ADAPT | system.resume(actor_ref) |
sys:replace_state/2 |
ADAPT | system.replace_state(actor_ref, new_state) |
sys:get_status/1 |
ADAPT | system.status(actor_ref) |
sys:get_state/1 |
ADAPT | system.state(actor_ref) |
sys:change_code/4 |
ADAPT | system.upgrade(actor_ref, old_vsn, new_vsn, extra) |
Design Note: Hot reloading is harder with WASM than with BEAM bytecode, but feasible with dynamic module linking. Nulang should support "rolling upgrade" patterns where new actor instances use the new code while old instances drain their mailboxes.
Status: ADAPT for protocol parsing — NOT IMPLEMENTED (no binary/<< >> syntax; term_to_binary equivalents do not exist). Nulang's own distribution wire protocol is hand-rolled big-endian serde in Rust (Packet::to_bytes/from_bytes, src/runtime/network.rs) — see §17 for the format.
Binary pattern matching is one of Erlang's most powerful features. Nulang should support it for wire protocol implementation:
-- Binary construction
let packet = binary {
version: 1 as u8,
flags: 0x03 as u16_be,
payload_length: String.length(data) as u32_be,
payload: data as bytes
}
-- Binary pattern matching
match packet {
| <<version: u8, flags: u16_be, length: u32_be, payload: bytes(length)>> => {
parse_payload(version, flags, payload)
}
| <<0xFF, rest: bytes>> => handle_legacy(rest)
| _ => Error("Invalid packet")
}
| Binary Primitive | Nulang Status | Nulang Form |
|---|---|---|
<<Data>> |
ADAPT | binary { ... } / << ... >> |
| Integer segments | ADAPT | value as u8, value as u16_be |
| Binary segments | ADAPT | data as bytes |
| Bit-size segments | ADAPT | value as bits(5) |
| UTF-8 segments | ADAPT | string as utf8 |
binary:match/2 |
OMIT | Use pattern matching |
binary:part/3 |
ADAPT | Binary.slice(data, start, length) |
binary:copy/1 |
ADAPT | Binary.copy(data) |
binary:list_to_bin/1 |
ADAPT | Binary.from_list(list) |
binary:bin_to_list/1 |
ADAPT | Binary.to_list(data) |
term_to_binary/1 |
ADOPT | Binary.serialize(term) |
binary_to_term/1 |
ADOPT | Binary.deserialize(data) |
Status: NOT IMPLEMENTED. No tracing infrastructure exists beyond debug opcodes (DbgBreak 0xF0, DbgPrint 0xF1, DbgStack 0xF2, MetaType 0xF3, MetaCap 0xF4) and Rust-side counters (SchedulerStats, GcStats). The trace.*/debug.* APIs below are the design target.
| BEAM Primitive | Nulang Status | Nulang Form |
|---|---|---|
erlang:trace/3 |
ADAPT | trace.enable(actor_ref, flags) |
erlang:trace_pattern/2 |
ADAPT | trace.set_pattern(module, behavior, match_spec) |
dbg:tracer/0 |
ADAPT | trace.start_tracer() |
dbg:p/2 |
ADAPT | trace.set_actor(actor_ref, flags) |
dbg:tp/2 |
ADAPT | trace.set_breakpoint(module, behavior) |
dbg:ctp/2 |
ADAPT | trace.clear_breakpoint(module, behavior) |
dbg:stop/0 |
ADAPT | trace.stop() |
| BEAM Primitive | Nulang Status | Nulang Form |
|---|---|---|
sys:suspend/1 |
ADAPT | debug.suspend(actor_ref) |
sys:resume/1 |
ADAPT | debug.resume(actor_ref) |
sys:get_state/1 |
ADAPT | debug.get_state(actor_ref) |
sys:get_status/1 |
ADAPT | debug.get_status(actor_ref) |
sys:replace_state/2 |
ADAPT | debug.replace_state(actor_ref, fn) |
sys:statistics/2 |
ADAPT | debug.statistics(actor_ref, flags) |
sys:log/2 |
ADAPT | debug.log(actor_ref, options) |
Status: IMPLEMENTED (runtime API) — ProcessGroups (src/runtime/process_groups.rs) is a single-node, RwLock<HashMap<String, HashSet<u64>>> implementation. The actor.groups.* syntax below does not parse; membership is managed from Rust, and actors are auto-removed from all groups on exit (leave_all in handle_actor_exit).
Process groups provide decentralized, conflict-free process group membership:
-- Join a process group
actor.groups.join("http_workers", self)
-- Get all members of a group
let workers = actor.groups.members("http_workers")
-- Send to all members in a group
actor.groups.broadcast("http_workers", ReloadConfig)
-- Leave a group
actor.groups.leave("http_workers", self)
| pg Primitive | Nulang Status |
|---|---|
pg:join/2,3 |
IMPLEMENTED (runtime API) — ProcessGroups::join(group, id) (idempotent, validated names) |
pg:leave/2,3 |
IMPLEMENTED (runtime API) — leave(group, id); empty groups are pruned |
pg:get_members/1,2 |
IMPLEMENTED (runtime API) — members(group) |
pg:get_local_members/1,2 |
IMPLEMENTED (runtime API) — all members are local (single-node) |
pg:which_groups/0,1 |
IMPLEMENTED (runtime API) — which_groups() |
| broadcast to group | NOT IMPLEMENTED — no broadcast/actor.groups.broadcast; senders must iterate members() |
Status: OMIT — Replaced by pg in modern Erlang. Nulang should only implement pg.
Status: ADAPT as application lifecycle — NOT IMPLEMENTED (no application block syntax)
OTP applications provide structured lifecycle management. Nulang should support application trees:
application MyService {
version = "1.0.0"
on_start {
let store = spawn_link KeyValueStore
let api = spawn_link ApiServer(store)
let metrics = spawn_link MetricsCollector
Ok({ store, api, metrics })
}
on_stop(state) {
actor.stop(state.api)
actor.stop(state.store)
actor.stop(state.metrics)
Ok(())
}
}
| Application Primitive | Nulang Status | Nulang Form |
|---|---|---|
application:start/1 |
ADAPT | application.start(MyService) |
application:stop/1 |
ADAPT | application.stop(MyService) |
application:loaded_applications/0 |
ADAPT | application.list_loaded() |
application:which_applications/0 |
ADAPT | application.list_running() |
application:get_env/2 |
ADAPT | application.get_env(MyService, key) |
application:set_env/3 |
ADAPT | application.set_env(MyService, key, value) |
| Application callback module | ADAPT | application block with on_start, on_stop |
Status: ADAPT as external.process — NOT IMPLEMENTED. Nulang's actual external interfaces today are the PyO3 Python bridge (src/python/, perform Python.call(...)) and the C FFI (src/ffi/, FFICall opcode 0xB0) — neither is a BEAM-style port with message-passing to an OS process.
Ports let BEAM communicate with external OS processes. Nulang should support this for integrating with external code:
let port = external.process.spawn("/usr/bin/python3", ["script.py"])
port <- { command: "classify", data: image_bytes }
receive {
| PortData(result) => result
| PortClosed => Error("Python process crashed")
} after Duration.seconds(30) {
Error("Classification timeout")
}
Status: REPLACE — Nulang already has the C FFI (src/ffi/: native library registry, Value↔C marshaling, stable C embedder API) instead of a WASM-module story. external.wasm does not exist.
NIFs let Erlang call C functions. Nulang's equivalent is its FFI layer; a WASM-module variant remains a design option:
-- Load a native WASM module
let crypto_lib = external.wasm.load("crypto.wasm")
let hash = crypto_lib.call("sha256", data)
| Category | Adopt | Adapt | Replace | Omit |
|---|---|---|---|---|
| Actor Lifecycle | spawn ✅, self ✅, exit ✅, trap_exit ✅, spawn_link ✅ (spawn link syntax), spawn_monitor ✅ (spawn monitor syntax), priority ✅ (Actor.set_priority; scheduling-only) |
spawn_opt, process_info | — | pid_to_list, list_to_pid |
| Message Passing | send ✅, ask ✅, receive ✅ (selective + after timeout via ReceiveWait 0xA0) |
— | — | — |
| Naming | register, unregister, whereis ✅ (Actor.register/unregister/whereis builtins), registered ✅(API) |
— | — | — |
| Links/Monitors | link, unlink, monitor, demonitor ✅ (Actor.* builtins) |
demonitor flush ❌ | — | — |
| OTP Behaviors | supervisor strategies ✅ (3 of 4), gen_server call/cast ✅ | gen_statem ❌, gen_event ❌, simple_one_for_one ❌ | — | — |
| Storage | persistent_term ❌ | ETS (actor-local tables) ❌ | Mnesia ✅ (persistent actors + CRDTs implemented) | match_spec |
| Distribution | node(), nodes() ✅(API), monitor_node ❌ | RPC calls |
global registry ❌ (no virtual actors) | set_cookie |
| Timers | send_after, start_timer, cancel, remaining, exit_after, kill_after ✅(API) | — | sleep (workflow-only Timer.sleep) |
apply_after |
| Hot Reloading | — | code loading, sys operations ❌ | — | — |
| Binary Syntax | term_to_binary, binary_to_term ❌ | binary construction/matching ❌ | — | — |
| Tracing | — | trace, dbg ❌ | — | — |
| Process Groups | pg join/leave/members ✅(API) | group broadcast ❌ | pg2 | — |
| Applications | — | application lifecycle ❌ | — | — |
| External | — | ports ❌, WASM modules ❌ | NIFs → C FFI ✅ | — |
Legend: ✅ implemented (API = Rust runtime API only, no Nulang-language builtin) ·
receive/receive after— DONE — selective receive viaReceiveMatch0x8F (non-blocking);receive { arms } after ms => bodyviaReceiveWait0xA0 with full suspend/wake/timeout semantics ("ReceiveWait:suspend"sentinel, one-shot timer per wait)spawn_link/spawn_monitor— DONE —spawn link Actor {..}/spawn monitor Actor {..}syntax, parser-desugared tospawn+perform Actor.link/Actor.monitoron the spawnerlink/unlink/monitor/demonitor— DONE —perform Actor.link(t)/Actor.unlink(t)/Actor.monitor(t)/Actor.demonitor(t)builtins, dispatched viaActorVmCallbacks::perform_builtin_effect→Runtime::perform_actor_builtinexitsignals andtrap_exit— DONE —perform Actor.exit(reason)(0/"normal", 1/"error", 2/"kill", else custom) andperform Actor.trap_exit(flag)builtinsprocess_flag(priority, trap_exit) — DONE — trap_exit viaActor.trap_exit; priority viaperform Actor.set_priority(0|1|2)(High/Normal/Low) with strict per-level scheduler preference (scheduling-only; mailbox stays FIFO —Message.priorityremains unconsulted byMailbox::receive_match)register/unregister/whereis— DONE —perform Actor.register(name)/Actor.unregister(name)/Actor.whereis(name)builtins (nil no-op outside an actor, like allActor.*effects)
GenServerbehavior mixin — not startedGenStateMbehavior mixin — DONE —state_machinekeyword with states, events, on_entry/on_exit hooks; desugared to actorEventBusbehavior mixin — not started- Supervisor dynamic child management — DONE —
simple_one_for_onewith child template,start_child/terminate_child;Otp.*language surface
timer.send_after/start_timer/cancel_timer— runtime API done (TimerWheel); needs language surface- ETS (actor-local tables) — not started
persistent_term— not started- Process groups (
pg) — runtime API done; group broadcast missing
cluster.call/multicast/broadcast— not started; remote-send behavior-name resolution is done (§17.5 item 1), remoteSpawnRequestdelivery is done as an MVP (§17.5 item 2), gossip has a wire packet (Packet::Gossip)cluster.monitor_node— not started
- Binary/bit syntax for protocol parsing — not started
- Hot code reloading — not started (and not WASM-based; Nulang targets native bytecode)
- Application lifecycle management — not started
- Tracing infrastructure — not started
- Port/external process interfaces — not started; C FFI + Python bridge already cover part of the integration story
-
Type safety first. (Target.) As implemented,
spawn/send/askdo not returnResult— spawn is infallible and actor identity is a bareu64carried as a NaN-boxedActorRef. Remote sends to unresolvable targets are silently dropped (runtime/distributed.rs:682). TheResult[T, Error]surface remains the design goal. -
Capability-gated. (Target.) No capability checks gate actor operations today; capabilities are compile-time only — there are no capability opcodes (checks compile to
Const1insrc/mir_codegen.rs). -
Effect-tracked. Implemented with dedicated effects:
spawnaddsSpawn,sendaddsSend,receiveaddsReceive,askaddsSend + Receive(src/effect_checker.rs) — not[IO]as earlier drafts stated. -
Virtual actor compatible. (Target.) All primitives are planned to work with local and virtual actors; only
ActorAddress::Local/Remoterouting exists today. -
Mailbox-first. (Target.) Behaviors currently run as message handlers dispatched by
Runtime::step_actor; selectivereceive(ReceiveMatch0x8F) shipped as a non-blocking mailbox scan — it never suspends the actor the way Erlang'sreceivedoes (§17.5 item 4). -
No
apply/3. Dynamic function application is intentionally omitted. Nulang's typed system uses behavior dispatch instead. (Holds today.) -
Structured errors. (Partial.)
RegisterErrorandPgErrorare typed; VM/runtime failures surface asNuError::VMError/RuntimeErrorstrings rather than typedbadarg/badmatch/noprocvariants. Thenoproccase exists only as the DOWN reasonError("noproc")when monitoring a dead actor.
Verified by reading src/runtime/, src/bytecode.rs, and src/vm.rs (post-v0.9 tree). "Runtime API" means implemented in Rust and covered by tests but not reachable from Nulang source code. File references point at the defining code.
| Area | Implementation | Where |
|---|---|---|
| Actor lifecycle | Runtime::spawn_actor / spawn_persistent_actor / spawn_workflow_actor; ids from a global AtomicU64 (fresh_actor_id); state machine Created → Running → Waiting → Suspended → Terminated |
runtime/mod.rs:212, runtime/actor.rs:10 |
| Language surface | spawn Actor { field = v }, spawn link Actor {..} / spawn monitor Actor {..} (parser desugar to spawn + Actor.link/Actor.monitor), send a b(args), ask a b(args), self, receive { | B(p) => e } (selective receive — §17.5 item 4), receive { arms } after ms => body (timed, suspending), perform Actor.link/unlink/monitor/demonitor/trap_exit/exit/register/unregister/whereis/set_priority, emit, migrate a to node |
parser.rs:1749, lexer.rs:815 |
| VM actor opcodes (handled) | Spawn 0x80, Send 0x81, Ask 0x82, SelfOp 0x83, Receive 0x84 (no-match fallback emitted by MIR lowering), StateGet 0x8B, StateSet 0x8C, Emit 0x8D, SignalWait 0x8E, ReceiveMatch 0x8F, ReceiveWait 0xA0 |
bytecode.rs:108, vm.rs:1373 |
| VM actor opcodes (defined, unhandled — fall to "unimplemented opcode") | Monitor 0x85, Demon 0x86, Link 0x87, Unlink 0x88, Exit 0x89, Yield 0x8A |
vm.rs:2387 |
| Mailbox | Unbounded lock-free MPSC via crossbeam::queue::SegQueue; push never fails, never drops; epoch-based reclamation; Message { behavior_id: u16, payload: Vec<Value>, sender: u64, priority } with MessagePriority::{System=0, Normal=1, Bulk=2} (stored, not scheduling-affecting) |
runtime/mailbox.rs |
| Scheduler | Work-stealing: Chase-Lev Worker deque per worker (LIFO local, FIFO steal) + global Injector split into three priority queues (ActorPriority::{High, Normal, Low}, default Normal; strict per-level preference — High drains before Normal before Low, FIFO within a level; every enqueue path reads the actor's current priority via Runtime::enqueue_actor); Runtime::new configures 4 workers; idle backoff (3 empty polls → 100 µs sleep); profiling counters (SchedulerStats) |
runtime/scheduler.rs |
| Preemption | Reduction counting: +1 per message processed; yield at max_reductions = 1000; actor re-enqueued only while mailbox non-empty |
runtime/actor.rs:120, runtime/mod.rs:1894 |
| GC | Per-actor ORCA: 64 KiB bump-allocator heaps (5 size classes, free lists), local_count/foreign_count per object; cross-actor sends bump foreign_count via OrcaCoordinator; deferred frees pumped every 256 scheduler ticks and on run-queue drain |
runtime/heap.rs, runtime/gc.rs, runtime/mod.rs:1514 |
| Cycle detection | Incremental CycleDetector: per-actor pinned sentinel node, foreign-ref edge graph with ref counts, full scan every 10 epochs, suspect marking, DFS, trial decrements, reclamation |
runtime/orca_cycle.rs |
| Links/monitors/exit | link_actors/unlink_actors/monitor/demonitor/exit_actor/kill_actor; abnormal exit cascades to non-trapping links; trapping actors get a System message [dead_id, linked_id]; monitors get DOWN [target_id, watcher_id, reason_code] (codes: Normal 0, Error 1, Kill 2, Killed 3, Shutdown 4, Custom 5), all with behavior_id = 0; monitoring a dead actor → immediate DOWN Error("noproc"). Language surface: perform Actor.link/unlink/monitor/demonitor/trap_exit/exit |
runtime/mod.rs:2461 |
| Supervision | 3 strategies (OneForOne, OneForAll, RestForOne), 3 policies (Permanent, Temporary, Transient), per-spec rate limits (default 5 restarts / 60 s), escalation with cascading supervisor shutdown |
runtime/supervisor.rs |
| Registry | ActorRegistry: register/unregister/whereis/registered + name validation + auto-cleanup on exit. Language surface: perform Actor.register/unregister/whereis |
runtime/registry.rs |
| Process groups | ProcessGroups: join/leave/leave_all/members/is_member/member_count/which_groups; empty-group pruning; auto-leave on exit |
runtime/process_groups.rs |
| Timers | TimerWheel (min-heap, lazy cancel): send_after, send_after_with_context, exit_after, kill_after, cancel, remaining, tick; driven every scheduler iteration; durable workflow timers via perform Timer.sleep(name, ms) (journaled, re-armed on recovery) |
runtime/timer.rs, runtime/mod.rs:2185 |
| Persistence | PersistenceStore trait + MemoryStore, JsonFileStore, LibsqlStore (libsql/Turso); per-field StateModel (Local/Durable/EventSourced/Crdt); journal (JournalEntry) + snapshot (ActorSnapshot, incl. waiting_signal); 8-variant WorkflowEvent journal; recover_actor replays journal + restores bytecode via register_recovery_module; pointers/strings normalize to Nil across restarts |
runtime/persistence.rs, runtime/mod.rs:2498 |
| Event sourcing | emit opcode → Runtime::emit_event appends to Actor.event_log; saga compensation for failed workflow steps; workflow signals (SignalWait suspend/resume) |
runtime/mod.rs:893, vm.rs:1438 |
Custom TCP protocol in src/runtime/network.rs. Every frame:
[0..4] message length (u32, big-endian, includes this header)
[4..8] magic: "NUL0"
[8] packet type discriminant
[9..17] sequence number (u64, big-endian)
[17..] type-specific payload
An 8-byte node-id handshake (big-endian u64) is exchanged immediately after TCP connect, before any framed packets; a mismatch aborts the connection. Limits: MAX_PACKET_LEN 16 MiB, per-connection I/O timeout 30 s, internal channel capacity 1024.
Packet types: ActorMessage = 0, Heartbeat = 1, Ack = 2 (serde-complete but unused in delivery paths), SpawnRequest = 3, SpawnResponse = 4, CrdtSync = 5, Gossip = 6, CrdtDeltaSync = 7. All serde is hand-rolled big-endian. Value payloads serialize under five tags — int / float / bool / string-id (u32) / unit; anything else (nil, actor refs, pointers) is written as raw-bit float and does not round-trip on read (see §17.5 item 12).
Cluster membership (src/runtime/cluster.rs) is gossip/SWIM-style: heartbeat every 500 ms, heartbeat timeout 2 s, suspicion 5 s, failed-node retention 60 s, gossip fanout 2. NodeStatus: Joining, Healthy, Suspicious, Failed, Leaving. ClusterState::tick returns ClusterAction::{SendHeartbeat, NodeJoined, NodeFailed, NodeLeft, SendGossip} which Runtime::process_network executes. SendGossip is wired: Packet::Gossip carries the sender's compact membership view (Vec<NodeGossip> — node id, address, status, incarnation per member; address = family byte + octets + port), merged on receipt by ClusterState::merge_membership (higher incarnation wins; equal incarnation refreshes last_heartbeat as a liveness hint, which keeps relay-only nodes from being suspected). Transitive propagation works — a chain of pairwise seeds (B joins A, C joins B) converges without a full mesh; see test_three_node_gossip_converges_chain_seeded.
Location transparency (src/runtime/distributed.rs): ActorAddress::{Local, Remote}, AddressResolver (checks cluster health before resolving), and an LRU RemoteActorCache capped at 10,000 entries. NodeId::LOCAL = 0. Migrate opcode (0xD1) records (actor, node) in VM::pending_migrations and forwards to the distributed callback; actual cross-node state transfer is not implemented.
8 types behind the Crdt trait, owned by CrdtManager (created with create_* constructors, synced via CrdtSync packets to all healthy members):
| CRDT | File |
|---|---|
GCounter, PNCounter, GSet, ORSet, AWORSet (+ LamportTime/LamportClock helpers) |
runtime/crdt.rs |
LWWRegister, MVRegister, RGA |
runtime/crdt_reg.rs |
CrdtOp wire format: crdt_id u64 BE · crdt_type u8 · payload_len u32 BE · payload. Entries created from remote payloads have their local node identity rewritten so new operations tag the local node.
| Constant | Value | Where |
|---|---|---|
max_reductions (preemption) |
1000 | runtime/actor.rs:120 |
Scheduler workers (Runtime::new) |
4 | runtime/mod.rs:179 |
| GC deferred-free pump interval | 256 scheduler ticks | runtime/mod.rs:1514 |
| Cycle-detection full-scan interval | 10 epochs | runtime/orca_cycle.rs:347 |
| Initial actor heap | 64 KiB | runtime/actor.rs:91 |
| Mailbox capacity | unbounded (SegQueue; constructor arg ignored) |
runtime/mailbox.rs:49 |
| Remote actor cache | 10,000 entries (LRU) | runtime/distributed.rs:56 |
| Supervisor restart defaults | 5 restarts / 60 s window | runtime/supervisor.rs:82 |
| Heartbeat interval / timeout / suspicion / retention | 500 ms / 2 s / 5 s / 60 s | runtime/cluster.rs:38 |
| Gossip fanout | 2 | runtime/cluster.rs:50 |
remote_ask timeout |
5000 ms | vm.rs:2132 |
Remote send drops the behavior name.FIXED.Packet::ActorMessagenow carriesbehavior_name: Stringon the wire (length-prefixed UTF-8, replacing thebehavior_id: u16field);process_network_packetsresolves it viaRuntime::behavior_id_foragainst the target actor's behavior table, falling back to behavior 0 for unknown names — mirroring localsend_message'sunwrap_or(0)(runtime/distributed.rs,runtime/network.rs).Remote spawn is send-only.FIXED (MVP).process_network_packetsnow handlesPacket::SpawnRequest: the receiving node spawns a fresh actor with the request'sinitial_stateand registers the requested behavior — but only if that behavior was explicitly offered viaRuntime::register_spawnable_behavior(MVP scope: remote spawn supports native behaviors the receiver opted into, not arbitrary or bytecode behaviors). Unknown names are answered withSpawnResponse{success:false}and no actor is created — the no-crash counterpart of the local unknown-behavior fallback. The reply carries the real actor id; the requester collects it viaRuntime::take_spawn_response(request_id)(recorded by theSpawnResponsearm ofprocess_network_packets; the address returned byspawn_on_nodeis still a placeholder whoseactor_idis the request id).RSpawn(0xD4) still returnsactor_ref(0)(vm.rs:1468);DistributedRuntimeImpl::spawn_on_nodestill returns placeholder addresses.RSend(0xD2) is a no-op in the VM (vm.rs:1465).FIXED — selective receive shipped, andreceivehas no semantics.receive afterhas since landed. MIR lowering (lower_receive,mir_lower.rs:1098) resolves arm behavior names to behavior-table indices and emitsReceiveMatch(0x8F) with a"max_params:id1,id2,..."spec constant; the VM callsActorVmCallbacks::try_receive_match(mailbox scanMailbox::receive_match, FIFO order, non-matching messages requeued), writes the matched arm index plus payload values to registers, and a MIR compare chain dispatches to the arm body. No-match falls through to the legacy pop-anyReceivehandler (vm.rs:2347, nil when the mailbox is empty or outside an actor context) — non-blocking, no suspension. The timed formreceive { arms } after ms => bodyemitsReceiveWait(0xA0, same spec/register contract, timeout staged in r0): on no match with a positive timeout in an actor the VM re-arms the PC and raises"ReceiveWait:suspend"; the runtime arms a one-shot timer per wait (Actor.receive_wait), resumes on a matching message or timer fire (timeout → arm-count sentinel to dst → after body; no legacy pop-any fallthrough in the timed form).- Fault-tolerance opcodes unhandled.
Monitor/Demon/Link/Unlink/Exit/Yieldstill hit the VM's "unimplemented opcode" catch-all (vm.rs:2387) — but the functionality is now reachable from source as built-in effects (perform Actor.link/unlink/monitor/demonitor/trap_exit/exit→Runtime::perform_actor_builtin), so the opcodes are superseded rather than missing. FIXED for trap_exit and the registry (trap_exitsis Rust-only.Actor.trap_exit,Actor.register/unregister/whereisbuiltins). Process groups andTimerWheelremain Rust-only.No actor scheduling priority.FIXED.Actor.priority: ActorPriority {High, Normal (default), Low}set viaperform Actor.set_priority(0|1|2); the scheduler's global injector is split into three per-level queues drained High → Normal → Low (FIFO within a level). Affects scheduling only:MessagePriorityis still stored on messages but never consulted by the mailbox (Mailbox::receive_matchstays FIFO).- Unresolvable remote sends are silently dropped (
ResolveResult::Unresolvable→ ignored,runtime/distributed.rs:682). Ackpackets serialize/deserialize and are tested, but nothing sends or consumes them.Supervisor restarts recreate bare actorsFIXED.RestartTemplate(captured atsupervise_child) carries state data, behavior table, and bytecode module;rebuild_childrestores them; dynamic children (SimpleOneForOne) spawn from the template module viaspawn_from_module.Killis trappable in practice.handle_actor_exitspecial-cases nothing forExitReason::Kill— linked actors withtrap_exitsreceive it as a message instead of dying, contradicting the "cannot be trapped" doc comment (types.rs:515,runtime/mod.rs:2751).ExitReason::Killedis never constructed; link cascades useError("linked actor ... exited with ...")instead.- Wire
Valueserde lossy. Only int/float/bool/string-id/unit round-trip; nil, actor refs, and pointers serialize as raw-bitVAL_FLOATand read back as floats (runtime/network.rs:522,:538). - No:
is_alive/process_info/processesbuiltins, actor hibernation, explicit GC triggers, group broadcast,monitor_node, cluster RPC family (call/multicall/cast/broadcast), ETS tables,persistent_term, virtual actors, application lifecycle, tracing, ports, binary/bit syntax, hot code loading. (Note:simple_one_for_onesupervisors andOtp.*language surface are now implemented — see §4.4 and §15.)
- The full distribution wire protocol, handshake, packet inventory, and cluster timing constants (§17.2).
- The 8-type CRDT inventory and
CrdtOpsync format (§17.3). - The three persistence backends, the journal/snapshot model, and the 8-variant workflow event journal with recovery replay (§17.1).
- The DOWN-message and trap-exit-message wire shapes (
behavior_id = 0, reason codes) (§17.1). - ORCA per-actor GC with deferred frees + epoch-driven cycle detection, and the 256-tick GC pump (§17.1, §17.4).
- Scheduler profiling (
SchedulerStats) and GC counters (GcStats) — the closest thing tosystem_monitortoday. - Debug opcodes
DbgBreak/DbgPrint/DbgStack/MetaType/MetaCap(0xF0–0xF4). - The v0.9 AI runtime (
src/ai/: LLM providers, semantic/procedural memory, pipelines, debates, supervisor teams) is wired intoRuntime(pipeline_*,debate_*,supervisor_*,complete_llm) with dedicated opcodes (LlmAsk0x9C,PipelineNew/Stage/Run0x9D–0x9F,SupervisorNew/Worker/Run0xC0–0xC2,DebateNew/Participant/Run0xC3–0xC5) — out of BEAM scope but resident in the same runtime.