From 63cba1c4eb08543e43701b45c2d50d923d6145a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 08:26:07 +0000 Subject: [PATCH 1/6] research: in-browser runtime Cycle 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research phase for browser-native swactor runtime: - constraints.md: wasm-threads (SharedArrayBuffer), Rust-only actors, COOP/COEP required, no new modules in src/ - research_synthesis.md: P0-P4 priority ranking, platform dependency analysis, ecosystem comparison (Lunatic, wasmCloud, Actix) - 6 feature-stage docs: platform abstraction → single-worker → multi-worker parallelism → feature parity → transport → DX - Key decision: SharedArrayBuffer + wasm-threads over postMessage isolation (swactor's shared-memory architecture demands it) Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski --- big-feature-phase/notes/constraints.md | 58 ++++++++ .../feature-stages/01-platform-abstraction.md | 110 +++++++++++++++ .../02-single-worker-browser.md | 129 ++++++++++++++++++ .../03-multi-worker-parallelism.md | 118 ++++++++++++++++ .../notes/feature-stages/04-feature-parity.md | 109 +++++++++++++++ .../feature-stages/05-transport-foundation.md | 107 +++++++++++++++ .../feature-stages/06-developer-experience.md | 102 ++++++++++++++ big-feature-phase/notes/history.md | 4 + big-feature-phase/notes/research_synthesis.md | 87 ++++++++++++ big-feature-phase/notes/state.md | 46 +++++++ 10 files changed, 870 insertions(+) create mode 100644 big-feature-phase/notes/constraints.md create mode 100644 big-feature-phase/notes/feature-stages/01-platform-abstraction.md create mode 100644 big-feature-phase/notes/feature-stages/02-single-worker-browser.md create mode 100644 big-feature-phase/notes/feature-stages/03-multi-worker-parallelism.md create mode 100644 big-feature-phase/notes/feature-stages/04-feature-parity.md create mode 100644 big-feature-phase/notes/feature-stages/05-transport-foundation.md create mode 100644 big-feature-phase/notes/feature-stages/06-developer-experience.md create mode 100644 big-feature-phase/notes/history.md create mode 100644 big-feature-phase/notes/research_synthesis.md create mode 100644 big-feature-phase/notes/state.md diff --git a/big-feature-phase/notes/constraints.md b/big-feature-phase/notes/constraints.md new file mode 100644 index 0000000..fefcef6 --- /dev/null +++ b/big-feature-phase/notes/constraints.md @@ -0,0 +1,58 @@ +# Constraints — In-Browser Swactor Runtime + +## Threading Model + +- **wasm-threads is mandatory** — the runtime uses SharedArrayBuffer + WebAssembly atomics for multi-worker parallelism. There is no single-threaded degraded mode for MVP. +- Browsers must serve pages with COOP/COEP headers: + - `Cross-Origin-Opener-Policy: same-origin` + - `Cross-Origin-Embedder-Policy: require-corp` +- Build requires nightly Rust + `-Z build-std=std,panic_abort` + target features `+atomics,+bulk-memory,+mutable-globals`. + +## Architecture Rules + +- **Platform abstractions live in core swactor** (`src/`), gated by `#[cfg(target_arch = "wasm32")]`. They do not belong in the wasm crate. +- **Do not add new modules** to `src/` — modify existing files only (TASK.md style rule). +- **Do not restructure** existing module boundaries. The abstraction is a thin layer (type aliases, cfg-gated imports), not a trait-based HAL. +- The browser crate (`crates/wasm-browser/` or evolved `crates/wasm/`) is a **thin wasm-bindgen shell**. All scheduling, routing, and actor logic stays in core Rust. + +## Actor Model + +- **Rust-only actors** — actors are written in Rust and compiled to wasm. JavaScript does not define actor behavior. +- JS interacts through the wasm-bindgen API: create runtime, spawn actors (by registered type), send messages, receive results. +- Actor types are registered at compile time via Rust generics, not dynamically from JS. + +## Performance Priorities + +- Maximize throughput: auto-scheduling via `setTimeout(0)` tight loop, not `requestAnimationFrame` (which caps at display refresh rate). +- Web Worker count defaults to `navigator.hardwareConcurrency` for full core utilization. +- Zero-copy where possible: SharedArrayBuffer eliminates serialization between workers. +- Minimize JS↔Wasm boundary crossings — batch operations where feasible. + +## Feature Scope + +- All core features that compile for wasm32: spawn, send, receive, tick, actor lifecycle, watching, extensions. +- swactor-std features (naming, groups, monitoring) should work if they compile. +- Transport: WebSocket adapter for distributed clusters. STUN/TURN (WebRTC) deferred to later. +- Features that require OS primitives not available in wasm (filesystem, raw TCP) are excluded. + +## Testing + +- Tests must pass on both native (`cargo test`) and wasm targets. +- Wasm tests use `wasm-pack test --headless --chrome` or Node.js with `--experimental-wasm-threads`. +- No test-only code paths that diverge native vs wasm behavior — if it works differently, it's a bug. +- Prefer scenario tests over structural tests (per project testing rules). + +## Dependencies + +- `web-time` — drop-in replacement for `std::time::Instant` on wasm32 +- `wasm-bindgen` + `js-sys` + `web-sys` — browser API bindings (in the wasm crate only, not core) +- `gloo-timers` — optional, for ergonomic setTimeout/setInterval +- No new dependencies in core swactor beyond `web-time` (which is no-op on native) + +## What We Don't Do + +- No async/await runtime (tokio, async-std) — swactor is synchronous tick-based +- No Emscripten — target is `wasm32-unknown-unknown` only +- No WASI — browser environment, not server-side wasm +- No JS actor definitions — Rust only +- No polyfills for missing atomics — if SharedArrayBuffer isn't available, the runtime doesn't start diff --git a/big-feature-phase/notes/feature-stages/01-platform-abstraction.md b/big-feature-phase/notes/feature-stages/01-platform-abstraction.md new file mode 100644 index 0000000..9fa5954 --- /dev/null +++ b/big-feature-phase/notes/feature-stages/01-platform-abstraction.md @@ -0,0 +1,110 @@ +# Stage 1 — Platform Abstraction Layer + +**Priority**: P0 +**Depends on**: Nothing +**Enables**: All subsequent stages + +## Goal + +Make core swactor compile for `wasm32-unknown-unknown` with `+atomics,+bulk-memory,+mutable-globals` target features. No behavioral changes on native targets. No new modules — only modify existing files with `cfg` gates. + +## What Changes + +### 1. Instant → web_time::Instant + +**Files**: `src/runtime.rs`, `src/worker.rs` + +Add `web-time` to `[dependencies]` (it's a no-op on non-wasm targets). Replace: +```rust +use std::time::Instant; +``` +with: +```rust +use web_time::Instant; +``` + +`web-time` is a drop-in replacement. The `Instant` type has identical API on native (delegates to `std::time::Instant`) and on wasm32 (uses `performance.now()`). + +**Scope**: 2 `use` statements, 0 logic changes. + +### 2. Thread Parking → ParkHandle + +**Files**: `src/runtime.rs`, `src/delivery.rs`, `src/worker.rs` + +Currently uses `OnceLock` + `thread::park_timeout` + `Thread::unpark`. On wasm32, there's no `Thread` type accessible from Rust (workers are JS objects). But wasm-threads supports `Atomics.wait`/`Atomics.notify` through Rust's `std::sync::atomic` and futex primitives. + +Approach: Define a `ParkHandle` abstraction in `src/runtime.rs`: + +**Native**: +```rust +#[cfg(not(target_arch = "wasm32"))] +mod parking { + pub type ParkHandle = OnceLock; + pub fn register(handle: &ParkHandle) { handle.set(thread::current()).ok(); } + pub fn unpark(handle: &ParkHandle) { if let Some(t) = handle.get() { t.unpark(); } } + pub fn park_timeout_us(micros: u64) { thread::park_timeout(Duration::from_micros(micros)); } + pub fn yield_now() { thread::yield_now(); } +} +``` + +**Wasm32**: +```rust +#[cfg(target_arch = "wasm32")] +mod parking { + // Use an AtomicI32 as a futex-like signal. Atomics.wait blocks the + // wasm thread, Atomics.notify wakes it — same semantics as park/unpark. + pub struct ParkHandle(AtomicI32); + pub fn register(_: &ParkHandle) {} // no-op, handle is pre-initialized + pub fn unpark(handle: &ParkHandle) { + handle.0.store(1, Ordering::Release); + std::sync::atomic::fence(Ordering::SeqCst); + // Atomics.notify via core::arch::wasm32::memory_atomic_notify + core::arch::wasm32::memory_atomic_notify(&handle.0 as *const _ as *mut i32, 1); + } + pub fn park_timeout_us(micros: u64) { + // Atomics.wait via core::arch::wasm32::memory_atomic_wait32 + core::arch::wasm32::memory_atomic_wait32(ptr, 0, timeout_ns as i64); + } + pub fn yield_now() {} // no-op on wasm +} +``` + +**Scope**: New `parking` sub-module in `runtime.rs` (~30 lines), update `TickContext` to use `ParkHandle` instead of `OnceLock`, update `worker.rs` backoff loop. + +### 3. Thread Spawning — No Change in Core + +Thread spawning (`std::thread::Builder::new().spawn()`) only happens in `Runtime::run()` (line 351). This method will be overridden/wrapped by the browser crate — it won't be called on wasm32. We can gate it: + +```rust +#[cfg(not(target_arch = "wasm32"))] +pub fn run(self) -> Result { ... } +``` + +The wasm browser crate will provide its own `run()` that spawns Web Workers instead. + +### 4. Validate crossbeam Compilation + +Test that `crossbeam-queue` compiles for wasm32 with atomics. If it doesn't, provide a cfg-gated fallback in `src/channel.rs` using `VecDeque` wrapped in `Mutex`. (Likely not needed — crossbeam uses `core::sync::atomic` which works with wasm atomics.) + +### 5. Feature Flag + +Add a `wasm` feature to `Cargo.toml`: +```toml +[features] +wasm = ["web-time", "no_random"] + +[dependencies] +web-time = { version = "0.2", optional = true } +``` + +On wasm32, this feature enables `web-time` and `no_random` together. + +## Verification + +1. `cargo test` passes unchanged on native +2. `cargo build --target wasm32-unknown-unknown --features wasm -Z build-std=std,panic_abort` compiles (may need `+atomics` RUSTFLAGS) +3. No runtime behavior changes on native (confirm with existing test suite) + +## Estimated Scope + +~50-80 lines of new/changed code across 4 files. No new modules. diff --git a/big-feature-phase/notes/feature-stages/02-single-worker-browser.md b/big-feature-phase/notes/feature-stages/02-single-worker-browser.md new file mode 100644 index 0000000..f48e655 --- /dev/null +++ b/big-feature-phase/notes/feature-stages/02-single-worker-browser.md @@ -0,0 +1,129 @@ +# Stage 2 — Single-Worker Browser Runtime + +**Priority**: P0 +**Depends on**: Stage 1 (platform abstraction) +**Enables**: Stage 3 (multi-worker), Stage 4 (feature parity) + +## Goal + +A working browser runtime on a single dedicated Web Worker with a JS API that supports spawning arbitrary (pre-registered) actor types, sending messages, receiving results, and auto-scheduled ticking. + +## What's Built + +### 1. New Crate: `crates/wasm-browser/` + +Replaces the PoC `crates/wasm/`. Structure: + +``` +crates/wasm-browser/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # wasm-bindgen entry point +│ ├── runtime.rs # BrowserRuntime wrapping swactor::Runtime +│ ├── worker_glue.rs # Web Worker spawn/communication glue +│ └── scheduling.rs # Auto-tick scheduling (setTimeout loop) +├── js/ +│ ├── worker.js # Web Worker bootstrap script +│ └── index.js # Main thread API wrapper (optional) +└── tests/ + └── browser.rs # wasm-pack test suite +``` + +### 2. BrowserRuntime (wasm-bindgen API) + +```rust +#[wasm_bindgen] +pub struct BrowserRuntime { ... } + +#[wasm_bindgen] +impl BrowserRuntime { + #[wasm_bindgen(constructor)] + pub fn new(config: JsValue) -> Self; + + /// Spawn an actor by type name. Returns an opaque handle. + pub fn spawn(&mut self, type_name: &str, init: JsValue) -> JsValue; + + /// Send a message to an actor. + pub fn send(&self, addr: JsValue, msg: JsValue) -> bool; + + /// Drive one tick manually. + pub fn tick(&self); + + /// Start auto-scheduling. Calls tick() in a tight setTimeout(0) loop. + pub fn start(&self); + + /// Stop auto-scheduling. + pub fn stop(&self); + + /// Poll for results from a JS-visible inbox. + pub fn try_recv(&self) -> JsValue; + + /// Runtime stats snapshot. + pub fn stats(&self) -> JsValue; +} +``` + +### 3. Actor Registration + +Since Rust generics can't be dynamically dispatched from JS, actor types are registered at compile time: + +```rust +// In the user's wasm crate that depends on wasm-browser: +register_actors! { + "counter" => Counter, + "relay" => Relay, +} +``` + +This macro generates a factory map that `BrowserRuntime::spawn` indexes by string name. Each entry knows how to deserialize `JsValue` init args into the actor's constructor. + +### 4. Auto-Scheduling + +The runtime self-drives via a `setTimeout(0)` loop: + +```javascript +function tickLoop() { + runtime.tick(); + if (runtime.is_running()) { + setTimeout(tickLoop, 0); + } +} +``` + +This runs as fast as the browser allows (~4ms between ticks in most browsers, faster in Web Workers). The Rust side just calls `tick()` — no async runtime needed. + +### 5. Message Serialization + +JS ↔ Wasm boundary requires serialization. Options: +- **serde-wasm-bindgen**: Serialize Rust types to/from JsValue via serde. Zero-copy for simple types. +- **Manual**: Convert JsValue to bytes, route as `ByteMessage`. + +For Stage 2, use `serde-wasm-bindgen` for typed messages. Actor `Incoming` types must implement `serde::Deserialize`. + +### 6. Single Worker Architecture + +``` +┌─────────────────────┐ postMessage ┌──────────────────────┐ +│ Main Thread │ ◄──────────────────────► │ Web Worker │ +│ │ │ │ +│ JS application │ "spawn", "send", │ BrowserRuntime │ +│ calls API methods │ "tick", "recv" │ swactor::Runtime │ +│ │ │ (1 worker, tick()) │ +└─────────────────────┘ └──────────────────────┘ +``` + +The Web Worker runs the swactor runtime. The main thread sends commands via `postMessage`. This keeps the UI thread free. + +Alternative: run everything on the main thread (simpler, but blocks UI during tick). Support both modes — the user picks. + +## Verification + +1. `wasm-pack build --target web` succeeds +2. `wasm-pack test --headless --chrome` passes +3. Manual test: HTML page spawns actors, sends messages, receives results +4. Auto-scheduling: actors process messages continuously without manual tick calls +5. Performance: measure ticks/sec, compare to native single-threaded + +## Estimated Scope + +~300-500 lines of Rust + ~50 lines of JS glue. diff --git a/big-feature-phase/notes/feature-stages/03-multi-worker-parallelism.md b/big-feature-phase/notes/feature-stages/03-multi-worker-parallelism.md new file mode 100644 index 0000000..2e1a2a1 --- /dev/null +++ b/big-feature-phase/notes/feature-stages/03-multi-worker-parallelism.md @@ -0,0 +1,118 @@ +# Stage 3 — Multi-Worker Parallelism + +**Priority**: P1 +**Depends on**: Stage 1 (platform abstraction), Stage 2 (single-worker browser) +**Enables**: Stage 5 (transport) + +## Goal + +Spawn N Web Workers sharing the same swactor runtime via SharedArrayBuffer. Actors distributed across workers for true multi-core parallelism. Worker count configurable, defaults to `navigator.hardwareConcurrency`. + +## Architecture + +``` +┌─────────────┐ +│ Main Thread │ postMessage API +│ (JS app) │◄─────────────────────┐ +└──────┬───────┘ │ + │ spawn workers │ + ▼ │ +┌──────────────┐ SharedArrayBuffer ┌──────────────┐ +│ Web Worker 0 │◄───────────────────►│ Web Worker 1 │ +│ swactor │ (InboxRegistry, │ swactor │ +│ Worker #0 │ AddressMap, │ Worker #1 │ +│ tick loop │ transfer queues, │ tick loop │ +└──────────────┘ atomics) └──────────────┘ + ... + ┌──────────────┐ + │ Web Worker N │ + │ swactor │ + │ Worker #N │ + └──────────────┘ +``` + +## Key Challenge: Web Worker ↔ SharedArrayBuffer + +Web Workers can share `SharedArrayBuffer` instances. The swactor `Runtime` struct contains `Arc`-wrapped shared state (InboxRegistry, AddressMap, etc.). On native, this memory is shared via the process address space. On wasm with SharedArrayBuffer, it's shared via the underlying wasm linear memory. + +### How It Works + +1. **Main thread** creates the `Runtime` (allocates shared structures in wasm linear memory) +2. **Main thread** spawns N Web Workers, each loading the same `.wasm` module with `shared: true` memory +3. Each Web Worker receives a pointer (offset) to the `Runtime` shared state +4. Each Worker runs `worker.run(&tc, &is_running)` — the same tick loop as native +5. Crossbeam queues, atomics, Mutex/RwLock all work because the underlying memory is shared + +### wasm-bindgen + Web Workers + +The `web-sys::Worker` API creates workers. Each worker loads the same wasm module: + +```javascript +// worker.js (loaded by each Web Worker) +import init, { worker_entry } from './pkg/wasm_browser.js'; + +self.onmessage = async (e) => { + const { module, memory, worker_id, runtime_ptr } = e.data; + await init({ module, memory }); // shared memory! + worker_entry(worker_id, runtime_ptr); +}; +``` + +The Rust side: +```rust +#[wasm_bindgen] +pub fn worker_entry(worker_id: usize, runtime_ptr: u32) { + // Reconstruct the shared Runtime reference from the raw pointer + // Run the tick loop for this worker +} +``` + +### Memory Sharing + +With `wasm-threads`, the wasm linear memory is backed by a `SharedArrayBuffer`. All workers see the same memory. `Arc` increments are atomic operations on shared memory. Crossbeam queues use atomic compare-and-swap on shared memory. This is identical to how it works with OS threads. + +**Critical**: The wasm module must be compiled with `--shared-memory` and the `atomics` feature. The `Memory` import must use `shared: true`. + +## Park / Unpark + +The `ParkHandle` from Stage 1 uses `memory_atomic_wait32` / `memory_atomic_notify` — the wasm equivalents of futex. These work across Web Workers sharing the same memory. + +- `park_timeout_us(micros)` → `Atomics.wait(ptr, expected, timeout)` — blocks the Worker thread +- `unpark(handle)` → `Atomics.notify(ptr, 1)` — wakes one waiting Worker + +This gives the same backoff behavior as native: hot spin → yield → sleep with exponential backoff. + +## Worker Spawning + +Replace `std::thread::Builder::new().spawn()` in `Runtime::run()`: + +```rust +#[cfg(target_arch = "wasm32")] +pub fn run(self) -> Result { + let num_workers = self.config.num_threads.max(1); + let rt = Arc::new(self); + + for i in 0..num_workers { + let worker = web_sys::Worker::new("./worker.js")?; + worker.post_message(&JsValue::from(/* module, memory, worker_id, ptr */)); + } + // ... +} +``` + +## Placement + +The existing `Placement` strategy (load-aware round-robin) works unchanged — it reads `WorkerStats` atomics to pick the least-loaded worker. On wasm, these atomics are in SharedArrayBuffer, readable from any worker. + +## Verification + +1. Spawn runtime with `num_threads: 4`, verify 4 Web Workers created +2. Spawn actors, verify they're distributed across workers (check stats per-worker actor count) +3. Cross-worker message delivery works (actor on Worker 0 sends to actor on Worker 1) +4. Backoff/parking works (idle workers sleep, wake on new messages) +5. Throughput scales with worker count (benchmark: N workers vs 1 worker) +6. Shutdown: all workers terminate cleanly when `is_running` set to false + +## Estimated Scope + +~200-300 lines Rust + ~30 lines JS worker bootstrap. Most complexity is in the Web Worker ↔ shared memory plumbing, not the Rust logic (which is the same as native). diff --git a/big-feature-phase/notes/feature-stages/04-feature-parity.md b/big-feature-phase/notes/feature-stages/04-feature-parity.md new file mode 100644 index 0000000..2e49512 --- /dev/null +++ b/big-feature-phase/notes/feature-stages/04-feature-parity.md @@ -0,0 +1,109 @@ +# Stage 4 — Feature Parity + +**Priority**: P1 +**Depends on**: Stage 2 (single-worker) or Stage 3 (multi-worker) +**Enables**: Stage 5 (transport), Stage 6 (DX) + +## Goal + +All swactor features that are feasible in a browser environment work and are tested: actor watching, swactor-std extensions (naming, groups, monitoring), stats introspection. + +## Features to Enable + +### 1. Actor Watching / Death Notifications + +**Status**: Already in core (`src/runtime.rs`, `src/worker.rs`) + +Components: +- `ExitReason` enum — normal, panic, stopped +- `ActorExited` message — delivered to watchers +- `on_actor_exit()` default method on `ActorInterface` +- `WatchRegistry` — tracks who watches whom +- Phase 5b in `tick_once` — delivers death notifications + +**Wasm concern**: `WatchRegistry` is behind `Arc>`. With wasm-threads, `Mutex` works. The catch_unwind panic-safety model works identically in wasm. + +**Work needed**: Compile and test. Write wasm-specific tests for: +- Actor dies → watchers notified +- Watcher on different Web Worker receives notification (cross-worker) +- Panic in wasm actor → poisoned, watchers notified + +### 2. swactor-std Extension + +**Status**: Complete in `crates/std/` + +Components: +- `StdExtension` — wraps NameRegistry + MonitorRegistry + GroupRegistry +- `CtxMonitoring` — watch/unwatch actors +- `CtxNaming` — register/resolve actor names +- `CtxGroups` — join/leave groups, broadcast +- `RuntimeNaming` — resolve names from runtime handle +- `RuntimeGroups` — list groups, broadcast from outside +- Supervisor — restart policies + +**Wasm concern**: All use `Arc`, `Mutex`, `HashMap` — standard types that work with wasm-threads. No OS-specific dependencies. + +**Work needed**: +- Add `crates/std/` to wasm build verification +- Test naming: register name → resolve from another actor on different worker +- Test groups: broadcast reaches all group members across workers +- Test supervisor: child dies → supervisor restarts (factory-based) + +### 3. Stats and Introspection + +**Status**: In core (`src/stats.rs`) + +Components: +- `WorkerStats` — per-worker atomic counters (actors, depth, ticks, messages) +- `RuntimeStats` — aggregated snapshot +- `StatsHook` trait — called each tick with stats + +**Wasm concern**: Atomic counters work with wasm-threads. `StatsHook` is called in the tick loop — works. + +**Work needed**: +- Expose `RuntimeStats` to JS via `serde-wasm-bindgen` (JSON-serializable snapshot) +- Optional: periodic stats push to main thread via `postMessage` +- Test: spawn actors across workers, verify stats reflect correct counts + +### 4. Runtime Extensions + +**Status**: In core (`src/extension.rs`) + +The `RuntimeExtension` trait (`on_actor_death`, `cleanup_dead`, `as_any`) is called during phase 7 of tick_once. It uses `Arc` — works with wasm-threads. + +**Work needed**: Verify `StdExtension` as a `RuntimeExtension` compiles and works in wasm. Test the full lifecycle: actor death → extension notified → cleanup runs. + +## JS API Additions + +Extend the `BrowserRuntime` wasm-bindgen API: + +```rust +impl BrowserRuntime { + // Actor watching + pub fn watch(&self, watcher: JsValue, target: JsValue) -> bool; + + // Naming (if StdExtension enabled) + pub fn register_name(&self, name: &str, addr: JsValue) -> bool; + pub fn resolve_name(&self, name: &str) -> JsValue; + + // Groups + pub fn join_group(&self, group: &str, addr: JsValue) -> bool; + pub fn broadcast_group(&self, group: &str, msg: JsValue) -> bool; + + // Stats + pub fn stats(&self) -> JsValue; // JSON snapshot of RuntimeStats +} +``` + +## Verification + +1. All existing native tests for watching/std pass on wasm target +2. Cross-worker watching: actor on Worker 0 watches actor on Worker 1, Worker 1 actor dies → notification arrives +3. Naming works across workers: register on Worker 0, resolve on Worker 1 +4. Group broadcast reaches actors on all workers +5. Stats counters are accurate across workers (compare sum to expected) +6. `cargo test` still passes on native (no regressions) + +## Estimated Scope + +~100-200 lines of new wasm-bindgen API surface + ~200 lines of wasm tests. Core logic should work as-is once it compiles. diff --git a/big-feature-phase/notes/feature-stages/05-transport-foundation.md b/big-feature-phase/notes/feature-stages/05-transport-foundation.md new file mode 100644 index 0000000..3c3b96e --- /dev/null +++ b/big-feature-phase/notes/feature-stages/05-transport-foundation.md @@ -0,0 +1,107 @@ +# Stage 5 — Transport Foundation + +**Priority**: P2 +**Depends on**: Stage 3 (multi-worker), Stage 4 (feature parity) +**Enables**: Browser nodes joining distributed swactor clusters + +## Goal + +Browser nodes connect to native swactor clusters via WebSocket. A browser can spawn actors that communicate with actors on server nodes. Foundation for future STUN/TURN (WebRTC DataChannel) for browser-to-browser direct connections. + +## Architecture + +``` +┌──────────────────┐ WebSocket ┌──────────────────┐ +│ Browser Node │ ◄────────────────────────► │ Server Node │ +│ (wasm runtime) │ │ (native runtime)│ +│ │ swactor wire protocol │ │ +│ Actor A ──────►─┤───── msg for Actor B ─────►├──► Actor B │ +│ │ │ │ +│ Actor C ◄───────┤◄──── msg for Actor C ─────┤───── Actor D │ +└──────────────────┘ └──────────────────┘ +``` + +## Existing Transport Infrastructure + +swactor already has a transport layer (feature-gated under `transport`): + +- `src/transport.rs` — `TransportRouter`, `CodecRegistry`, remote message routing +- `crates/distribution/` — SWIM protocol, gossip, cluster membership +- `crates/distribution/src/driver.rs` — `NodeDriver` bridges `DistributedNode` ↔ TCP +- Wire protocol: Ping/Ack/PingReq with piggyback bytes + +The browser transport needs to implement the same wire protocol over WebSocket instead of raw TCP. + +## What's Built + +### 1. WebSocket Transport Adapter + +A new module in `crates/wasm-browser/` (not in core): + +```rust +pub struct WebSocketTransport { + ws: web_sys::WebSocket, + // ... +} + +impl TransportAdapter for WebSocketTransport { + fn send(&self, dest: SocketAddr, data: &[u8]) -> Result<(), Error>; + fn recv(&self) -> Option<(SocketAddr, Vec)>; +} +``` + +Uses `web-sys::WebSocket` for the browser side. The server side uses a WebSocket server (e.g., `tokio-tungstenite`) that bridges to the existing TCP transport. + +### 2. WebSocket ↔ TCP Bridge (Server Side) + +A thin relay server that accepts WebSocket connections from browsers and translates to/from the TCP wire protocol: + +``` +Browser ──WebSocket──► Bridge Server ──TCP──► swactor-node +``` + +This bridge is a separate binary/service, not part of the runtime. It's a protocol translator. + +### 3. Browser Node Identity + +Browser nodes need: +- A unique node ID (derived from random or assigned by the cluster) +- An address for the cluster to route messages to (the WebSocket endpoint) +- Membership in the SWIM protocol (lightweight — browsers are "client" members that don't participate in failure detection) + +### 4. Cluster Registry Integration + +The existing ClusterRegistry (LWW-Register CRDT in `crates/distribution/src/registry.rs`) should work from browsers: +- `register_name` / `resolve_name` / `registry_events` — all work over the wire +- Piggyback payloads carry registry updates through the WebSocket connection + +## Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Transport protocol | WebSocket (binary frames) | Universal browser support, bidirectional, binary-capable | +| Membership role | Client member (no failure detection) | Browsers are ephemeral; full SWIM overhead not justified | +| Bridge architecture | Separate relay server | Keeps swactor-node unchanged; bridge handles WebSocket↔TCP | +| Wire format | Same as TCP transport | No translation needed beyond framing (WebSocket frames ↔ TCP stream) | + +## STUN/TURN Foundation (Future) + +This stage establishes the transport abstraction. Stage 5 itself is WebSocket only. Future work: + +- **WebRTC DataChannel** — direct browser-to-browser, requires STUN/TURN for NAT traversal +- The `TransportAdapter` trait from this stage will have a WebRTC implementation +- STUN/TURN server infrastructure is out of scope for this feature phase + +## Verification + +1. Browser node connects to server cluster via WebSocket +2. Actor on browser sends message to actor on server → received +3. Actor on server sends message to actor on browser → received +4. Browser appears in cluster membership (visible in dashboard) +5. ClusterRegistry: name registered on server → resolvable from browser +6. Browser disconnects → cluster detects and removes membership +7. Reconnection: browser reconnects → re-joins cluster, actor addresses still valid + +## Estimated Scope + +~500-800 lines for WebSocket transport adapter + bridge server. Builds heavily on existing distribution infrastructure. diff --git a/big-feature-phase/notes/feature-stages/06-developer-experience.md b/big-feature-phase/notes/feature-stages/06-developer-experience.md new file mode 100644 index 0000000..dab3283 --- /dev/null +++ b/big-feature-phase/notes/feature-stages/06-developer-experience.md @@ -0,0 +1,102 @@ +# Stage 6 — Developer Experience + +**Priority**: P3 +**Depends on**: Stage 2 (single-worker browser), Stage 4 (feature parity) +**Enables**: Adoption, ecosystem growth + +## Goal + +Make it easy for Rust developers to build browser applications with swactor. TypeScript type safety, build tooling, and debugging support. + +## Features + +### 1. TypeScript Type Generation + +Derive TypeScript interfaces from Rust actor message types. When an actor defines: + +```rust +#[derive(Serialize, Deserialize)] +pub struct ChatMessage { + pub from: String, + pub text: String, +} +``` + +Generate: +```typescript +export interface ChatMessage { + from: string; + text: string; +} +``` + +**Approach**: Use `ts-rs` crate or a custom proc macro that emits `.d.ts` files during `wasm-pack build`. This gives TypeScript consumers compile-time type checking for messages. + +### 2. Build Tooling + +A `swactor-build` CLI or build script that wraps: +```bash +RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals' \ + cargo +nightly build --target wasm32-unknown-unknown \ + -Z build-std=std,panic_abort \ + --release +wasm-bindgen --target web --out-dir pkg/ ... +``` + +Into: +```bash +swactor-build --target browser +``` + +Features: +- Detects nightly toolchain, installs if missing +- Sets correct RUSTFLAGS for wasm-threads +- Runs wasm-bindgen with correct target +- Copies worker.js bootstrap into output +- Generates example HTML with correct COOP/COEP headers + +### 3. Example Project Template + +A `cargo generate` template or example project: + +``` +my-swactor-app/ +├── Cargo.toml +├── src/ +│ └── lib.rs # Define actors, register them +├── web/ +│ ├── index.html # With COOP/COEP headers +│ ├── main.js # Import wasm, create runtime, interact +│ └── worker.js # Web Worker bootstrap (auto-generated) +└── tests/ + └── browser.rs # wasm-pack tests +``` + +### 4. Browser Dev Tools Integration + +Expose runtime internals for debugging: + +- **Actor Inspector**: List all actors, their types, mailbox depths, message counts +- **Message Tracer**: Log messages between actors (opt-in, performance impact) +- **Performance Monitor**: Ticks/sec, messages/sec, worker utilization + +Implementation: A `console`-based logger that uses `web-sys::console` to output structured data. Optionally integrates with browser DevTools via custom formatters or a small React/Preact inspector panel. + +### 5. Documentation + +- Getting started guide +- Architecture overview for browser runtime +- Migration guide from native → browser (what works, what doesn't) +- API reference (generated from Rust doc comments) +- Example: Chat application with multiple browser tabs + +## Verification + +1. TypeScript types match Rust types (compile TS project against generated `.d.ts`) +2. Build tool produces working wasm output from example project +3. Template project builds and runs out of the box +4. Dev tools show actor state in browser console + +## Estimated Scope + +Variable — this stage is a collection of independent DX improvements. Each can be implemented and shipped independently. Total: ~500-1000 lines across Rust, JS, and documentation. diff --git a/big-feature-phase/notes/history.md b/big-feature-phase/notes/history.md new file mode 100644 index 0000000..67e6a3f --- /dev/null +++ b/big-feature-phase/notes/history.md @@ -0,0 +1,4 @@ +# Cycle History (append-only) + +## Cycle 0 — Research (complete) +Investigated Lunatic, wasmCloud, Actix-wasm attempts. Analyzed core swactor platform deps: 4 blockers (thread spawn, park/unpark, yield, Instant). User confirmed: performance-first, SharedArrayBuffer+wasm-threads, Rust-only actors, all feasible features, future STUN/TURN. Produced constraints.md, research_synthesis.md, 6 feature-stage docs. Next: Stage 1 platform abstraction. diff --git a/big-feature-phase/notes/research_synthesis.md b/big-feature-phase/notes/research_synthesis.md new file mode 100644 index 0000000..aaa5da6 --- /dev/null +++ b/big-feature-phase/notes/research_synthesis.md @@ -0,0 +1,87 @@ +# Research Synthesis — In-Browser Swactor Runtime + +## Ecosystem Landscape + +No established Rust actor framework runs natively in browsers. The closest projects: + +- **Lunatic** — Erlang-inspired Wasm actor runtime using wasmtime (server-side, not browser). Uses preemptive scheduling and work-stealing. Not applicable to browser constraints. +- **wasmCloud** — CNCF distributed actor platform. Single-threaded actors, NATS-backed lattice. Cloud/edge focus, no browser target. +- **Actix** — Tokio-dependent, network stack doesn't compile for wasm32. Community attempts to port failed due to `net2`/tokio dependencies. + +**Implication**: swactor would be the first Rust actor runtime with true multi-threaded browser execution via wasm-threads. This is a differentiated position. + +## Existing Work in This Codebase + +| Component | Status | Notes | +|-----------|--------|-------| +| `crates/wasm/` | Basic PoC | Hardcoded Counter/Relay actors, manual tick, u32-only messages | +| `no_random` feature | Working | Deterministic address generation without `getrandom` | +| `tick()` method | Working | Single-threaded tick for manual driving | +| Actor watching | In core | `ExitReason`, `ActorExited`, `on_actor_exit`, `WatchRegistry` | +| swactor-std | Complete | StdExtension, naming, groups, monitoring, supervisor | + +## Platform Dependencies Analysis + +### Works as-is with wasm-threads +- `crossbeam-queue` (ArrayQueue, SegQueue) — uses `core::sync::atomic` +- `std::sync::{Mutex, RwLock}` — stdlib uses futex on wasm with atomics +- `std::sync::atomic::*` — maps to wasm atomic instructions +- `Arc` — works with atomics +- `std::sync::OnceLock` — works with atomics + +### Requires platform abstraction (4 items) +1. `std::thread::spawn` → Web Worker via `web-sys::Worker` +2. `thread::park_timeout` / `Thread::unpark` → `Atomics.wait` / `Atomics.notify` +3. `thread::yield_now` → no-op (or `Atomics.wait(0)` as hint) +4. `std::time::Instant` → `web_time::Instant` (drop-in crate) + +## Priority Ranking + +### P0 — Must Have (enables everything else) + +1. **Platform abstraction layer** — cfg-gated replacements for thread spawn, park/unpark, yield, Instant. Core swactor compiles for wasm32 with atomics. +2. **Single-worker browser runtime** — Prove the runtime works in a browser. One Web Worker, auto-scheduled tick loop, generic JS API for spawn/send/recv. +3. **Multi-worker parallelism** — N Web Workers sharing runtime state via SharedArrayBuffer. Full utilization of browser CPU cores. + +### P1 — Should Have (full actor system) + +4. **Actor watching in browser** — Death notifications, exit reasons. Already in core, just needs to compile and pass wasm tests. +5. **swactor-std in browser** — Naming, groups, monitoring extensions. Compile and test for wasm32. +6. **Stats and introspection** — Runtime stats accessible from JS. Worker info, actor counts, message throughput. + +### P2 — Important (distributed peer) + +7. **WebSocket transport** — Adapter implementing swactor's transport traits over WebSocket. Browser node joins a distributed cluster. +8. **Browser-to-browser transport foundation** — WebRTC DataChannel scaffolding for future STUN/TURN. + +### P3 — Nice to Have (developer experience) + +9. **TypeScript type generation** — Derive TS interfaces from Rust actor message types. +10. **Build tooling** — wasm-pack wrapper script, example project template, CI configuration. +11. **Browser dev tools** — Actor inspector, message flow visualization, performance profiling. + +### P4 — Future (out of scope for this feature phase) + +12. **STUN/TURN integration** — Full NAT traversal for peer-to-peer browser connections. +13. **Hot code reload** — Swap actor implementations without restarting the runtime. +14. **Wasm component model** — Migrate from wasm-bindgen to component model when stabilized. + +## Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Threading model | SharedArrayBuffer + wasm-threads | swactor's shared-memory architecture (Arc, crossbeam queues, atomics) maps directly. postMessage isolation would require a rewrite. | +| Scheduling | `setTimeout(0)` tight loop | `requestAnimationFrame` caps at 60Hz. setTimeout(0) gives ~4ms resolution, sufficient for actor ticks. For rendering-coupled actors, RAF can be opt-in. | +| Actor definition | Rust only | Keeps the type system intact. JS actors would require dynamic dispatch and lose compile-time guarantees. | +| Platform abstraction approach | cfg-gated type aliases + inline functions | Minimal invasion. No trait-based HAL, no new modules. Just swap `std::time::Instant` → `web_time::Instant` etc. | +| Browser crate location | New `crates/wasm-browser/` | Clean separation from the existing PoC. The old `crates/wasm/` can be deprecated or kept as a minimal example. | + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| crossbeam-queue doesn't compile for wasm32+atomics | Low | High | Test early in Stage 1. Fallback: thin wrapper over `VecDeque` behind cfg. | +| COOP/COEP headers break third-party integrations | Medium | Medium | Document clearly. This is the standard trade-off for SharedArrayBuffer apps. | +| Nightly Rust + build-std is unstable | Medium | Medium | Pin to a known-good nightly. Revisit when wasm-threads stabilizes. | +| Web Worker spawn latency | Low | Low | Spawn workers eagerly at runtime init, not per-actor. | +| Browser compatibility gaps | Low | Medium | Target Chrome/Firefox/Safari latest. All support SharedArrayBuffer since 2021. | diff --git a/big-feature-phase/notes/state.md b/big-feature-phase/notes/state.md new file mode 100644 index 0000000..e0f952a --- /dev/null +++ b/big-feature-phase/notes/state.md @@ -0,0 +1,46 @@ +## Current Task +Cycle 0 — Research +Step: Complete +Attempt: 1 of 3 + +## Key Files (read these first on resume) +- `big-feature-phase/TASK.md` — workflow rules +- `big-feature-phase/notes/constraints.md` — guardrails for in-browser runtime +- `big-feature-phase/notes/research_synthesis.md` — P0-P4 priority ranking +- `big-feature-phase/notes/feature-stages/01-platform-abstraction.md` — Stage 1 spec +- `src/runtime.rs` — thread spawn (line 351), park handles (line 110), Instant (line 205) +- `src/worker.rs` — backoff loop (lines 495-523), Instant timing (9 calls) +- `src/delivery.rs` — TickContext.worker_threads (line 246) +- `src/channel.rs` — crossbeam HybridChannel (validate wasm compilation) +- `crates/wasm/src/lib.rs` — existing PoC browser runtime + +## Last Action & Result +Completed Cycle 0 research phase: +- Investigated similar projects (Lunatic, wasmCloud, Actix wasm attempts) +- Analyzed all platform-specific code in core swactor (4 blockers found) +- User confirmed: Rust-only actors, performance-first, Web Workers, all features, full peer +- Key decision: SharedArrayBuffer + wasm-threads (not postMessage isolation) +- Wrote constraints.md, research_synthesis.md, 6 feature-stage docs + +## Next Action +Begin Stage 1 (Platform Abstraction Layer) — `notes/feature-stages/01-platform-abstraction.md`: +1. Add `web-time` dependency to core Cargo.toml +2. Replace `std::time::Instant` with `web_time::Instant` in runtime.rs and worker.rs +3. Implement `ParkHandle` abstraction with cfg gates +4. Gate `Runtime::run()` thread spawning for non-wasm +5. Validate crossbeam-queue compiles for wasm32 with atomics +6. Add `wasm` feature flag + +## Completed This Session +- [x] Read TASK.md, state.md, history.md (context restart checklist) +- [x] Investigated similar codebases (Lunatic, wasmCloud, Actix-wasm) +- [x] Analyzed platform dependencies in core swactor +- [x] Asked and resolved design questions with user +- [x] Wrote `notes/constraints.md` +- [x] Wrote `notes/research_synthesis.md` +- [x] Created `notes/feature-stages/` with 6 stage docs + +## Open Questions / Blockers +- Need to verify crossbeam-queue compiles for wasm32 with atomics (Stage 1 task) +- Need to find/verify correct `core::arch::wasm32` APIs for memory_atomic_wait/notify +- Nightly Rust toolchain + wasm32 target needs to be set up in CI -- 2.45.2 From 2649a54febb9003ee527b100741225ddb60063e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 08:51:32 +0000 Subject: [PATCH 2/6] feat: platform abstraction layer for wasm32 compilation (Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core swactor now compiles for wasm32-unknown-unknown: - Added `wasm` feature flag (bundles no_random + web-time) - Platform-aware Instant: web_time::Instant on wasm, std::time on native - cfg-gated Runtime::run() and RuntimeHandle (thread::spawn unavailable on wasm32 — browser crate provides Web Worker-based alternative) - Validated: crossbeam-queue, atomics, Mutex, RwLock, thread parking all work on wasm32 with atomics target feature - Updated crates/wasm/ to use new `wasm` feature Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski --- Cargo.lock | 11 +++ Cargo.toml | 2 + big-feature-phase/notes/history.md | 3 + big-feature-phase/notes/state.md | 65 +++++++-------- crates/wasm/Cargo.toml | 2 +- .../in-browser/PLATFORM_ABSTRACTION.md | 82 +++++++++++++++++++ src/lib.rs | 7 ++ src/runtime.rs | 11 ++- src/worker.rs | 4 +- 9 files changed, 150 insertions(+), 37 deletions(-) create mode 100644 docs/development_history/in-browser/PLATFORM_ABSTRACTION.md diff --git a/Cargo.lock b/Cargo.lock index 1e99605..52eef1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2723,6 +2723,7 @@ dependencies = [ "serde", "swactor-std", "tracing", + "web-time", ] [[package]] @@ -3735,6 +3736,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/big-feature-phase/notes/history.md b/big-feature-phase/notes/history.md index 67e6a3f..abc909b 100644 --- a/big-feature-phase/notes/history.md +++ b/big-feature-phase/notes/history.md @@ -2,3 +2,6 @@ ## Cycle 0 — Research (complete) Investigated Lunatic, wasmCloud, Actix-wasm attempts. Analyzed core swactor platform deps: 4 blockers (thread spawn, park/unpark, yield, Instant). User confirmed: performance-first, SharedArrayBuffer+wasm-threads, Rust-only actors, all feasible features, future STUN/TURN. Produced constraints.md, research_synthesis.md, 6 feature-stage docs. Next: Stage 1 platform abstraction. + +## Stage 1 — Platform Abstraction (complete) +Added `wasm` feature + `web-time` dep. Replaced `std::time::Instant` → `crate::Instant` (cfg-gated re-export). Gated `Runtime::run()` and `RuntimeHandle` for non-wasm. Key finding: only `thread::spawn` needed gating — park/unpark/yield/Mutex/RwLock/atomics/crossbeam all work on wasm32 with atomics. Cleaned unused Mutex import in worker.rs. Updated crates/wasm/ to use `wasm` feature. All native tests pass, wasm32 compilation succeeds. Files changed: Cargo.toml, src/lib.rs, src/runtime.rs, src/worker.rs, crates/wasm/Cargo.toml. diff --git a/big-feature-phase/notes/state.md b/big-feature-phase/notes/state.md index e0f952a..df07bba 100644 --- a/big-feature-phase/notes/state.md +++ b/big-feature-phase/notes/state.md @@ -1,46 +1,47 @@ ## Current Task -Cycle 0 — Research +Stage 1 — Platform Abstraction Layer Step: Complete Attempt: 1 of 3 ## Key Files (read these first on resume) - `big-feature-phase/TASK.md` — workflow rules -- `big-feature-phase/notes/constraints.md` — guardrails for in-browser runtime -- `big-feature-phase/notes/research_synthesis.md` — P0-P4 priority ranking -- `big-feature-phase/notes/feature-stages/01-platform-abstraction.md` — Stage 1 spec -- `src/runtime.rs` — thread spawn (line 351), park handles (line 110), Instant (line 205) -- `src/worker.rs` — backoff loop (lines 495-523), Instant timing (9 calls) -- `src/delivery.rs` — TickContext.worker_threads (line 246) -- `src/channel.rs` — crossbeam HybridChannel (validate wasm compilation) -- `crates/wasm/src/lib.rs` — existing PoC browser runtime +- `big-feature-phase/notes/constraints.md` — guardrails +- `big-feature-phase/notes/feature-stages/02-single-worker-browser.md` — Stage 2 spec +- `Cargo.toml` — `wasm` feature flag, `web-time` dep (lines 23, 30) +- `src/lib.rs` — platform-aware `Instant` re-export (lines 20-24) +- `src/runtime.rs` — cfg-gated `run()` (line 340) and `RuntimeHandle` (line 73) +- `crates/wasm/Cargo.toml` — now uses `features = ["wasm"]` +- `docs/development_history/in-browser/PLATFORM_ABSTRACTION.md` — what was done ## Last Action & Result -Completed Cycle 0 research phase: -- Investigated similar projects (Lunatic, wasmCloud, Actix wasm attempts) -- Analyzed all platform-specific code in core swactor (4 blockers found) -- User confirmed: Rust-only actors, performance-first, Web Workers, all features, full peer -- Key decision: SharedArrayBuffer + wasm-threads (not postMessage isolation) -- Wrote constraints.md, research_synthesis.md, 6 feature-stage docs +Completed Stage 1 (Platform Abstraction Layer): +- Added `web-time` dep + `wasm` feature (`no_random` + `web-time`) +- Replaced `std::time::Instant` → `crate::Instant` in runtime.rs, worker.rs +- cfg-gated `Runtime::run()` and `RuntimeHandle` for `not(target_arch = "wasm32")` +- Removed unused `Mutex` import from worker.rs +- Updated `crates/wasm/` to use `wasm` feature +- Key finding: most std::sync/thread primitives work on wasm32 with atomics; only `thread::spawn` needed gating +- All native tests pass, wasm32 compilation succeeds ## Next Action -Begin Stage 1 (Platform Abstraction Layer) — `notes/feature-stages/01-platform-abstraction.md`: -1. Add `web-time` dependency to core Cargo.toml -2. Replace `std::time::Instant` with `web_time::Instant` in runtime.rs and worker.rs -3. Implement `ParkHandle` abstraction with cfg gates -4. Gate `Runtime::run()` thread spawning for non-wasm -5. Validate crossbeam-queue compiles for wasm32 with atomics -6. Add `wasm` feature flag +Begin Stage 2 (Single-Worker Browser Runtime) — `notes/feature-stages/02-single-worker-browser.md`: +1. Create `crates/wasm-browser/` crate structure +2. Implement `BrowserRuntime` wasm-bindgen API (spawn, send, tick, try_recv, stats) +3. Actor registration macro/pattern for JS-accessible spawning +4. Auto-scheduling via setTimeout(0) loop +5. Message serialization across JS↔Wasm boundary +6. Tests (wasm-pack test or Node.js) ## Completed This Session -- [x] Read TASK.md, state.md, history.md (context restart checklist) -- [x] Investigated similar codebases (Lunatic, wasmCloud, Actix-wasm) -- [x] Analyzed platform dependencies in core swactor -- [x] Asked and resolved design questions with user -- [x] Wrote `notes/constraints.md` -- [x] Wrote `notes/research_synthesis.md` -- [x] Created `notes/feature-stages/` with 6 stage docs +- [x] Cycle 0 research artifacts (constraints.md, research_synthesis.md, 6 stage docs) +- [x] Stage 1: `web-time` dep + `wasm` feature flag in Cargo.toml +- [x] Stage 1: Platform-aware `Instant` re-export in src/lib.rs +- [x] Stage 1: cfg-gated `Runtime::run()` and `RuntimeHandle` in src/runtime.rs +- [x] Stage 1: Updated crates/wasm/ to use `wasm` feature +- [x] Stage 1: Validated wasm32 compilation and native tests +- [x] Stage 1: Development history doc ## Open Questions / Blockers -- Need to verify crossbeam-queue compiles for wasm32 with atomics (Stage 1 task) -- Need to find/verify correct `core::arch::wasm32` APIs for memory_atomic_wait/notify -- Nightly Rust toolchain + wasm32 target needs to be set up in CI +- Stage 2: Need to decide on message serialization (serde-wasm-bindgen vs raw bytes) +- Stage 2: Actor registration pattern — macro vs manual factory map +- Stage 3: Web Worker thread state initialization needs investigation (does std::thread::current() work in a Web Worker context?) diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index f4fda2e..445b85c 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -7,5 +7,5 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -swactor = { path = "../..", default-features = false, features = ["no_random"] } +swactor = { path = "../..", default-features = false, features = ["wasm"] } wasm-bindgen = "0.2" 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..446dc66 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; -- 2.45.2 From 82e72ff235a8aabd571d845e1401b3dfe43ee7c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 09:05:41 +0000 Subject: [PATCH 3/6] 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 -- 2.45.2 From 3e44579e5c5c5fcb26b3ec585a4739345b1796c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 09:28:56 +0000 Subject: [PATCH 4/6] feat: swactor-std feature parity in wasm (Stage 4) Enable naming, groups, watching, and stats in the browser runtime. Fix core watching gap: StopSignal deaths now trigger WatchRegistry notifications. Add register_name to RuntimeNaming trait. Cfg-gate getrandom in router.rs for wasm builds. 30 Node.js tests pass. Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski --- Cargo.lock | 1 + crates/std/Cargo.toml | 5 +- crates/std/src/router.rs | 18 +- crates/std/src/runtime_ext.rs | 7 + crates/wasm/Cargo.toml | 1 + crates/wasm/src/lib.rs | 174 +++++++++++++++- crates/wasm/test.mjs | 188 ++++++++++++++++++ .../in-browser/FEATURE_PARITY.md | 75 +++++++ src/worker.rs | 1 + 9 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 docs/development_history/in-browser/FEATURE_PARITY.md diff --git a/Cargo.lock b/Cargo.lock index 52eef1f..945a03c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3300,6 +3300,7 @@ name = "wasm" version = "0.1.0" dependencies = [ "swactor", + "swactor-std", "wasm-bindgen", ] 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 445b85c..20787f6 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -8,4 +8,5 @@ crate-type = ["cdylib"] [dependencies] 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/src/lib.rs b/crates/wasm/src/lib.rs index 7bf53cb..36fcf4f 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -1,7 +1,10 @@ +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}; // ─── Core JS-facing types ─────────────────────────────────────────────────── @@ -66,13 +69,31 @@ impl WasmInboxBytes { } } +/// 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. 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. +/// 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, @@ -85,7 +106,8 @@ impl WasmRuntime { let rt = Runtime::new(RuntimeConfig { num_threads: 1, ..RuntimeConfig::default() - }); + }) + .with_extension(Arc::new(StdExtension::new())); Self { rt } } @@ -113,6 +135,13 @@ impl WasmRuntime { } } + /// 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() @@ -132,6 +161,67 @@ impl WasmRuntime { 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 { @@ -174,6 +264,52 @@ impl ActorInterface for Relay { } } +/// 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, +} + +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); + } + + 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] @@ -197,3 +333,31 @@ pub fn spawn_relay(rt: &WasmRuntime, target: &WasmAddr) -> WasmAddr { .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 7948b6b..7c8c287 100644 --- a/crates/wasm/test.mjs +++ b/crates/wasm/test.mjs @@ -3,6 +3,8 @@ import { WasmAddr, spawn_counter, spawn_relay, + spawn_sentinel, + spawn_group_member, } from "./pkg/wasm.js"; let passed = 0; @@ -153,6 +155,192 @@ function drainInbox(inbox) { 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(); +} + // ---- results -------------------------------------------------------------- console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); 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/src/worker.rs b/src/worker.rs index 446dc66..884d247 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -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; -- 2.45.2 From 60a9269f98721d1543ed3752c692b656ffba404e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 20:19:53 +0700 Subject: [PATCH 5/6] feat: in-browser engine demo --- crates/wasm/demo.html | 570 ++++++++++++++++++++ docs/development_history/in-browser/DEMO.md | 60 +++ 2 files changed, 630 insertions(+) create mode 100644 crates/wasm/demo.html create mode 100644 docs/development_history/in-browser/DEMO.md 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/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. -- 2.45.2 From 6bbf87f7ea5c1fed1ec05e8773a48c7e24cd9c07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Feb 2026 20:26:34 +0700 Subject: [PATCH 6/6] keep agent notes out of repo --- big-feature-phase/notes/constraints.md | 58 -------- .../feature-stages/01-platform-abstraction.md | 110 --------------- .../02-single-worker-browser.md | 129 ------------------ .../03-multi-worker-parallelism.md | 118 ---------------- .../notes/feature-stages/04-feature-parity.md | 109 --------------- .../feature-stages/05-transport-foundation.md | 107 --------------- .../feature-stages/06-developer-experience.md | 102 -------------- big-feature-phase/notes/history.md | 7 - big-feature-phase/notes/research_synthesis.md | 87 ------------ big-feature-phase/notes/state.md | 47 ------- 10 files changed, 874 deletions(-) delete mode 100644 big-feature-phase/notes/constraints.md delete mode 100644 big-feature-phase/notes/feature-stages/01-platform-abstraction.md delete mode 100644 big-feature-phase/notes/feature-stages/02-single-worker-browser.md delete mode 100644 big-feature-phase/notes/feature-stages/03-multi-worker-parallelism.md delete mode 100644 big-feature-phase/notes/feature-stages/04-feature-parity.md delete mode 100644 big-feature-phase/notes/feature-stages/05-transport-foundation.md delete mode 100644 big-feature-phase/notes/feature-stages/06-developer-experience.md delete mode 100644 big-feature-phase/notes/history.md delete mode 100644 big-feature-phase/notes/research_synthesis.md delete mode 100644 big-feature-phase/notes/state.md diff --git a/big-feature-phase/notes/constraints.md b/big-feature-phase/notes/constraints.md deleted file mode 100644 index fefcef6..0000000 --- a/big-feature-phase/notes/constraints.md +++ /dev/null @@ -1,58 +0,0 @@ -# Constraints — In-Browser Swactor Runtime - -## Threading Model - -- **wasm-threads is mandatory** — the runtime uses SharedArrayBuffer + WebAssembly atomics for multi-worker parallelism. There is no single-threaded degraded mode for MVP. -- Browsers must serve pages with COOP/COEP headers: - - `Cross-Origin-Opener-Policy: same-origin` - - `Cross-Origin-Embedder-Policy: require-corp` -- Build requires nightly Rust + `-Z build-std=std,panic_abort` + target features `+atomics,+bulk-memory,+mutable-globals`. - -## Architecture Rules - -- **Platform abstractions live in core swactor** (`src/`), gated by `#[cfg(target_arch = "wasm32")]`. They do not belong in the wasm crate. -- **Do not add new modules** to `src/` — modify existing files only (TASK.md style rule). -- **Do not restructure** existing module boundaries. The abstraction is a thin layer (type aliases, cfg-gated imports), not a trait-based HAL. -- The browser crate (`crates/wasm-browser/` or evolved `crates/wasm/`) is a **thin wasm-bindgen shell**. All scheduling, routing, and actor logic stays in core Rust. - -## Actor Model - -- **Rust-only actors** — actors are written in Rust and compiled to wasm. JavaScript does not define actor behavior. -- JS interacts through the wasm-bindgen API: create runtime, spawn actors (by registered type), send messages, receive results. -- Actor types are registered at compile time via Rust generics, not dynamically from JS. - -## Performance Priorities - -- Maximize throughput: auto-scheduling via `setTimeout(0)` tight loop, not `requestAnimationFrame` (which caps at display refresh rate). -- Web Worker count defaults to `navigator.hardwareConcurrency` for full core utilization. -- Zero-copy where possible: SharedArrayBuffer eliminates serialization between workers. -- Minimize JS↔Wasm boundary crossings — batch operations where feasible. - -## Feature Scope - -- All core features that compile for wasm32: spawn, send, receive, tick, actor lifecycle, watching, extensions. -- swactor-std features (naming, groups, monitoring) should work if they compile. -- Transport: WebSocket adapter for distributed clusters. STUN/TURN (WebRTC) deferred to later. -- Features that require OS primitives not available in wasm (filesystem, raw TCP) are excluded. - -## Testing - -- Tests must pass on both native (`cargo test`) and wasm targets. -- Wasm tests use `wasm-pack test --headless --chrome` or Node.js with `--experimental-wasm-threads`. -- No test-only code paths that diverge native vs wasm behavior — if it works differently, it's a bug. -- Prefer scenario tests over structural tests (per project testing rules). - -## Dependencies - -- `web-time` — drop-in replacement for `std::time::Instant` on wasm32 -- `wasm-bindgen` + `js-sys` + `web-sys` — browser API bindings (in the wasm crate only, not core) -- `gloo-timers` — optional, for ergonomic setTimeout/setInterval -- No new dependencies in core swactor beyond `web-time` (which is no-op on native) - -## What We Don't Do - -- No async/await runtime (tokio, async-std) — swactor is synchronous tick-based -- No Emscripten — target is `wasm32-unknown-unknown` only -- No WASI — browser environment, not server-side wasm -- No JS actor definitions — Rust only -- No polyfills for missing atomics — if SharedArrayBuffer isn't available, the runtime doesn't start diff --git a/big-feature-phase/notes/feature-stages/01-platform-abstraction.md b/big-feature-phase/notes/feature-stages/01-platform-abstraction.md deleted file mode 100644 index 9fa5954..0000000 --- a/big-feature-phase/notes/feature-stages/01-platform-abstraction.md +++ /dev/null @@ -1,110 +0,0 @@ -# Stage 1 — Platform Abstraction Layer - -**Priority**: P0 -**Depends on**: Nothing -**Enables**: All subsequent stages - -## Goal - -Make core swactor compile for `wasm32-unknown-unknown` with `+atomics,+bulk-memory,+mutable-globals` target features. No behavioral changes on native targets. No new modules — only modify existing files with `cfg` gates. - -## What Changes - -### 1. Instant → web_time::Instant - -**Files**: `src/runtime.rs`, `src/worker.rs` - -Add `web-time` to `[dependencies]` (it's a no-op on non-wasm targets). Replace: -```rust -use std::time::Instant; -``` -with: -```rust -use web_time::Instant; -``` - -`web-time` is a drop-in replacement. The `Instant` type has identical API on native (delegates to `std::time::Instant`) and on wasm32 (uses `performance.now()`). - -**Scope**: 2 `use` statements, 0 logic changes. - -### 2. Thread Parking → ParkHandle - -**Files**: `src/runtime.rs`, `src/delivery.rs`, `src/worker.rs` - -Currently uses `OnceLock` + `thread::park_timeout` + `Thread::unpark`. On wasm32, there's no `Thread` type accessible from Rust (workers are JS objects). But wasm-threads supports `Atomics.wait`/`Atomics.notify` through Rust's `std::sync::atomic` and futex primitives. - -Approach: Define a `ParkHandle` abstraction in `src/runtime.rs`: - -**Native**: -```rust -#[cfg(not(target_arch = "wasm32"))] -mod parking { - pub type ParkHandle = OnceLock; - pub fn register(handle: &ParkHandle) { handle.set(thread::current()).ok(); } - pub fn unpark(handle: &ParkHandle) { if let Some(t) = handle.get() { t.unpark(); } } - pub fn park_timeout_us(micros: u64) { thread::park_timeout(Duration::from_micros(micros)); } - pub fn yield_now() { thread::yield_now(); } -} -``` - -**Wasm32**: -```rust -#[cfg(target_arch = "wasm32")] -mod parking { - // Use an AtomicI32 as a futex-like signal. Atomics.wait blocks the - // wasm thread, Atomics.notify wakes it — same semantics as park/unpark. - pub struct ParkHandle(AtomicI32); - pub fn register(_: &ParkHandle) {} // no-op, handle is pre-initialized - pub fn unpark(handle: &ParkHandle) { - handle.0.store(1, Ordering::Release); - std::sync::atomic::fence(Ordering::SeqCst); - // Atomics.notify via core::arch::wasm32::memory_atomic_notify - core::arch::wasm32::memory_atomic_notify(&handle.0 as *const _ as *mut i32, 1); - } - pub fn park_timeout_us(micros: u64) { - // Atomics.wait via core::arch::wasm32::memory_atomic_wait32 - core::arch::wasm32::memory_atomic_wait32(ptr, 0, timeout_ns as i64); - } - pub fn yield_now() {} // no-op on wasm -} -``` - -**Scope**: New `parking` sub-module in `runtime.rs` (~30 lines), update `TickContext` to use `ParkHandle` instead of `OnceLock`, update `worker.rs` backoff loop. - -### 3. Thread Spawning — No Change in Core - -Thread spawning (`std::thread::Builder::new().spawn()`) only happens in `Runtime::run()` (line 351). This method will be overridden/wrapped by the browser crate — it won't be called on wasm32. We can gate it: - -```rust -#[cfg(not(target_arch = "wasm32"))] -pub fn run(self) -> Result { ... } -``` - -The wasm browser crate will provide its own `run()` that spawns Web Workers instead. - -### 4. Validate crossbeam Compilation - -Test that `crossbeam-queue` compiles for wasm32 with atomics. If it doesn't, provide a cfg-gated fallback in `src/channel.rs` using `VecDeque` wrapped in `Mutex`. (Likely not needed — crossbeam uses `core::sync::atomic` which works with wasm atomics.) - -### 5. Feature Flag - -Add a `wasm` feature to `Cargo.toml`: -```toml -[features] -wasm = ["web-time", "no_random"] - -[dependencies] -web-time = { version = "0.2", optional = true } -``` - -On wasm32, this feature enables `web-time` and `no_random` together. - -## Verification - -1. `cargo test` passes unchanged on native -2. `cargo build --target wasm32-unknown-unknown --features wasm -Z build-std=std,panic_abort` compiles (may need `+atomics` RUSTFLAGS) -3. No runtime behavior changes on native (confirm with existing test suite) - -## Estimated Scope - -~50-80 lines of new/changed code across 4 files. No new modules. diff --git a/big-feature-phase/notes/feature-stages/02-single-worker-browser.md b/big-feature-phase/notes/feature-stages/02-single-worker-browser.md deleted file mode 100644 index f48e655..0000000 --- a/big-feature-phase/notes/feature-stages/02-single-worker-browser.md +++ /dev/null @@ -1,129 +0,0 @@ -# Stage 2 — Single-Worker Browser Runtime - -**Priority**: P0 -**Depends on**: Stage 1 (platform abstraction) -**Enables**: Stage 3 (multi-worker), Stage 4 (feature parity) - -## Goal - -A working browser runtime on a single dedicated Web Worker with a JS API that supports spawning arbitrary (pre-registered) actor types, sending messages, receiving results, and auto-scheduled ticking. - -## What's Built - -### 1. New Crate: `crates/wasm-browser/` - -Replaces the PoC `crates/wasm/`. Structure: - -``` -crates/wasm-browser/ -├── Cargo.toml -├── src/ -│ ├── lib.rs # wasm-bindgen entry point -│ ├── runtime.rs # BrowserRuntime wrapping swactor::Runtime -│ ├── worker_glue.rs # Web Worker spawn/communication glue -│ └── scheduling.rs # Auto-tick scheduling (setTimeout loop) -├── js/ -│ ├── worker.js # Web Worker bootstrap script -│ └── index.js # Main thread API wrapper (optional) -└── tests/ - └── browser.rs # wasm-pack test suite -``` - -### 2. BrowserRuntime (wasm-bindgen API) - -```rust -#[wasm_bindgen] -pub struct BrowserRuntime { ... } - -#[wasm_bindgen] -impl BrowserRuntime { - #[wasm_bindgen(constructor)] - pub fn new(config: JsValue) -> Self; - - /// Spawn an actor by type name. Returns an opaque handle. - pub fn spawn(&mut self, type_name: &str, init: JsValue) -> JsValue; - - /// Send a message to an actor. - pub fn send(&self, addr: JsValue, msg: JsValue) -> bool; - - /// Drive one tick manually. - pub fn tick(&self); - - /// Start auto-scheduling. Calls tick() in a tight setTimeout(0) loop. - pub fn start(&self); - - /// Stop auto-scheduling. - pub fn stop(&self); - - /// Poll for results from a JS-visible inbox. - pub fn try_recv(&self) -> JsValue; - - /// Runtime stats snapshot. - pub fn stats(&self) -> JsValue; -} -``` - -### 3. Actor Registration - -Since Rust generics can't be dynamically dispatched from JS, actor types are registered at compile time: - -```rust -// In the user's wasm crate that depends on wasm-browser: -register_actors! { - "counter" => Counter, - "relay" => Relay, -} -``` - -This macro generates a factory map that `BrowserRuntime::spawn` indexes by string name. Each entry knows how to deserialize `JsValue` init args into the actor's constructor. - -### 4. Auto-Scheduling - -The runtime self-drives via a `setTimeout(0)` loop: - -```javascript -function tickLoop() { - runtime.tick(); - if (runtime.is_running()) { - setTimeout(tickLoop, 0); - } -} -``` - -This runs as fast as the browser allows (~4ms between ticks in most browsers, faster in Web Workers). The Rust side just calls `tick()` — no async runtime needed. - -### 5. Message Serialization - -JS ↔ Wasm boundary requires serialization. Options: -- **serde-wasm-bindgen**: Serialize Rust types to/from JsValue via serde. Zero-copy for simple types. -- **Manual**: Convert JsValue to bytes, route as `ByteMessage`. - -For Stage 2, use `serde-wasm-bindgen` for typed messages. Actor `Incoming` types must implement `serde::Deserialize`. - -### 6. Single Worker Architecture - -``` -┌─────────────────────┐ postMessage ┌──────────────────────┐ -│ Main Thread │ ◄──────────────────────► │ Web Worker │ -│ │ │ │ -│ JS application │ "spawn", "send", │ BrowserRuntime │ -│ calls API methods │ "tick", "recv" │ swactor::Runtime │ -│ │ │ (1 worker, tick()) │ -└─────────────────────┘ └──────────────────────┘ -``` - -The Web Worker runs the swactor runtime. The main thread sends commands via `postMessage`. This keeps the UI thread free. - -Alternative: run everything on the main thread (simpler, but blocks UI during tick). Support both modes — the user picks. - -## Verification - -1. `wasm-pack build --target web` succeeds -2. `wasm-pack test --headless --chrome` passes -3. Manual test: HTML page spawns actors, sends messages, receives results -4. Auto-scheduling: actors process messages continuously without manual tick calls -5. Performance: measure ticks/sec, compare to native single-threaded - -## Estimated Scope - -~300-500 lines of Rust + ~50 lines of JS glue. diff --git a/big-feature-phase/notes/feature-stages/03-multi-worker-parallelism.md b/big-feature-phase/notes/feature-stages/03-multi-worker-parallelism.md deleted file mode 100644 index 2e1a2a1..0000000 --- a/big-feature-phase/notes/feature-stages/03-multi-worker-parallelism.md +++ /dev/null @@ -1,118 +0,0 @@ -# Stage 3 — Multi-Worker Parallelism - -**Priority**: P1 -**Depends on**: Stage 1 (platform abstraction), Stage 2 (single-worker browser) -**Enables**: Stage 5 (transport) - -## Goal - -Spawn N Web Workers sharing the same swactor runtime via SharedArrayBuffer. Actors distributed across workers for true multi-core parallelism. Worker count configurable, defaults to `navigator.hardwareConcurrency`. - -## Architecture - -``` -┌─────────────┐ -│ Main Thread │ postMessage API -│ (JS app) │◄─────────────────────┐ -└──────┬───────┘ │ - │ spawn workers │ - ▼ │ -┌──────────────┐ SharedArrayBuffer ┌──────────────┐ -│ Web Worker 0 │◄───────────────────►│ Web Worker 1 │ -│ swactor │ (InboxRegistry, │ swactor │ -│ Worker #0 │ AddressMap, │ Worker #1 │ -│ tick loop │ transfer queues, │ tick loop │ -└──────────────┘ atomics) └──────────────┘ - ... - ┌──────────────┐ - │ Web Worker N │ - │ swactor │ - │ Worker #N │ - └──────────────┘ -``` - -## Key Challenge: Web Worker ↔ SharedArrayBuffer - -Web Workers can share `SharedArrayBuffer` instances. The swactor `Runtime` struct contains `Arc`-wrapped shared state (InboxRegistry, AddressMap, etc.). On native, this memory is shared via the process address space. On wasm with SharedArrayBuffer, it's shared via the underlying wasm linear memory. - -### How It Works - -1. **Main thread** creates the `Runtime` (allocates shared structures in wasm linear memory) -2. **Main thread** spawns N Web Workers, each loading the same `.wasm` module with `shared: true` memory -3. Each Web Worker receives a pointer (offset) to the `Runtime` shared state -4. Each Worker runs `worker.run(&tc, &is_running)` — the same tick loop as native -5. Crossbeam queues, atomics, Mutex/RwLock all work because the underlying memory is shared - -### wasm-bindgen + Web Workers - -The `web-sys::Worker` API creates workers. Each worker loads the same wasm module: - -```javascript -// worker.js (loaded by each Web Worker) -import init, { worker_entry } from './pkg/wasm_browser.js'; - -self.onmessage = async (e) => { - const { module, memory, worker_id, runtime_ptr } = e.data; - await init({ module, memory }); // shared memory! - worker_entry(worker_id, runtime_ptr); -}; -``` - -The Rust side: -```rust -#[wasm_bindgen] -pub fn worker_entry(worker_id: usize, runtime_ptr: u32) { - // Reconstruct the shared Runtime reference from the raw pointer - // Run the tick loop for this worker -} -``` - -### Memory Sharing - -With `wasm-threads`, the wasm linear memory is backed by a `SharedArrayBuffer`. All workers see the same memory. `Arc` increments are atomic operations on shared memory. Crossbeam queues use atomic compare-and-swap on shared memory. This is identical to how it works with OS threads. - -**Critical**: The wasm module must be compiled with `--shared-memory` and the `atomics` feature. The `Memory` import must use `shared: true`. - -## Park / Unpark - -The `ParkHandle` from Stage 1 uses `memory_atomic_wait32` / `memory_atomic_notify` — the wasm equivalents of futex. These work across Web Workers sharing the same memory. - -- `park_timeout_us(micros)` → `Atomics.wait(ptr, expected, timeout)` — blocks the Worker thread -- `unpark(handle)` → `Atomics.notify(ptr, 1)` — wakes one waiting Worker - -This gives the same backoff behavior as native: hot spin → yield → sleep with exponential backoff. - -## Worker Spawning - -Replace `std::thread::Builder::new().spawn()` in `Runtime::run()`: - -```rust -#[cfg(target_arch = "wasm32")] -pub fn run(self) -> Result { - let num_workers = self.config.num_threads.max(1); - let rt = Arc::new(self); - - for i in 0..num_workers { - let worker = web_sys::Worker::new("./worker.js")?; - worker.post_message(&JsValue::from(/* module, memory, worker_id, ptr */)); - } - // ... -} -``` - -## Placement - -The existing `Placement` strategy (load-aware round-robin) works unchanged — it reads `WorkerStats` atomics to pick the least-loaded worker. On wasm, these atomics are in SharedArrayBuffer, readable from any worker. - -## Verification - -1. Spawn runtime with `num_threads: 4`, verify 4 Web Workers created -2. Spawn actors, verify they're distributed across workers (check stats per-worker actor count) -3. Cross-worker message delivery works (actor on Worker 0 sends to actor on Worker 1) -4. Backoff/parking works (idle workers sleep, wake on new messages) -5. Throughput scales with worker count (benchmark: N workers vs 1 worker) -6. Shutdown: all workers terminate cleanly when `is_running` set to false - -## Estimated Scope - -~200-300 lines Rust + ~30 lines JS worker bootstrap. Most complexity is in the Web Worker ↔ shared memory plumbing, not the Rust logic (which is the same as native). diff --git a/big-feature-phase/notes/feature-stages/04-feature-parity.md b/big-feature-phase/notes/feature-stages/04-feature-parity.md deleted file mode 100644 index 2e49512..0000000 --- a/big-feature-phase/notes/feature-stages/04-feature-parity.md +++ /dev/null @@ -1,109 +0,0 @@ -# Stage 4 — Feature Parity - -**Priority**: P1 -**Depends on**: Stage 2 (single-worker) or Stage 3 (multi-worker) -**Enables**: Stage 5 (transport), Stage 6 (DX) - -## Goal - -All swactor features that are feasible in a browser environment work and are tested: actor watching, swactor-std extensions (naming, groups, monitoring), stats introspection. - -## Features to Enable - -### 1. Actor Watching / Death Notifications - -**Status**: Already in core (`src/runtime.rs`, `src/worker.rs`) - -Components: -- `ExitReason` enum — normal, panic, stopped -- `ActorExited` message — delivered to watchers -- `on_actor_exit()` default method on `ActorInterface` -- `WatchRegistry` — tracks who watches whom -- Phase 5b in `tick_once` — delivers death notifications - -**Wasm concern**: `WatchRegistry` is behind `Arc>`. With wasm-threads, `Mutex` works. The catch_unwind panic-safety model works identically in wasm. - -**Work needed**: Compile and test. Write wasm-specific tests for: -- Actor dies → watchers notified -- Watcher on different Web Worker receives notification (cross-worker) -- Panic in wasm actor → poisoned, watchers notified - -### 2. swactor-std Extension - -**Status**: Complete in `crates/std/` - -Components: -- `StdExtension` — wraps NameRegistry + MonitorRegistry + GroupRegistry -- `CtxMonitoring` — watch/unwatch actors -- `CtxNaming` — register/resolve actor names -- `CtxGroups` — join/leave groups, broadcast -- `RuntimeNaming` — resolve names from runtime handle -- `RuntimeGroups` — list groups, broadcast from outside -- Supervisor — restart policies - -**Wasm concern**: All use `Arc`, `Mutex`, `HashMap` — standard types that work with wasm-threads. No OS-specific dependencies. - -**Work needed**: -- Add `crates/std/` to wasm build verification -- Test naming: register name → resolve from another actor on different worker -- Test groups: broadcast reaches all group members across workers -- Test supervisor: child dies → supervisor restarts (factory-based) - -### 3. Stats and Introspection - -**Status**: In core (`src/stats.rs`) - -Components: -- `WorkerStats` — per-worker atomic counters (actors, depth, ticks, messages) -- `RuntimeStats` — aggregated snapshot -- `StatsHook` trait — called each tick with stats - -**Wasm concern**: Atomic counters work with wasm-threads. `StatsHook` is called in the tick loop — works. - -**Work needed**: -- Expose `RuntimeStats` to JS via `serde-wasm-bindgen` (JSON-serializable snapshot) -- Optional: periodic stats push to main thread via `postMessage` -- Test: spawn actors across workers, verify stats reflect correct counts - -### 4. Runtime Extensions - -**Status**: In core (`src/extension.rs`) - -The `RuntimeExtension` trait (`on_actor_death`, `cleanup_dead`, `as_any`) is called during phase 7 of tick_once. It uses `Arc` — works with wasm-threads. - -**Work needed**: Verify `StdExtension` as a `RuntimeExtension` compiles and works in wasm. Test the full lifecycle: actor death → extension notified → cleanup runs. - -## JS API Additions - -Extend the `BrowserRuntime` wasm-bindgen API: - -```rust -impl BrowserRuntime { - // Actor watching - pub fn watch(&self, watcher: JsValue, target: JsValue) -> bool; - - // Naming (if StdExtension enabled) - pub fn register_name(&self, name: &str, addr: JsValue) -> bool; - pub fn resolve_name(&self, name: &str) -> JsValue; - - // Groups - pub fn join_group(&self, group: &str, addr: JsValue) -> bool; - pub fn broadcast_group(&self, group: &str, msg: JsValue) -> bool; - - // Stats - pub fn stats(&self) -> JsValue; // JSON snapshot of RuntimeStats -} -``` - -## Verification - -1. All existing native tests for watching/std pass on wasm target -2. Cross-worker watching: actor on Worker 0 watches actor on Worker 1, Worker 1 actor dies → notification arrives -3. Naming works across workers: register on Worker 0, resolve on Worker 1 -4. Group broadcast reaches actors on all workers -5. Stats counters are accurate across workers (compare sum to expected) -6. `cargo test` still passes on native (no regressions) - -## Estimated Scope - -~100-200 lines of new wasm-bindgen API surface + ~200 lines of wasm tests. Core logic should work as-is once it compiles. diff --git a/big-feature-phase/notes/feature-stages/05-transport-foundation.md b/big-feature-phase/notes/feature-stages/05-transport-foundation.md deleted file mode 100644 index 3c3b96e..0000000 --- a/big-feature-phase/notes/feature-stages/05-transport-foundation.md +++ /dev/null @@ -1,107 +0,0 @@ -# Stage 5 — Transport Foundation - -**Priority**: P2 -**Depends on**: Stage 3 (multi-worker), Stage 4 (feature parity) -**Enables**: Browser nodes joining distributed swactor clusters - -## Goal - -Browser nodes connect to native swactor clusters via WebSocket. A browser can spawn actors that communicate with actors on server nodes. Foundation for future STUN/TURN (WebRTC DataChannel) for browser-to-browser direct connections. - -## Architecture - -``` -┌──────────────────┐ WebSocket ┌──────────────────┐ -│ Browser Node │ ◄────────────────────────► │ Server Node │ -│ (wasm runtime) │ │ (native runtime)│ -│ │ swactor wire protocol │ │ -│ Actor A ──────►─┤───── msg for Actor B ─────►├──► Actor B │ -│ │ │ │ -│ Actor C ◄───────┤◄──── msg for Actor C ─────┤───── Actor D │ -└──────────────────┘ └──────────────────┘ -``` - -## Existing Transport Infrastructure - -swactor already has a transport layer (feature-gated under `transport`): - -- `src/transport.rs` — `TransportRouter`, `CodecRegistry`, remote message routing -- `crates/distribution/` — SWIM protocol, gossip, cluster membership -- `crates/distribution/src/driver.rs` — `NodeDriver` bridges `DistributedNode` ↔ TCP -- Wire protocol: Ping/Ack/PingReq with piggyback bytes - -The browser transport needs to implement the same wire protocol over WebSocket instead of raw TCP. - -## What's Built - -### 1. WebSocket Transport Adapter - -A new module in `crates/wasm-browser/` (not in core): - -```rust -pub struct WebSocketTransport { - ws: web_sys::WebSocket, - // ... -} - -impl TransportAdapter for WebSocketTransport { - fn send(&self, dest: SocketAddr, data: &[u8]) -> Result<(), Error>; - fn recv(&self) -> Option<(SocketAddr, Vec)>; -} -``` - -Uses `web-sys::WebSocket` for the browser side. The server side uses a WebSocket server (e.g., `tokio-tungstenite`) that bridges to the existing TCP transport. - -### 2. WebSocket ↔ TCP Bridge (Server Side) - -A thin relay server that accepts WebSocket connections from browsers and translates to/from the TCP wire protocol: - -``` -Browser ──WebSocket──► Bridge Server ──TCP──► swactor-node -``` - -This bridge is a separate binary/service, not part of the runtime. It's a protocol translator. - -### 3. Browser Node Identity - -Browser nodes need: -- A unique node ID (derived from random or assigned by the cluster) -- An address for the cluster to route messages to (the WebSocket endpoint) -- Membership in the SWIM protocol (lightweight — browsers are "client" members that don't participate in failure detection) - -### 4. Cluster Registry Integration - -The existing ClusterRegistry (LWW-Register CRDT in `crates/distribution/src/registry.rs`) should work from browsers: -- `register_name` / `resolve_name` / `registry_events` — all work over the wire -- Piggyback payloads carry registry updates through the WebSocket connection - -## Key Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Transport protocol | WebSocket (binary frames) | Universal browser support, bidirectional, binary-capable | -| Membership role | Client member (no failure detection) | Browsers are ephemeral; full SWIM overhead not justified | -| Bridge architecture | Separate relay server | Keeps swactor-node unchanged; bridge handles WebSocket↔TCP | -| Wire format | Same as TCP transport | No translation needed beyond framing (WebSocket frames ↔ TCP stream) | - -## STUN/TURN Foundation (Future) - -This stage establishes the transport abstraction. Stage 5 itself is WebSocket only. Future work: - -- **WebRTC DataChannel** — direct browser-to-browser, requires STUN/TURN for NAT traversal -- The `TransportAdapter` trait from this stage will have a WebRTC implementation -- STUN/TURN server infrastructure is out of scope for this feature phase - -## Verification - -1. Browser node connects to server cluster via WebSocket -2. Actor on browser sends message to actor on server → received -3. Actor on server sends message to actor on browser → received -4. Browser appears in cluster membership (visible in dashboard) -5. ClusterRegistry: name registered on server → resolvable from browser -6. Browser disconnects → cluster detects and removes membership -7. Reconnection: browser reconnects → re-joins cluster, actor addresses still valid - -## Estimated Scope - -~500-800 lines for WebSocket transport adapter + bridge server. Builds heavily on existing distribution infrastructure. diff --git a/big-feature-phase/notes/feature-stages/06-developer-experience.md b/big-feature-phase/notes/feature-stages/06-developer-experience.md deleted file mode 100644 index dab3283..0000000 --- a/big-feature-phase/notes/feature-stages/06-developer-experience.md +++ /dev/null @@ -1,102 +0,0 @@ -# Stage 6 — Developer Experience - -**Priority**: P3 -**Depends on**: Stage 2 (single-worker browser), Stage 4 (feature parity) -**Enables**: Adoption, ecosystem growth - -## Goal - -Make it easy for Rust developers to build browser applications with swactor. TypeScript type safety, build tooling, and debugging support. - -## Features - -### 1. TypeScript Type Generation - -Derive TypeScript interfaces from Rust actor message types. When an actor defines: - -```rust -#[derive(Serialize, Deserialize)] -pub struct ChatMessage { - pub from: String, - pub text: String, -} -``` - -Generate: -```typescript -export interface ChatMessage { - from: string; - text: string; -} -``` - -**Approach**: Use `ts-rs` crate or a custom proc macro that emits `.d.ts` files during `wasm-pack build`. This gives TypeScript consumers compile-time type checking for messages. - -### 2. Build Tooling - -A `swactor-build` CLI or build script that wraps: -```bash -RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals' \ - cargo +nightly build --target wasm32-unknown-unknown \ - -Z build-std=std,panic_abort \ - --release -wasm-bindgen --target web --out-dir pkg/ ... -``` - -Into: -```bash -swactor-build --target browser -``` - -Features: -- Detects nightly toolchain, installs if missing -- Sets correct RUSTFLAGS for wasm-threads -- Runs wasm-bindgen with correct target -- Copies worker.js bootstrap into output -- Generates example HTML with correct COOP/COEP headers - -### 3. Example Project Template - -A `cargo generate` template or example project: - -``` -my-swactor-app/ -├── Cargo.toml -├── src/ -│ └── lib.rs # Define actors, register them -├── web/ -│ ├── index.html # With COOP/COEP headers -│ ├── main.js # Import wasm, create runtime, interact -│ └── worker.js # Web Worker bootstrap (auto-generated) -└── tests/ - └── browser.rs # wasm-pack tests -``` - -### 4. Browser Dev Tools Integration - -Expose runtime internals for debugging: - -- **Actor Inspector**: List all actors, their types, mailbox depths, message counts -- **Message Tracer**: Log messages between actors (opt-in, performance impact) -- **Performance Monitor**: Ticks/sec, messages/sec, worker utilization - -Implementation: A `console`-based logger that uses `web-sys::console` to output structured data. Optionally integrates with browser DevTools via custom formatters or a small React/Preact inspector panel. - -### 5. Documentation - -- Getting started guide -- Architecture overview for browser runtime -- Migration guide from native → browser (what works, what doesn't) -- API reference (generated from Rust doc comments) -- Example: Chat application with multiple browser tabs - -## Verification - -1. TypeScript types match Rust types (compile TS project against generated `.d.ts`) -2. Build tool produces working wasm output from example project -3. Template project builds and runs out of the box -4. Dev tools show actor state in browser console - -## Estimated Scope - -Variable — this stage is a collection of independent DX improvements. Each can be implemented and shipped independently. Total: ~500-1000 lines across Rust, JS, and documentation. diff --git a/big-feature-phase/notes/history.md b/big-feature-phase/notes/history.md deleted file mode 100644 index abc909b..0000000 --- a/big-feature-phase/notes/history.md +++ /dev/null @@ -1,7 +0,0 @@ -# Cycle History (append-only) - -## Cycle 0 — Research (complete) -Investigated Lunatic, wasmCloud, Actix-wasm attempts. Analyzed core swactor platform deps: 4 blockers (thread spawn, park/unpark, yield, Instant). User confirmed: performance-first, SharedArrayBuffer+wasm-threads, Rust-only actors, all feasible features, future STUN/TURN. Produced constraints.md, research_synthesis.md, 6 feature-stage docs. Next: Stage 1 platform abstraction. - -## Stage 1 — Platform Abstraction (complete) -Added `wasm` feature + `web-time` dep. Replaced `std::time::Instant` → `crate::Instant` (cfg-gated re-export). Gated `Runtime::run()` and `RuntimeHandle` for non-wasm. Key finding: only `thread::spawn` needed gating — park/unpark/yield/Mutex/RwLock/atomics/crossbeam all work on wasm32 with atomics. Cleaned unused Mutex import in worker.rs. Updated crates/wasm/ to use `wasm` feature. All native tests pass, wasm32 compilation succeeds. Files changed: Cargo.toml, src/lib.rs, src/runtime.rs, src/worker.rs, crates/wasm/Cargo.toml. diff --git a/big-feature-phase/notes/research_synthesis.md b/big-feature-phase/notes/research_synthesis.md deleted file mode 100644 index aaa5da6..0000000 --- a/big-feature-phase/notes/research_synthesis.md +++ /dev/null @@ -1,87 +0,0 @@ -# Research Synthesis — In-Browser Swactor Runtime - -## Ecosystem Landscape - -No established Rust actor framework runs natively in browsers. The closest projects: - -- **Lunatic** — Erlang-inspired Wasm actor runtime using wasmtime (server-side, not browser). Uses preemptive scheduling and work-stealing. Not applicable to browser constraints. -- **wasmCloud** — CNCF distributed actor platform. Single-threaded actors, NATS-backed lattice. Cloud/edge focus, no browser target. -- **Actix** — Tokio-dependent, network stack doesn't compile for wasm32. Community attempts to port failed due to `net2`/tokio dependencies. - -**Implication**: swactor would be the first Rust actor runtime with true multi-threaded browser execution via wasm-threads. This is a differentiated position. - -## Existing Work in This Codebase - -| Component | Status | Notes | -|-----------|--------|-------| -| `crates/wasm/` | Basic PoC | Hardcoded Counter/Relay actors, manual tick, u32-only messages | -| `no_random` feature | Working | Deterministic address generation without `getrandom` | -| `tick()` method | Working | Single-threaded tick for manual driving | -| Actor watching | In core | `ExitReason`, `ActorExited`, `on_actor_exit`, `WatchRegistry` | -| swactor-std | Complete | StdExtension, naming, groups, monitoring, supervisor | - -## Platform Dependencies Analysis - -### Works as-is with wasm-threads -- `crossbeam-queue` (ArrayQueue, SegQueue) — uses `core::sync::atomic` -- `std::sync::{Mutex, RwLock}` — stdlib uses futex on wasm with atomics -- `std::sync::atomic::*` — maps to wasm atomic instructions -- `Arc` — works with atomics -- `std::sync::OnceLock` — works with atomics - -### Requires platform abstraction (4 items) -1. `std::thread::spawn` → Web Worker via `web-sys::Worker` -2. `thread::park_timeout` / `Thread::unpark` → `Atomics.wait` / `Atomics.notify` -3. `thread::yield_now` → no-op (or `Atomics.wait(0)` as hint) -4. `std::time::Instant` → `web_time::Instant` (drop-in crate) - -## Priority Ranking - -### P0 — Must Have (enables everything else) - -1. **Platform abstraction layer** — cfg-gated replacements for thread spawn, park/unpark, yield, Instant. Core swactor compiles for wasm32 with atomics. -2. **Single-worker browser runtime** — Prove the runtime works in a browser. One Web Worker, auto-scheduled tick loop, generic JS API for spawn/send/recv. -3. **Multi-worker parallelism** — N Web Workers sharing runtime state via SharedArrayBuffer. Full utilization of browser CPU cores. - -### P1 — Should Have (full actor system) - -4. **Actor watching in browser** — Death notifications, exit reasons. Already in core, just needs to compile and pass wasm tests. -5. **swactor-std in browser** — Naming, groups, monitoring extensions. Compile and test for wasm32. -6. **Stats and introspection** — Runtime stats accessible from JS. Worker info, actor counts, message throughput. - -### P2 — Important (distributed peer) - -7. **WebSocket transport** — Adapter implementing swactor's transport traits over WebSocket. Browser node joins a distributed cluster. -8. **Browser-to-browser transport foundation** — WebRTC DataChannel scaffolding for future STUN/TURN. - -### P3 — Nice to Have (developer experience) - -9. **TypeScript type generation** — Derive TS interfaces from Rust actor message types. -10. **Build tooling** — wasm-pack wrapper script, example project template, CI configuration. -11. **Browser dev tools** — Actor inspector, message flow visualization, performance profiling. - -### P4 — Future (out of scope for this feature phase) - -12. **STUN/TURN integration** — Full NAT traversal for peer-to-peer browser connections. -13. **Hot code reload** — Swap actor implementations without restarting the runtime. -14. **Wasm component model** — Migrate from wasm-bindgen to component model when stabilized. - -## Key Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Threading model | SharedArrayBuffer + wasm-threads | swactor's shared-memory architecture (Arc, crossbeam queues, atomics) maps directly. postMessage isolation would require a rewrite. | -| Scheduling | `setTimeout(0)` tight loop | `requestAnimationFrame` caps at 60Hz. setTimeout(0) gives ~4ms resolution, sufficient for actor ticks. For rendering-coupled actors, RAF can be opt-in. | -| Actor definition | Rust only | Keeps the type system intact. JS actors would require dynamic dispatch and lose compile-time guarantees. | -| Platform abstraction approach | cfg-gated type aliases + inline functions | Minimal invasion. No trait-based HAL, no new modules. Just swap `std::time::Instant` → `web_time::Instant` etc. | -| Browser crate location | New `crates/wasm-browser/` | Clean separation from the existing PoC. The old `crates/wasm/` can be deprecated or kept as a minimal example. | - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| crossbeam-queue doesn't compile for wasm32+atomics | Low | High | Test early in Stage 1. Fallback: thin wrapper over `VecDeque` behind cfg. | -| COOP/COEP headers break third-party integrations | Medium | Medium | Document clearly. This is the standard trade-off for SharedArrayBuffer apps. | -| Nightly Rust + build-std is unstable | Medium | Medium | Pin to a known-good nightly. Revisit when wasm-threads stabilizes. | -| Web Worker spawn latency | Low | Low | Spawn workers eagerly at runtime init, not per-actor. | -| Browser compatibility gaps | Low | Medium | Target Chrome/Firefox/Safari latest. All support SharedArrayBuffer since 2021. | diff --git a/big-feature-phase/notes/state.md b/big-feature-phase/notes/state.md deleted file mode 100644 index df07bba..0000000 --- a/big-feature-phase/notes/state.md +++ /dev/null @@ -1,47 +0,0 @@ -## Current Task -Stage 1 — Platform Abstraction Layer -Step: Complete -Attempt: 1 of 3 - -## Key Files (read these first on resume) -- `big-feature-phase/TASK.md` — workflow rules -- `big-feature-phase/notes/constraints.md` — guardrails -- `big-feature-phase/notes/feature-stages/02-single-worker-browser.md` — Stage 2 spec -- `Cargo.toml` — `wasm` feature flag, `web-time` dep (lines 23, 30) -- `src/lib.rs` — platform-aware `Instant` re-export (lines 20-24) -- `src/runtime.rs` — cfg-gated `run()` (line 340) and `RuntimeHandle` (line 73) -- `crates/wasm/Cargo.toml` — now uses `features = ["wasm"]` -- `docs/development_history/in-browser/PLATFORM_ABSTRACTION.md` — what was done - -## Last Action & Result -Completed Stage 1 (Platform Abstraction Layer): -- Added `web-time` dep + `wasm` feature (`no_random` + `web-time`) -- Replaced `std::time::Instant` → `crate::Instant` in runtime.rs, worker.rs -- cfg-gated `Runtime::run()` and `RuntimeHandle` for `not(target_arch = "wasm32")` -- Removed unused `Mutex` import from worker.rs -- Updated `crates/wasm/` to use `wasm` feature -- Key finding: most std::sync/thread primitives work on wasm32 with atomics; only `thread::spawn` needed gating -- All native tests pass, wasm32 compilation succeeds - -## Next Action -Begin Stage 2 (Single-Worker Browser Runtime) — `notes/feature-stages/02-single-worker-browser.md`: -1. Create `crates/wasm-browser/` crate structure -2. Implement `BrowserRuntime` wasm-bindgen API (spawn, send, tick, try_recv, stats) -3. Actor registration macro/pattern for JS-accessible spawning -4. Auto-scheduling via setTimeout(0) loop -5. Message serialization across JS↔Wasm boundary -6. Tests (wasm-pack test or Node.js) - -## Completed This Session -- [x] Cycle 0 research artifacts (constraints.md, research_synthesis.md, 6 stage docs) -- [x] Stage 1: `web-time` dep + `wasm` feature flag in Cargo.toml -- [x] Stage 1: Platform-aware `Instant` re-export in src/lib.rs -- [x] Stage 1: cfg-gated `Runtime::run()` and `RuntimeHandle` in src/runtime.rs -- [x] Stage 1: Updated crates/wasm/ to use `wasm` feature -- [x] Stage 1: Validated wasm32 compilation and native tests -- [x] Stage 1: Development history doc - -## Open Questions / Blockers -- Stage 2: Need to decide on message serialization (serde-wasm-bindgen vs raw bytes) -- Stage 2: Actor registration pattern — macro vs manual factory map -- Stage 3: Web Worker thread state initialization needs investigation (does std::thread::current() work in a Web Worker context?) -- 2.45.2