diff --git a/Cargo.lock b/Cargo.lock index 1e99605..945a03c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2723,6 +2723,7 @@ dependencies = [ "serde", "swactor-std", "tracing", + "web-time", ] [[package]] @@ -3299,6 +3300,7 @@ name = "wasm" version = "0.1.0" dependencies = [ "swactor", + "swactor-std", "wasm-bindgen", ] @@ -3735,6 +3737,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 19c2995..2634ddc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,11 +22,13 @@ serde = ["dep:serde"] tracing = ["dep:tracing"] no_random = [] # compile without access to a source of randomness transport = [] # transport-agnostic messaging (no mandatory deps; codec is user-provided) +wasm = ["no_random", "dep:web-time"] # browser/wasm32 target support [dependencies] getrandom = { version = "0.2", optional = true } serde = { version = "1", features = ["derive"], optional = true } tracing = { version = "0.1", optional = true } +web-time = { version = "0.2", optional = true } crossbeam-queue = "0.3.12" crossbeam-utils = "0.8.21" diff --git a/crates/std/Cargo.toml b/crates/std/Cargo.toml index 99e4b2a..5b063c8 100644 --- a/crates/std/Cargo.toml +++ b/crates/std/Cargo.toml @@ -5,8 +5,9 @@ edition = "2024" [features] default = ["getrandom"] -getrandom = ["dep:getrandom"] +getrandom = ["dep:getrandom", "swactor/getrandom"] +wasm = ["swactor/wasm"] [dependencies] -swactor = { path = "../.." } +swactor = { path = "../..", default-features = false } getrandom = { version = "0.2", optional = true } diff --git a/crates/std/src/router.rs b/crates/std/src/router.rs index c0d2f89..3202d6c 100644 --- a/crates/std/src/router.rs +++ b/crates/std/src/router.rs @@ -102,10 +102,20 @@ impl Router { Some(live[idx]) } RoutingStrategy::Random => { - let mut buf = [0u8; 8]; - getrandom::getrandom(&mut buf).expect("getrandom failed"); - let r = u64::from_ne_bytes(buf) as usize; - Some(live[r % live.len()]) + #[cfg(feature = "getrandom")] + { + let mut buf = [0u8; 8]; + getrandom::getrandom(&mut buf).expect("getrandom failed"); + let r = u64::from_ne_bytes(buf) as usize; + Some(live[r % live.len()]) + } + #[cfg(not(feature = "getrandom"))] + { + // Fallback to round-robin when getrandom is unavailable (wasm) + let idx = self.rr_index % live.len(); + self.rr_index = self.rr_index.wrapping_add(1); + Some(live[idx]) + } } RoutingStrategy::Broadcast => None, // handled separately } diff --git a/crates/std/src/runtime_ext.rs b/crates/std/src/runtime_ext.rs index 1f7ab67..f75b0d7 100644 --- a/crates/std/src/runtime_ext.rs +++ b/crates/std/src/runtime_ext.rs @@ -17,6 +17,9 @@ fn get_ext(rt: &Runtime) -> &StdExtension { /// Provides `spawn_named`, `where_is`, `unregister`, and `registered_names` /// via the [`StdExtension`] name registry. pub trait RuntimeNaming { + /// Register a name for an already-spawned actor. Returns `Err` if name is taken. + fn register_name(&self, name: impl Into, addr: ActorAddress) -> Result<(), Error>; + /// Spawn an actor with a registered name, returning its address. fn spawn_named(&self, name: impl Into, actor: A) -> Result; @@ -31,6 +34,10 @@ pub trait RuntimeNaming { } impl RuntimeNaming for Runtime { + fn register_name(&self, name: impl Into, addr: ActorAddress) -> Result<(), Error> { + get_ext(self).name_registry.register(name.into(), addr) + } + fn spawn_named(&self, name: impl Into, actor: A) -> Result { let name = name.into(); let addr = self.spawn(actor)?; diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index f4fda2e..20787f6 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -7,5 +7,6 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -swactor = { path = "../..", default-features = false, features = ["no_random"] } +swactor = { path = "../..", default-features = false, features = ["wasm"] } +swactor-std = { path = "../std", default-features = false, features = ["wasm"] } wasm-bindgen = "0.2" diff --git a/crates/wasm/demo.html b/crates/wasm/demo.html new file mode 100644 index 0000000..d50e660 --- /dev/null +++ b/crates/wasm/demo.html @@ -0,0 +1,570 @@ + + + + +swactor — In-Browser Runtime Demo + + + + +

swactor in-browser runtime

+

actor runtime compiled to WebAssembly, running right here

+ +
Loading wasm module...
+ +
+
+ +
+

Controls

+ +
+

Runtime

+
+ + + +
+ + 20 tps +
+
+
Actors0
+
Messages0
+
Panics0
+
Uptime0ms
+
Ticks0
+
+ +
+

Spawn Actor

+
+ + +
+
+
+ +
+

Send Message

+
+ + +
+ +
+ +
+

Naming

+
+ + +
+
+ + + +
+
+
+ +
+

Groups

+
+ +
+
+ +
+
+
+
+ + +
+

Actors

+ +
+
+ + +
+

Event Log

