feat: skeleton for in browser swactor engine #35
3 changed files with 317 additions and 106 deletions
|
|
@ -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<u32>,
|
||||
}
|
||||
|
||||
#[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<u32> {
|
||||
self.inner.try_recv()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbox that receives byte arrays from actors.
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmInboxBytes {
|
||||
inner: Inbox<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[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<Vec<u8>> {
|
||||
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<u32>,
|
||||
actors: Vec<ActorAddress>,
|
||||
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<u32> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
69
docs/development_history/in-browser/BROWSER_RUNTIME.md
Normal file
69
docs/development_history/in-browser/BROWSER_RUNTIME.md
Normal file
|
|
@ -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<T>`.
|
||||
|
||||
### 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<T>` | 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
|
||||
Loading…
Reference in a new issue