From 82e72ff235a8aabd571d845e1401b3dfe43ee7c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 09:05:41 +0000 Subject: [PATCH] feat: generic browser runtime API with typed handles (Stage 2) Evolved crates/wasm/ from hardcoded PoC to a generic browser runtime: - WasmRuntime: wraps Runtime, provides tick/send/stop/stats/inbox creation - WasmAddr: opaque actor address handle for JS (replaces fragile indices) - WasmInboxU32, WasmInboxBytes: typed inboxes for receiving actor results - Free-standing spawn_counter/spawn_relay demonstrate the actor pattern - 10 Node.js tests pass (accumulator, relay, multi-counter, stop, bytes) Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski --- crates/wasm/src/lib.rs | 234 ++++++++++++------ crates/wasm/test.mjs | 120 ++++++--- .../in-browser/BROWSER_RUNTIME.md | 69 ++++++ 3 files changed, 317 insertions(+), 106 deletions(-) create mode 100644 docs/development_history/in-browser/BROWSER_RUNTIME.md diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 221a38d..7bf53cb 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -3,9 +3,148 @@ use wasm_bindgen::prelude::*; use swactor::actor::{ActorAddress, ActorInterface}; use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig}; -// --------------------------------------------------------------------------- -// 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() + } +} + +// ─── Runtime ──────────────────────────────────────────────────────────────── + +/// The browser-facing swactor runtime. +/// +/// Wraps `swactor::Runtime` in single-threaded mode. Actors are spawned via +/// dedicated spawn functions (one per actor type). The runtime is driven by +/// calling `tick()` — either manually or from a `setTimeout(0)` loop. +#[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() + }); + 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"), + } + } + + /// 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 + } +} + +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 +174,26 @@ impl ActorInterface for Relay { } } -// --------------------------------------------------------------------------- -// JS-facing runtime wrapper -// --------------------------------------------------------------------------- - +/// Spawn a counter that accumulates u32 values and reports running totals +/// to the given inbox address. #[wasm_bindgen] -pub struct SwactorRuntime { - rt: Runtime, - inbox: Inbox, - actors: Vec, +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] -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(), - } - } - - /// 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() - } +pub fn spawn_relay(rt: &WasmRuntime, target: &WasmAddr) -> WasmAddr { + let addr = rt + .rt + .spawn(Relay { target: target.0 }) + .expect("spawn relay"); + WasmAddr(addr) } diff --git a/crates/wasm/test.mjs b/crates/wasm/test.mjs index b23438a..7948b6b 100644 --- a/crates/wasm/test.mjs +++ b/crates/wasm/test.mjs @@ -1,4 +1,9 @@ -import { SwactorRuntime } from "./pkg/swactor_wasm.js"; +import { + WasmRuntime, + WasmAddr, + spawn_counter, + spawn_relay, +} from "./pkg/wasm.js"; let passed = 0; let failed = 0; @@ -23,77 +28,128 @@ 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(); } 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