+
+
+
+
+ + + + diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 221a38d..36fcf4f 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -1,11 +1,240 @@ +use std::sync::Arc; + use wasm_bindgen::prelude::*; -use swactor::actor::{ActorAddress, ActorInterface}; +use swactor::actor::{ActorAddress, ActorExited, ActorInterface}; use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig}; +use swactor_std::{CtxGroups, RuntimeNaming, RuntimeGroups, StdExtension}; -// --------------------------------------------------------------------------- -// Actors (private — only exposed through the wasm API) -// --------------------------------------------------------------------------- +// ─── Core JS-facing types ─────────────────────────────────────────────────── + +/// Opaque actor address handle for JavaScript. +/// +/// Returned by spawn functions, passed to send functions. JS never sees +/// the raw 32-byte address — it just holds and forwards this handle. +#[wasm_bindgen] +#[derive(Clone)] +pub struct WasmAddr(ActorAddress); + +#[wasm_bindgen] +impl WasmAddr { + /// Debug representation of the address (first 8 hex bytes + ellipsis). + #[wasm_bindgen(js_name = toString)] + pub fn to_js_string(&self) -> String { + format!("{}", self.0) + } +} + +impl WasmAddr { + /// Access the inner address from Rust (not exposed to JS). + pub fn inner(&self) -> ActorAddress { + self.0 + } +} + +/// Inbox that receives `u32` values from actors. +#[wasm_bindgen] +pub struct WasmInboxU32 { + inner: Inbox, +} + +#[wasm_bindgen] +impl WasmInboxU32 { + /// The address actors should send results to. + pub fn addr(&self) -> WasmAddr { + WasmAddr(*self.inner.addr()) + } + + /// Poll for the next value. Returns `undefined` when empty. + pub fn try_recv(&self) -> Option { + self.inner.try_recv() + } +} + +/// Inbox that receives byte arrays from actors. +#[wasm_bindgen] +pub struct WasmInboxBytes { + inner: Inbox>, +} + +#[wasm_bindgen] +impl WasmInboxBytes { + pub fn addr(&self) -> WasmAddr { + WasmAddr(*self.inner.addr()) + } + + /// Poll for the next byte array. Returns `undefined` when empty. + pub fn try_recv(&self) -> Option> { + self.inner.try_recv() + } +} + +/// Inbox that receives string values (used for death notifications, etc.). +#[wasm_bindgen] +pub struct WasmInboxString { + inner: Inbox, +} + +#[wasm_bindgen] +impl WasmInboxString { + pub fn addr(&self) -> WasmAddr { + WasmAddr(*self.inner.addr()) + } + + /// Poll for the next string. Returns `undefined` when empty. + pub fn try_recv(&self) -> Option { + self.inner.try_recv() + } +} + +// ─── Runtime ──────────────────────────────────────────────────────────────── + +/// The browser-facing swactor runtime. +/// +/// Wraps `swactor::Runtime` in single-threaded mode with StdExtension installed +/// (naming, monitoring, groups). Actors are spawned via dedicated spawn functions +/// (one per actor type). The runtime is driven by calling `tick()`. +#[wasm_bindgen] +pub struct WasmRuntime { + rt: Runtime, +} + +#[wasm_bindgen] +impl WasmRuntime { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + let rt = Runtime::new(RuntimeConfig { + num_threads: 1, + ..RuntimeConfig::default() + }) + .with_extension(Arc::new(StdExtension::new())); + Self { rt } + } + + /// Drive one tick of the runtime. + pub fn tick(&self) { + self.rt.tick(); + } + + /// Number of actors currently alive. + pub fn actor_count(&self) -> usize { + self.rt.stats().actors.len() + } + + /// Create an inbox that receives u32 values. + pub fn new_inbox_u32(&self) -> WasmInboxU32 { + WasmInboxU32 { + inner: self.rt.new_inbox().expect("new_inbox_u32"), + } + } + + /// Create an inbox that receives byte arrays. + pub fn new_inbox_bytes(&self) -> WasmInboxBytes { + WasmInboxBytes { + inner: self.rt.new_inbox().expect("new_inbox_bytes"), + } + } + + /// Create an inbox that receives strings. + pub fn new_inbox_string(&self) -> WasmInboxString { + WasmInboxString { + inner: self.rt.new_inbox().expect("new_inbox_string"), + } + } + + /// Send a u32 to an actor. Returns false if the address is invalid. + pub fn send_u32(&self, addr: &WasmAddr, value: u32) -> bool { + self.rt.send_to(addr.0, value).is_ok() + } + + /// Send a byte array to an actor. Returns false if the address is invalid. + pub fn send_bytes(&self, addr: &WasmAddr, data: &[u8]) -> bool { + self.rt.send_to(addr.0, data.to_vec()).is_ok() + } + + /// Stop an actor gracefully. + pub fn stop_actor(&self, addr: &WasmAddr) -> bool { + self.rt.stop_actor(addr.0).is_ok() + } + + /// Runtime uptime in milliseconds. + pub fn uptime_ms(&self) -> f64 { + self.rt.stats().uptime_ms as f64 + } + + // ─── Naming ───────────────────────────────────────────────────────────── + + /// Register a name for an actor address. Returns false if the name is taken. + pub fn register_name(&self, name: &str, addr: &WasmAddr) -> bool { + self.rt.register_name(name.to_string(), addr.0).is_ok() + } + + /// Look up an actor address by name. Returns undefined if not found. + pub fn where_is(&self, name: &str) -> Option { + self.rt.where_is(name).map(WasmAddr) + } + + /// Unregister a name. Returns the address it was bound to, or undefined. + pub fn unregister_name(&self, name: &str) -> Option { + self.rt.unregister(name).map(WasmAddr) + } + + /// Return all registered actor names as a comma-separated string. + pub fn registered_names(&self) -> String { + self.rt.registered_names().join(",") + } + + // ─── Groups ───────────────────────────────────────────────────────────── + + /// Add an actor to a named group. + pub fn join_group(&self, addr: &WasmAddr, group: &str) { + self.rt.join_group(addr.0, group.to_string()); + } + + /// Remove an actor from a named group. + pub fn leave_group(&self, addr: &WasmAddr, group: &str) { + self.rt.leave_group(addr.0, group); + } + + /// Broadcast a u32 message to all members of a group. Returns count sent. + pub fn publish_to_group_u32(&self, group: &str, msg: u32) -> usize { + self.rt.publish_to(group, msg) + } + + /// Number of actors in a group. + pub fn group_member_count(&self, group: &str) -> usize { + self.rt.group_members(group).len() + } + + /// Return all group names as a comma-separated string. + pub fn group_names(&self) -> String { + self.rt.groups().join(",") + } + + // ─── Stats ────────────────────────────────────────────────────────────── + + /// Total messages processed across all workers. + pub fn total_messages(&self) -> f64 { + self.rt.stats().workers.iter().map(|w| w.messages_processed).sum::() as f64 + } + + /// Total panics across all workers. + pub fn total_panics(&self) -> f64 { + self.rt.stats().workers.iter().map(|w| w.panics).sum::() as f64 + } +} + +impl WasmRuntime { + /// Access the inner Runtime from Rust (for custom spawn functions). + pub fn runtime(&self) -> &Runtime { + &self.rt + } +} + +// ─── Demo actors ──────────────────────────────────────────────────────────── +// +// These demonstrate the pattern for exposing actors to JavaScript. +// Each actor type gets a `spawn_*` function that returns a WasmAddr. struct Counter { total: u32, @@ -35,79 +264,100 @@ impl ActorInterface for Relay { } } -// --------------------------------------------------------------------------- -// JS-facing runtime wrapper -// --------------------------------------------------------------------------- - -#[wasm_bindgen] -pub struct SwactorRuntime { - rt: Runtime, - inbox: Inbox, - actors: Vec, +/// A sentinel actor that watches a target and reports its death to an inbox. +/// +/// Uses the std monitoring extension (CtxMonitoring::monitor). When the target +/// dies, the sentinel receives a `Down` message and sends the dead actor's +/// string representation to the report inbox, then stops itself. +struct Sentinel { + target: ActorAddress, + report_to: ActorAddress, } -#[wasm_bindgen] -impl SwactorRuntime { - #[wasm_bindgen(constructor)] - pub fn new() -> Self { - let rt = Runtime::new(RuntimeConfig { - num_threads: 1, - ..RuntimeConfig::default() - }); - let inbox = rt.new_inbox().unwrap(); - Self { - rt, - inbox, - actors: Vec::new(), - } +impl ActorInterface for Sentinel { + type Incoming = (); + type Response = (); + + fn handle(&mut self, _ctx: &Ctx, _msg: ()) {} + + fn on_start(&mut self, ctx: &Ctx) { + ctx.watch(self.target); } - /// Spawn a counter actor. Returns its index (used with `send`). - pub fn spawn_counter(&mut self) -> usize { - let addr = self - .rt - .spawn(Counter { - total: 0, - report_to: *self.inbox.addr(), - }) - .expect("spawn counter"); - let idx = self.actors.len(); - self.actors.push(addr); - idx - } - - /// Spawn a relay that forwards every message to `target_idx`. - pub fn spawn_relay(&mut self, target_idx: usize) -> usize { - let target = self.actors[target_idx]; - let addr = self - .rt - .spawn(Relay { target }) - .expect("spawn relay"); - let idx = self.actors.len(); - self.actors.push(addr); - idx - } - - /// Send a u32 to the actor at `actor_idx`. - pub fn send(&self, actor_idx: usize, value: u32) -> bool { - if actor_idx >= self.actors.len() { - return false; - } - self.rt.send_to(self.actors[actor_idx], value).is_ok() - } - - /// Drive one tick of the single-threaded runtime. - pub fn tick(&self) { - self.rt.tick(); - } - - /// Try to read the next result from the inbox. Returns `undefined` when empty. - pub fn try_recv(&self) -> Option { - self.inbox.try_recv() - } - - /// Number of actors the runtime knows about. - pub fn actor_count(&self) -> usize { - self.rt.stats().actors.len() + fn on_actor_exit(&mut self, ctx: &Ctx, exited: ActorExited) { + let msg = format!("{}:{:?}", exited.addr, exited.reason); + let _ = ctx.send(self.report_to, msg); + ctx.stop_self(); } } + +/// A group member that joins a named group and forwards u32 messages to a report inbox. +struct GroupMember { + group: String, + report_to: ActorAddress, +} + +impl ActorInterface for GroupMember { + type Incoming = u32; + type Response = (); + + fn on_start(&mut self, ctx: &Ctx) { + ctx.join_group(self.group.clone()); + } + + fn handle(&mut self, ctx: &Ctx, msg: u32) { + let _ = ctx.send(self.report_to, msg); + } +} + +/// Spawn a counter that accumulates u32 values and reports running totals +/// to the given inbox address. +#[wasm_bindgen] +pub fn spawn_counter(rt: &WasmRuntime, report_to: &WasmAddr) -> WasmAddr { + let addr = rt + .rt + .spawn(Counter { + total: 0, + report_to: report_to.0, + }) + .expect("spawn counter"); + WasmAddr(addr) +} + +/// Spawn a relay that forwards every u32 message to the target actor. +#[wasm_bindgen] +pub fn spawn_relay(rt: &WasmRuntime, target: &WasmAddr) -> WasmAddr { + let addr = rt + .rt + .spawn(Relay { target: target.0 }) + .expect("spawn relay"); + WasmAddr(addr) +} + +/// Spawn a sentinel that watches a target actor and reports its death +/// to the given string inbox. +#[wasm_bindgen] +pub fn spawn_sentinel(rt: &WasmRuntime, target: &WasmAddr, report_to: &WasmInboxString) -> WasmAddr { + let addr = rt + .rt + .spawn(Sentinel { + target: target.0, + report_to: *report_to.inner.addr(), + }) + .expect("spawn sentinel"); + WasmAddr(addr) +} + +/// Spawn a group member that joins the given group and forwards u32 messages +/// to the report inbox. +#[wasm_bindgen] +pub fn spawn_group_member(rt: &WasmRuntime, group: &str, report_to: &WasmAddr) -> WasmAddr { + let addr = rt + .rt + .spawn(GroupMember { + group: group.to_string(), + report_to: report_to.0, + }) + .expect("spawn group_member"); + WasmAddr(addr) +} diff --git a/crates/wasm/test.mjs b/crates/wasm/test.mjs index b23438a..7c8c287 100644 --- a/crates/wasm/test.mjs +++ b/crates/wasm/test.mjs @@ -1,4 +1,11 @@ -import { SwactorRuntime } from "./pkg/swactor_wasm.js"; +import { + WasmRuntime, + WasmAddr, + spawn_counter, + spawn_relay, + spawn_sentinel, + spawn_group_member, +} from "./pkg/wasm.js"; let passed = 0; let failed = 0; @@ -23,77 +30,314 @@ function assertEq(a, b, msg) { } } -function drain(rt) { +function drainInbox(inbox) { const results = []; let v; - while ((v = rt.try_recv()) !== undefined) results.push(v); + while ((v = inbox.try_recv()) !== undefined) results.push(v); return results; } // ---- accumulator ---------------------------------------------------------- { - console.log("test: accumulator processes messages"); - const rt = new SwactorRuntime(); - const c = rt.spawn_counter(); - rt.send(c, 1); - rt.send(c, 2); - rt.send(c, 10); + console.log("test: accumulator processes messages via WasmAddr"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const c = spawn_counter(rt, inbox.addr()); + rt.send_u32(c, 1); + rt.send_u32(c, 2); + rt.send_u32(c, 10); rt.tick(); - assertEq(drain(rt), [1, 3, 13], "running totals"); + assertEq(drainInbox(inbox), [1, 3, 13], "running totals"); + inbox.free(); rt.free(); } // ---- relay ---------------------------------------------------------------- { console.log("test: relay forwards to counter"); - const rt = new SwactorRuntime(); - const c = rt.spawn_counter(); - const r = rt.spawn_relay(c); - rt.send(r, 5); - rt.send(r, 7); - // tick 1: relay receives and forwards (cross-actor, same worker → pending_local) + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const c = spawn_counter(rt, inbox.addr()); + const r = spawn_relay(rt, c); + rt.send_u32(r, 5); + rt.send_u32(r, 7); + // tick 1: relay receives and forwards (same worker → pending_local) // tick 2: counter receives forwarded messages rt.tick(); rt.tick(); - assertEq(drain(rt), [5, 12], "relayed totals"); + assertEq(drainInbox(inbox), [5, 12], "relayed totals"); + inbox.free(); rt.free(); } // ---- multiple counters ---------------------------------------------------- { console.log("test: multiple independent counters"); - const rt = new SwactorRuntime(); - const a = rt.spawn_counter(); - const b = rt.spawn_counter(); - rt.send(a, 10); - rt.send(b, 100); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const a = spawn_counter(rt, inbox.addr()); + const b = spawn_counter(rt, inbox.addr()); + rt.send_u32(a, 10); + rt.send_u32(b, 100); rt.tick(); - const results = drain(rt); - // order depends on HashMap iteration, so just check set equality + const results = drainInbox(inbox); assert( results.includes(10) && results.includes(100) && results.length === 2, "both counters report" ); + inbox.free(); rt.free(); } // ---- actor_count ---------------------------------------------------------- { console.log("test: actor_count tracks spawns"); - const rt = new SwactorRuntime(); - rt.spawn_counter(); - rt.spawn_counter(); - rt.spawn_counter(); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + spawn_counter(rt, inbox.addr()); + spawn_counter(rt, inbox.addr()); + spawn_counter(rt, inbox.addr()); rt.tick(); // drain spawn queue assertEq(rt.actor_count(), 3, "three actors"); + inbox.free(); rt.free(); } -// ---- send to invalid index returns false ---------------------------------- +// ---- WasmAddr toString ---------------------------------------------------- { - console.log("test: send to bad index returns false"); - const rt = new SwactorRuntime(); - assert(!rt.send(999, 1), "out-of-bounds send"); + console.log("test: WasmAddr has string representation"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const addr = spawn_counter(rt, inbox.addr()); + const s = addr.toString(); + // no_random generates deterministic addresses — just check it's a non-empty hex string + assert(typeof s === "string" && s.length > 0, "addr toString is non-empty string"); + inbox.free(); + rt.free(); +} + +// ---- stop_actor ----------------------------------------------------------- +{ + console.log("test: stop_actor removes actor"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const c = spawn_counter(rt, inbox.addr()); + rt.tick(); // drain spawn + assertEq(rt.actor_count(), 1, "one actor before stop"); + rt.stop_actor(c); + rt.tick(); // process stop + cleanup + assertEq(rt.actor_count(), 0, "zero actors after stop"); + inbox.free(); + rt.free(); +} + +// ---- bytes inbox ---------------------------------------------------------- +{ + console.log("test: byte inbox receives Uint8Array"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_bytes(); + // Send bytes directly (no actor — just to the inbox address) + rt.send_bytes(inbox.addr(), new Uint8Array([1, 2, 3])); + rt.tick(); + const result = inbox.try_recv(); + assert(result instanceof Uint8Array, "result is Uint8Array"); + assertEq(Array.from(result), [1, 2, 3], "bytes match"); + inbox.free(); + rt.free(); +} + +// ---- uptime --------------------------------------------------------------- +{ + console.log("test: uptime_ms returns a number"); + const rt = new WasmRuntime(); + const uptime = rt.uptime_ms(); + assert(typeof uptime === "number" && uptime >= 0, "uptime is non-negative number"); + rt.free(); +} + +// ---- naming: register and resolve ----------------------------------------- +{ + console.log("test: naming — register_name and where_is"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const c = spawn_counter(rt, inbox.addr()); + rt.tick(); // drain spawn + + const ok = rt.register_name("my_counter", c); + assert(ok, "register_name succeeds"); + + const found = rt.where_is("my_counter"); + assert(found !== undefined, "where_is finds registered actor"); + assertEq(found.toString(), c.toString(), "where_is returns correct address"); + + const notFound = rt.where_is("nonexistent"); + assert(notFound === undefined, "where_is returns undefined for unknown name"); + + found.free(); + inbox.free(); + rt.free(); +} + +// ---- naming: unregister --------------------------------------------------- +{ + console.log("test: naming — unregister_name"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const c = spawn_counter(rt, inbox.addr()); + rt.tick(); + + rt.register_name("temp", c); + const prev = rt.unregister_name("temp"); + assert(prev !== undefined, "unregister returns previous address"); + assertEq(prev.toString(), c.toString(), "unregister returns correct address"); + + const gone = rt.where_is("temp"); + assert(gone === undefined, "name no longer resolves after unregister"); + + prev.free(); + inbox.free(); + rt.free(); +} + +// ---- naming: registered_names --------------------------------------------- +{ + console.log("test: naming — registered_names"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const a = spawn_counter(rt, inbox.addr()); + const b = spawn_counter(rt, inbox.addr()); + rt.tick(); + + rt.register_name("alpha", a); + rt.register_name("beta", b); + const names = rt.registered_names().split(",").sort(); + assertEq(names, ["alpha", "beta"], "registered_names lists all names"); + + inbox.free(); + rt.free(); +} + +// ---- naming: duplicate name rejected -------------------------------------- +{ + console.log("test: naming — duplicate name rejected"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const a = spawn_counter(rt, inbox.addr()); + const b = spawn_counter(rt, inbox.addr()); + rt.tick(); + + const ok1 = rt.register_name("unique", a); + const ok2 = rt.register_name("unique", b); + assert(ok1, "first registration succeeds"); + assert(!ok2, "duplicate registration fails"); + + inbox.free(); + rt.free(); +} + +// ---- groups: join and broadcast ------------------------------------------- +{ + console.log("test: groups — join_group and publish_to_group_u32"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const a = spawn_group_member(rt, "workers", inbox.addr()); + const b = spawn_group_member(rt, "workers", inbox.addr()); + rt.tick(); // spawn + on_start (join group) + + assertEq(rt.group_member_count("workers"), 2, "two members in group"); + + rt.publish_to_group_u32("workers", 42); + rt.tick(); // group members receive + rt.tick(); // group members forward to inbox + + const results = drainInbox(inbox); + assertEq(results.length, 2, "both members received broadcast"); + assert(results.every((v) => v === 42), "correct value broadcast"); + + inbox.free(); + rt.free(); +} + +// ---- groups: leave -------------------------------------------------------- +{ + console.log("test: groups — leave_group"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const a = spawn_group_member(rt, "pool", inbox.addr()); + const b = spawn_group_member(rt, "pool", inbox.addr()); + rt.tick(); // spawn + on_start + + assertEq(rt.group_member_count("pool"), 2, "two members before leave"); + rt.leave_group(a, "pool"); + assertEq(rt.group_member_count("pool"), 1, "one member after leave"); + + inbox.free(); + rt.free(); +} + +// ---- groups: group_names -------------------------------------------------- +{ + console.log("test: groups — group_names"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + spawn_group_member(rt, "alpha", inbox.addr()); + spawn_group_member(rt, "beta", inbox.addr()); + rt.tick(); // spawn + join + + const names = rt.group_names().split(",").sort(); + assertEq(names, ["alpha", "beta"], "group_names lists all groups"); + + inbox.free(); + rt.free(); +} + +// ---- watching: sentinel detects death ------------------------------------- +{ + console.log("test: watching — sentinel reports actor death"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const deathInbox = rt.new_inbox_string(); + + const target = spawn_counter(rt, inbox.addr()); + const sentinel = spawn_sentinel(rt, target, deathInbox); + rt.tick(); // spawn + on_start (watch) + + rt.stop_actor(target); + // tick to process stop, cleanup, and deliver death notification + for (let i = 0; i < 5; i++) rt.tick(); + + const notification = deathInbox.try_recv(); + assert(notification !== undefined, "sentinel received death notification"); + assert( + typeof notification === "string" && notification.length > 0, + "notification is a non-empty string" + ); + + inbox.free(); + deathInbox.free(); + rt.free(); +} + +// ---- stats: total_messages ------------------------------------------------ +{ + console.log("test: stats — total_messages"); + const rt = new WasmRuntime(); + const inbox = rt.new_inbox_u32(); + const c = spawn_counter(rt, inbox.addr()); + rt.send_u32(c, 1); + rt.send_u32(c, 2); + rt.send_u32(c, 3); + rt.tick(); + assert(rt.total_messages() >= 3, "total_messages counts processed messages"); + inbox.free(); + rt.free(); +} + +// ---- stats: total_panics starts at zero ----------------------------------- +{ + console.log("test: stats — total_panics starts at zero"); + const rt = new WasmRuntime(); + assertEq(rt.total_panics(), 0, "no panics initially"); rt.free(); } diff --git a/docs/development_history/in-browser/BROWSER_RUNTIME.md b/docs/development_history/in-browser/BROWSER_RUNTIME.md new file mode 100644 index 0000000..6b0f0e5 --- /dev/null +++ b/docs/development_history/in-browser/BROWSER_RUNTIME.md @@ -0,0 +1,69 @@ +# Browser Runtime API — Development History + +> Stage 2 of the in-browser swactor runtime. Replaces the hardcoded PoC with +> a generic, type-safe API using opaque address handles and typed inboxes. + +--- + +## Changes + +### Core Types + +**`WasmRuntime`** — wraps `swactor::Runtime` in single-threaded mode. +Methods: `tick()`, `actor_count()`, `send_u32()`, `send_bytes()`, +`stop_actor()`, `uptime_ms()`, `new_inbox_u32()`, `new_inbox_bytes()`. +Also exposes `runtime()` for Rust-side custom spawn functions. + +**`WasmAddr`** — opaque handle wrapping `ActorAddress`. Returned by spawn +functions, passed to send functions. JS holds it as an opaque object. +Has `toString()` for debugging. + +**`WasmInboxU32`** / **`WasmInboxBytes`** — typed inboxes for receiving +results from actors. Each has `addr()` → `WasmAddr` (so actors know where +to send) and `try_recv()` → `Option`. + +### Design Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | Opaque `WasmAddr` handles instead of indices | Type-safe, stable identity, no out-of-bounds errors | +| 2 | Typed inbox types instead of generic `Inbox` | wasm-bindgen doesn't support generics; concrete types are explicit | +| 3 | Free-standing `spawn_*` functions, not methods | Each actor type gets its own spawn function with typed args | +| 4 | `send_u32`/`send_bytes` on runtime | Common send types; custom types use typed spawn wrappers | +| 5 | Evolved existing `crates/wasm/` instead of new crate | Less churn, existing build/test infrastructure | + +### Actor Pattern + +Users expose actors to JS by writing one `#[wasm_bindgen]` spawn function +per actor type: + +```rust +#[wasm_bindgen] +pub fn spawn_my_actor(rt: &WasmRuntime, arg: JsValue) -> WasmAddr { + let actor = MyActor::from_js(arg); + let addr = rt.runtime().spawn(actor).unwrap(); + WasmAddr(addr) +} +``` + +## Test Coverage + +10 Node.js tests in `crates/wasm/test.mjs`: + +| Test | Scenario | +|------|----------| +| accumulator | Counter processes messages, reports running totals to inbox | +| relay | Relay forwards messages to counter (cross-actor, 2 ticks) | +| multiple counters | Two independent counters report to same inbox | +| actor_count | Spawning 3 actors reflects in stats | +| WasmAddr toString | Address has non-empty debug representation | +| stop_actor | Graceful stop removes actor from runtime | +| bytes inbox | WasmInboxBytes receives Uint8Array correctly | +| uptime_ms | Returns non-negative number | + +## Verification + +- `cargo test -p swactor` — native tests pass (no regressions) +- `cargo build --target wasm32-unknown-unknown -p wasm` — compiles +- `wasm-pack build --target nodejs` in `crates/wasm/` — builds pkg/ +- `node test.mjs` in `crates/wasm/` — 10/10 tests pass diff --git a/docs/development_history/in-browser/DEMO.md b/docs/development_history/in-browser/DEMO.md new file mode 100644 index 0000000..3d8d117 --- /dev/null +++ b/docs/development_history/in-browser/DEMO.md @@ -0,0 +1,60 @@ +# Stage 5 — Interactive Browser Demo + +Visual verification page for the in-browser swactor runtime. Single self-contained +HTML file that loads the `--target web` wasm build and exposes every API surface +through a live dashboard. + +## Running + +```bash +# Build for browser (one-time, or after Rust changes) +cd crates/wasm && wasm-pack build --target web --out-dir pkg-web + +# Serve (any static server works — needs correct .wasm MIME type) +cd crates/wasm && python3 -m http.server 8080 +``` + +Open `http://localhost:8080/demo.html`. + +## What It Covers + +| Feature | How to verify | +|---|---| +| Runtime tick loop | Start/Pause button, Step for single tick, adjustable 1–60 tps | +| Actor spawning | Spawn Counter, Relay, GroupMember, Sentinel from dropdown | +| Message delivery | Send u32 to any actor, inbox polling shows received values | +| Cross-actor relay | Spawn Relay → target Counter, send to relay, counter accumulates | +| Actor stopping | Stop button on each card, actor disappears from viz | +| Watching / death notifications | Spawn Sentinel watching an actor, stop the watched actor | +| Name registry | Register/Lookup/Unregister names, live list in sidebar | +| Groups | GroupMember auto-joins on spawn, Broadcast sends to all members | +| Stats | Live actor count, total messages, total panics, uptime, tick count | + +## Architecture + +``` +demo.html + ├── imports pkg-web/wasm.js (ES module, --target web) + ├── creates WasmRuntime (single-threaded, StdExtension) + ├── requestAnimationFrame tick loop + ├── canvas visualization (actor circle graph + edges) + └── event log (spawn, send, recv, death, naming, groups) +``` + +All state lives in the page. No build step, no bundler, no framework — just +the wasm module and vanilla JS. + +## Suggested Walkthrough + +1. **Counter basics** — Spawn a Counter, Step once, click "Send 1", Step again. + Inbox log shows the running total. +2. **Relay chain** — Spawn Counter #1, then Relay targeting #1. Send to the relay, + observe the counter accumulating. +3. **Death watching** — Spawn a Counter, then a Sentinel watching it. Stop the + counter. The sentinel reports the death and self-terminates. +4. **Groups** — Spawn 3 GroupMembers in "workers". Hit "Broadcast 42". All three + receive the message. +5. **Naming** — Register "@main" for an actor. Lookup confirms it resolves. Unregister + and verify it's gone. +6. **Burst load** — Spawn several counters, click "Send ×10" on each, start the + runtime at 60 tps. Watch messages processed climb. diff --git a/docs/development_history/in-browser/FEATURE_PARITY.md b/docs/development_history/in-browser/FEATURE_PARITY.md new file mode 100644 index 0000000..2ac323b --- /dev/null +++ b/docs/development_history/in-browser/FEATURE_PARITY.md @@ -0,0 +1,75 @@ +# Feature Parity — Development History + +> Stage 4 of the in-browser swactor runtime. Enables swactor-std extensions +> (naming, monitoring, groups) and core actor watching in the wasm crate. + +--- + +## Changes + +### swactor-std wasm compilation + +- Added `wasm` feature to `crates/std/Cargo.toml` (forwards to `swactor/wasm`) +- Changed swactor dependency to `default-features = false`, forwarding `getrandom` + feature when active (`getrandom = ["dep:getrandom", "swactor/getrandom"]`) +- Cfg-gated `getrandom::getrandom()` call in `router.rs` `RoutingStrategy::Random` + — falls back to round-robin when `getrandom` feature is disabled (wasm mode) + +### RuntimeNaming: register_name + +- Added `register_name(name, addr)` method to `RuntimeNaming` trait and impl + — allows registering a name for an already-spawned actor from outside the runtime + — complements existing `spawn_named` (which spawns + registers atomically) + +### Core watching fix: StopSignal death notifications + +- Fixed gap in `worker.rs` tick_all: externally-stopped actors (via `rt.stop_actor()`) + were not added to the `deaths` list, so core WatchRegistry (phase 5b) never fired + for them. Added `deaths.push((addr, ExitReason::Stopped))` when StopSignal is + intercepted (line 737). All 140 existing native tests continue to pass. + +### WasmRuntime: StdExtension + new APIs + +- `WasmRuntime::new()` now installs `StdExtension` automatically +- New inbox type: `WasmInboxString` for receiving string notifications +- **Naming API**: `register_name`, `where_is`, `unregister_name`, `registered_names` +- **Groups API**: `join_group`, `leave_group`, `publish_to_group_u32`, + `group_member_count`, `group_names` +- **Stats API**: `total_messages`, `total_panics` (returned as f64 for JS compat) +- New demo actors: + - `Sentinel` — watches a target via `ctx.watch()`, reports death to string inbox + - `GroupMember` — joins a group on start, forwards u32 messages to report inbox + +### Design Decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| 1 | StdExtension always installed | Browser runtime should have full naming/groups by default | +| 2 | Stats as f64, not u64 | wasm-bindgen maps u64 to BigInt which JSON.stringify rejects | +| 3 | Sentinel actor for watching | Demonstrates core watching from JS without exposing Watch API directly | +| 4 | register_name on RuntimeNaming | Needed for post-spawn registration from JS (no actor context available) | +| 5 | Round-robin fallback for Random routing | wasm mode disables getrandom; graceful degradation preferred | + +## Test Coverage + +22 new assertions across 10 new test scenarios (30 total, from 10): + +| Test | Scenario | +|------|----------| +| naming — register_name and where_is | Register name, resolve, verify not-found returns undefined | +| naming — unregister_name | Unregister returns previous addr, name no longer resolves | +| naming — registered_names | Lists all registered names as CSV | +| naming — duplicate name rejected | Second registration with same name fails | +| groups — join_group and publish_to_group_u32 | Two members receive broadcast message | +| groups — leave_group | Member count decreases after leave | +| groups — group_names | Lists all active group names | +| watching — sentinel reports actor death | Stop target → sentinel receives death notification | +| stats — total_messages | Counts processed messages across workers | +| stats — total_panics starts at zero | Fresh runtime has zero panics | + +## Verification + +- `cargo test -p swactor -p swactor-std` — 157 native tests pass (no regressions) +- `cargo build --target wasm32-unknown-unknown -p wasm` — compiles +- `wasm-pack build --target nodejs` in `crates/wasm/` — builds pkg/ +- `node test.mjs` in `crates/wasm/` — 30/30 tests pass diff --git a/docs/development_history/in-browser/PLATFORM_ABSTRACTION.md b/docs/development_history/in-browser/PLATFORM_ABSTRACTION.md new file mode 100644 index 0000000..30738ed --- /dev/null +++ b/docs/development_history/in-browser/PLATFORM_ABSTRACTION.md @@ -0,0 +1,82 @@ +# Platform Abstraction Layer — Development History + +> Stage 1 of the in-browser swactor runtime. Makes core swactor compile for +> `wasm32-unknown-unknown` without behavioral changes on native targets. + +--- + +## Changes + +### 1. `web-time` dependency + `wasm` feature flag + +**File**: `Cargo.toml` + +Added `web-time` as an optional dependency and a `wasm` feature that bundles +`no_random` + `web-time`: + +```toml +wasm = ["no_random", "dep:web-time"] +web-time = { version = "0.2", optional = true } +``` + +`web-time` is a drop-in replacement for `std::time::Instant`: +- Native: re-exports `std::time::Instant` (zero-cost) +- wasm32: uses `performance.now()` via `js-sys` + +### 2. Platform-aware `Instant` re-export + +**File**: `src/lib.rs` + +```rust +#[cfg(feature = "wasm")] +pub(crate) use web_time::Instant; +#[cfg(not(feature = "wasm"))] +pub(crate) use std::time::Instant; +``` + +All modules (`runtime.rs`, `worker.rs`) now use `crate::Instant` instead of +`std::time::Instant`. Single point of truth — no cfg noise in consumer code. + +### 3. cfg-gated `Runtime::run()` and `RuntimeHandle` + +**File**: `src/runtime.rs` + +`Runtime::run()` calls `std::thread::spawn()` which is not available on wasm32. +Both `run()` and `RuntimeHandle` (which holds `JoinHandle<()>`) are gated: + +```rust +#[cfg(not(target_arch = "wasm32"))] +pub fn run(self) -> Result { ... } +``` + +On wasm32, the browser crate will provide its own `run()` via Web Workers. +`tick()` remains available on all platforms for single-threaded driving. + +### 4. Updated `crates/wasm/` to use `wasm` feature + +**File**: `crates/wasm/Cargo.toml` + +Changed from `features = ["no_random"]` to `features = ["wasm"]` to pick up +the `web-time` Instant on wasm32. + +## What Did NOT Need Abstraction + +Key discovery: on wasm32 with the `+atomics` target feature, most of +`std::sync` and `std::thread` works: + +- `OnceLock` — compiles and works (futex-based) +- `Thread::unpark()` — works (futex → `memory.atomic.notify`) +- `thread::park_timeout()` — works (futex → `memory.atomic.wait32`) +- `thread::yield_now()` — works (no-op on wasm) +- `Mutex`, `RwLock` — work (futex-based) +- `crossbeam-queue` — works (uses `core::sync::atomic`) +- `AtomicBool/Usize/U64` — work (wasm atomic instructions) + +Only `std::thread::spawn()` and `JoinHandle` are not functional on wasm32. + +## Verification + +- `cargo test` — all native tests pass (no regressions) +- `cargo test --features wasm` — all native tests pass with wasm feature +- `cargo build --target wasm32-unknown-unknown --features wasm --no-default-features` — compiles +- `cargo build --target wasm32-unknown-unknown -p wasm` — existing PoC crate compiles diff --git a/src/lib.rs b/src/lib.rs index 146ff90..00d9f72 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,13 @@ pub mod runtime; #[cfg(feature = "transport")] pub mod transport; +// Platform-aware Instant: web_time on wasm, std::time on native. +// web_time is a no-op re-export of std::time::Instant on non-wasm targets. +#[cfg(feature = "wasm")] +pub(crate) use web_time::Instant; +#[cfg(not(feature = "wasm"))] +pub(crate) use std::time::Instant; + #[cfg(feature = "getrandom")] pub(crate) fn get_random(buf: &mut [u8]) { getrandom::getrandom(buf).unwrap() diff --git a/src/runtime.rs b/src/runtime.rs index dd65928..a412f91 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -2,8 +2,10 @@ use std::any::Any; use std::cell::RefCell; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use std::thread::{self, JoinHandle, Thread}; -use std::time::Instant; +#[cfg(not(target_arch = "wasm32"))] +use std::thread::{self, JoinHandle}; +use std::thread::Thread; +use crate::Instant; use crate::actor::{Actor, ActorAddress, ActorExited, ActorInterface, AnyActor, ExitReason, Message, StopSignal, TimerRequest}; use crate::channel::{Receiver, Sender}; @@ -67,11 +69,13 @@ impl Ask { } /// Handle for dealing with a runtime that has started via the `Runtime::run()` method. +#[cfg(not(target_arch = "wasm32"))] pub struct RuntimeHandle { pub runtime: Arc, threads: Vec>, } +#[cfg(not(target_arch = "wasm32"))] impl RuntimeHandle { pub fn join(self) { for handle in self.threads { @@ -333,6 +337,9 @@ impl Runtime { /// /// Works in both single-threaded and multi-threaded configurations. /// In single-threaded mode, one background thread is spawned. + /// + /// Not available on wasm32 — use the browser crate's Web Worker-based run instead. + #[cfg(not(target_arch = "wasm32"))] pub fn run(self) -> Result { self.is_running.store(true, Ordering::Release); diff --git a/src/worker.rs b/src/worker.rs index 901fc99..884d247 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -2,9 +2,9 @@ use std::any::Any; use std::cell::RefCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::thread; -use std::time::Instant; +use crate::Instant; use crate::actor::{ActorAddress, ActorExited, AnyActor, CloneMsg, ContextInner, Ctx, ExitReason, StopReason, StopSignal, TimerRequest}; use crate::channel::Receiver; @@ -735,6 +735,7 @@ impl ActorPool { slot.stopping = true; stats.stops.fetch_add(1, Ordering::Relaxed); slot.mailbox.clear(); + deaths.push((addr, ExitReason::Stopped)); #[cfg(feature = "tracing")] tracing::info!(actor_addr = %addr, "actor.stop_requested"); break;