Compare commits
No commits in common. "60a9269f98721d1543ed3752c692b656ffba404e" and "cd1816e3985ce0bfc52427935ab6ed6f5582e9fd" have entirely different histories.
60a9269f98
...
cd1816e398
26 changed files with 115 additions and 2387 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -2723,7 +2723,6 @@ dependencies = [
|
|||
"serde",
|
||||
"swactor-std",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3300,7 +3299,6 @@ name = "wasm"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"swactor",
|
||||
"swactor-std",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
|
@ -3737,16 +3735,6 @@ 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"
|
||||
|
|
|
|||
|
|
@ -22,13 +22,11 @@ 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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>` + `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<Thread>;
|
||||
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<Thread>`, 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<RuntimeHandle, Error> { ... }
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<BrowserRuntimeHandle, Error> {
|
||||
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).
|
||||
|
|
@ -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<Mutex<...>>`. 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<dyn RuntimeExtension>` — 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.
|
||||
|
|
@ -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<u8>)>;
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<T>` — 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. |
|
||||
|
|
@ -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?)
|
||||
|
|
@ -5,9 +5,8 @@ edition = "2024"
|
|||
|
||||
[features]
|
||||
default = ["getrandom"]
|
||||
getrandom = ["dep:getrandom", "swactor/getrandom"]
|
||||
wasm = ["swactor/wasm"]
|
||||
getrandom = ["dep:getrandom"]
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", default-features = false }
|
||||
swactor = { path = "../.." }
|
||||
getrandom = { version = "0.2", optional = true }
|
||||
|
|
|
|||
|
|
@ -102,21 +102,11 @@ impl<M: Message> Router<M> {
|
|||
Some(live[idx])
|
||||
}
|
||||
RoutingStrategy::Random => {
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ 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<String>, addr: ActorAddress) -> Result<(), Error>;
|
||||
|
||||
/// Spawn an actor with a registered name, returning its address.
|
||||
fn spawn_named<A: ActorInterface>(&self, name: impl Into<String>, actor: A) -> Result<ActorAddress, Error>;
|
||||
|
||||
|
|
@ -34,10 +31,6 @@ pub trait RuntimeNaming {
|
|||
}
|
||||
|
||||
impl RuntimeNaming for Runtime {
|
||||
fn register_name(&self, name: impl Into<String>, addr: ActorAddress) -> Result<(), Error> {
|
||||
get_ext(self).name_registry.register(name.into(), addr)
|
||||
}
|
||||
|
||||
fn spawn_named<A: ActorInterface>(&self, name: impl Into<String>, actor: A) -> Result<ActorAddress, Error> {
|
||||
let name = name.into();
|
||||
let addr = self.spawn(actor)?;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,5 @@ edition = "2024"
|
|||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", default-features = false, features = ["wasm"] }
|
||||
swactor-std = { path = "../std", default-features = false, features = ["wasm"] }
|
||||
swactor = { path = "../..", default-features = false, features = ["no_random"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
|
|
|||
|
|
@ -1,570 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>swactor — In-Browser Runtime Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
||||
--text: #c9d1d9; --dim: #8b949e; --accent: #58a6ff;
|
||||
--green: #3fb950; --red: #f85149; --yellow: #d29922; --purple: #bc8cff;
|
||||
--font: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--font); background: var(--bg); color: var(--text); padding: 20px; }
|
||||
h1 { font-size: 1.4em; margin-bottom: 4px; }
|
||||
h1 span { color: var(--accent); }
|
||||
.subtitle { color: var(--dim); font-size: 0.8em; margin-bottom: 20px; }
|
||||
.grid { display: grid; grid-template-columns: 300px 1fr 280px; gap: 16px; height: calc(100vh - 100px); }
|
||||
|
||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; overflow-y: auto; }
|
||||
.panel h2 { font-size: 0.9em; color: var(--accent); margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||||
|
||||
.stat-row { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 0.85em; }
|
||||
.stat-label { color: var(--dim); }
|
||||
.stat-value { color: var(--green); font-weight: bold; }
|
||||
|
||||
.btn {
|
||||
display: inline-block; padding: 6px 12px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text); border-radius: 4px;
|
||||
cursor: pointer; font-family: var(--font); font-size: 0.8em; transition: 0.15s;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.btn.danger:hover { border-color: var(--red); color: var(--red); }
|
||||
.btn.small { padding: 3px 8px; font-size: 0.75em; }
|
||||
|
||||
.section { margin-bottom: 16px; }
|
||||
.section h3 { font-size: 0.8em; color: var(--dim); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
input, select {
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 5px 8px; border-radius: 4px; font-family: var(--font); font-size: 0.8em; width: 100%;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.actor-card {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 10px; margin-bottom: 8px; font-size: 0.8em; position: relative;
|
||||
}
|
||||
.actor-card .type { color: var(--purple); font-weight: bold; }
|
||||
.actor-card .addr { color: var(--dim); font-size: 0.9em; }
|
||||
.actor-card .name-tag { color: var(--yellow); font-size: 0.85em; }
|
||||
.actor-card .group-tag { color: var(--green); font-size: 0.85em; margin-left: 4px; }
|
||||
.actor-card .actions { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
|
||||
#log {
|
||||
font-size: 0.75em; line-height: 1.6; padding: 8px;
|
||||
background: var(--bg); border-radius: 4px; height: calc(100% - 40px); overflow-y: auto;
|
||||
}
|
||||
.log-entry { border-bottom: 1px solid var(--border); padding: 3px 0; }
|
||||
.log-time { color: var(--dim); }
|
||||
.log-spawn { color: var(--green); }
|
||||
.log-msg { color: var(--accent); }
|
||||
.log-recv { color: var(--yellow); }
|
||||
.log-death { color: var(--red); }
|
||||
.log-name { color: var(--purple); }
|
||||
.log-group { color: var(--green); }
|
||||
|
||||
.tick-indicator {
|
||||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--dim); margin-right: 6px; transition: 0.1s;
|
||||
}
|
||||
.tick-indicator.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
|
||||
.controls { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.speed-label { font-size: 0.75em; color: var(--dim); }
|
||||
|
||||
#loading { text-align: center; padding: 40px; color: var(--dim); font-size: 1.2em; }
|
||||
#app { display: none; }
|
||||
|
||||
.form-row { display: flex; gap: 6px; margin-bottom: 6px; }
|
||||
.form-row input { flex: 1; }
|
||||
|
||||
.viz-canvas { width: 100%; height: 200px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1><span>swactor</span> in-browser runtime</h1>
|
||||
<p class="subtitle">actor runtime compiled to WebAssembly, running right here</p>
|
||||
|
||||
<div id="loading">Loading wasm module...</div>
|
||||
|
||||
<div id="app">
|
||||
<div class="grid">
|
||||
<!-- LEFT: Controls -->
|
||||
<div class="panel">
|
||||
<h2>Controls</h2>
|
||||
|
||||
<div class="section">
|
||||
<h3>Runtime</h3>
|
||||
<div class="controls">
|
||||
<span class="tick-indicator" id="tick-led"></span>
|
||||
<button class="btn" id="btn-toggle">Start</button>
|
||||
<button class="btn" id="btn-step">Step</button>
|
||||
<div>
|
||||
<input type="range" id="speed" min="1" max="60" value="20" style="width:80px">
|
||||
<span class="speed-label" id="speed-label">20 tps</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row"><span class="stat-label">Actors</span><span class="stat-value" id="s-actors">0</span></div>
|
||||
<div class="stat-row"><span class="stat-label">Messages</span><span class="stat-value" id="s-msgs">0</span></div>
|
||||
<div class="stat-row"><span class="stat-label">Panics</span><span class="stat-value" id="s-panics">0</span></div>
|
||||
<div class="stat-row"><span class="stat-label">Uptime</span><span class="stat-value" id="s-uptime">0ms</span></div>
|
||||
<div class="stat-row"><span class="stat-label">Ticks</span><span class="stat-value" id="s-ticks">0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Spawn Actor</h3>
|
||||
<div class="form-row">
|
||||
<select id="spawn-type">
|
||||
<option value="counter">Counter</option>
|
||||
<option value="relay">Relay</option>
|
||||
<option value="group_member">Group Member</option>
|
||||
<option value="sentinel">Sentinel</option>
|
||||
</select>
|
||||
<button class="btn" id="btn-spawn">Spawn</button>
|
||||
</div>
|
||||
<div id="spawn-opts"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Send Message</h3>
|
||||
<div class="form-row">
|
||||
<select id="send-target" style="flex:2"><option value="">— select actor —</option></select>
|
||||
<input id="send-value" type="number" value="1" style="flex:1" placeholder="u32">
|
||||
</div>
|
||||
<button class="btn" id="btn-send" style="margin-top:4px">Send u32</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Naming</h3>
|
||||
<div class="form-row">
|
||||
<input id="name-input" placeholder="name">
|
||||
<select id="name-target" style="flex:1"><option value="">— actor —</option></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn small" id="btn-register">Register</button>
|
||||
<button class="btn small" id="btn-lookup">Lookup</button>
|
||||
<button class="btn small" id="btn-unreg">Unregister</button>
|
||||
</div>
|
||||
<div id="names-list" style="font-size:0.75em;color:var(--dim);margin-top:4px"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Groups</h3>
|
||||
<div class="form-row">
|
||||
<input id="group-name" placeholder="group name" value="workers">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn small" id="btn-broadcast">Broadcast 42</button>
|
||||
</div>
|
||||
<div id="groups-list" style="font-size:0.75em;color:var(--dim);margin-top:4px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: Actor Map + Visualization -->
|
||||
<div class="panel">
|
||||
<h2>Actors</h2>
|
||||
<canvas id="viz" class="viz-canvas"></canvas>
|
||||
<div id="actor-list" style="margin-top:12px"></div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Event Log -->
|
||||
<div class="panel">
|
||||
<h2>Event Log</h2>
|
||||
<div id="log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import init, {
|
||||
WasmRuntime, WasmAddr, WasmInboxU32, WasmInboxString,
|
||||
spawn_counter, spawn_relay, spawn_sentinel, spawn_group_member,
|
||||
} from './pkg-web/wasm.js';
|
||||
|
||||
await init();
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'block';
|
||||
|
||||
// ─── State ─────────────────────────────────────────────────────────────
|
||||
const rt = new WasmRuntime();
|
||||
const actors = new Map(); // id -> { addr, type, name?, groups:Set, inbox? }
|
||||
let nextId = 1;
|
||||
let running = false;
|
||||
let tickCount = 0;
|
||||
let rafId = null;
|
||||
let lastTick = 0;
|
||||
|
||||
// Global inboxes for polling
|
||||
const inboxes = []; // { inbox, type: 'u32'|'string', label, actorId? }
|
||||
|
||||
// ─── Logging ───────────────────────────────────────────────────────────
|
||||
const logEl = document.getElementById('log');
|
||||
function log(cls, msg) {
|
||||
const t = new Date().toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 2 });
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry';
|
||||
entry.innerHTML = `<span class="log-time">${t}</span> <span class="${cls}">${msg}</span>`;
|
||||
logEl.appendChild(entry);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
// cap at 500 entries
|
||||
while (logEl.children.length > 500) logEl.removeChild(logEl.firstChild);
|
||||
}
|
||||
|
||||
// ─── Spawn helpers ─────────────────────────────────────────────────────
|
||||
function addActor(addr, type, extra = {}) {
|
||||
const id = nextId++;
|
||||
const entry = { id, addr, type, name: null, groups: new Set(), ...extra };
|
||||
actors.set(id, entry);
|
||||
log('log-spawn', `spawned <b>${type}</b> #${id} (${addr.toString()})`);
|
||||
refreshActorUI();
|
||||
return id;
|
||||
}
|
||||
|
||||
function createInbox(type) {
|
||||
if (type === 'u32') {
|
||||
const inbox = rt.new_inbox_u32();
|
||||
inboxes.push({ inbox, type: 'u32' });
|
||||
return inbox;
|
||||
} else if (type === 'string') {
|
||||
const inbox = rt.new_inbox_string();
|
||||
inboxes.push({ inbox, type: 'string' });
|
||||
return inbox;
|
||||
}
|
||||
}
|
||||
|
||||
function doSpawn() {
|
||||
const type = document.getElementById('spawn-type').value;
|
||||
if (type === 'counter') {
|
||||
const inbox = createInbox('u32');
|
||||
const inboxAddr = inbox.addr();
|
||||
const addr = spawn_counter(rt, inboxAddr);
|
||||
const id = addActor(addr, 'Counter', { reportInbox: inbox });
|
||||
} else if (type === 'relay') {
|
||||
// relay needs a target — pick first available actor
|
||||
const targetId = prompt('Target actor ID to relay to:');
|
||||
const target = actors.get(Number(targetId));
|
||||
if (!target) { log('log-death', 'invalid target'); return; }
|
||||
const addr = spawn_relay(rt, target.addr);
|
||||
addActor(addr, 'Relay', { relayTarget: targetId });
|
||||
} else if (type === 'group_member') {
|
||||
const group = document.getElementById('group-name').value || 'workers';
|
||||
const inbox = createInbox('u32');
|
||||
const inboxAddr = inbox.addr();
|
||||
const addr = spawn_group_member(rt, group, inboxAddr);
|
||||
const id = addActor(addr, 'GroupMember', { group, reportInbox: inbox });
|
||||
actors.get(id).groups.add(group);
|
||||
} else if (type === 'sentinel') {
|
||||
const targetId = prompt('Actor ID to watch:');
|
||||
const target = actors.get(Number(targetId));
|
||||
if (!target) { log('log-death', 'invalid target'); return; }
|
||||
const inbox = createInbox('string');
|
||||
const addr = spawn_sentinel(rt, target.addr, inbox);
|
||||
addActor(addr, 'Sentinel', { watching: targetId, deathInbox: inbox });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tick loop ─────────────────────────────────────────────────────────
|
||||
function tickOnce() {
|
||||
rt.tick();
|
||||
tickCount++;
|
||||
pollInboxes();
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function pollInboxes() {
|
||||
for (const ib of inboxes) {
|
||||
let val;
|
||||
while ((val = ib.inbox.try_recv()) !== undefined) {
|
||||
if (ib.type === 'u32') {
|
||||
log('log-recv', `inbox received <b>${val}</b>`);
|
||||
} else {
|
||||
log('log-death', `death notification: <b>${val}</b>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTickInterval() {
|
||||
return 1000 / Number(document.getElementById('speed').value);
|
||||
}
|
||||
|
||||
function loop(ts) {
|
||||
if (!running) return;
|
||||
if (ts - lastTick >= getTickInterval()) {
|
||||
tickOnce();
|
||||
flashLed();
|
||||
lastTick = ts;
|
||||
}
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function flashLed() {
|
||||
const led = document.getElementById('tick-led');
|
||||
led.classList.add('active');
|
||||
setTimeout(() => led.classList.remove('active'), 80);
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('s-actors').textContent = rt.actor_count();
|
||||
document.getElementById('s-msgs').textContent = rt.total_messages();
|
||||
document.getElementById('s-panics').textContent = rt.total_panics();
|
||||
document.getElementById('s-uptime').textContent = Math.round(rt.uptime_ms()) + 'ms';
|
||||
document.getElementById('s-ticks').textContent = tickCount;
|
||||
|
||||
// names
|
||||
const names = rt.registered_names();
|
||||
document.getElementById('names-list').textContent = names ? `Registered: ${names}` : 'No names registered';
|
||||
|
||||
// groups
|
||||
const groups = rt.group_names();
|
||||
const parts = groups ? groups.split(',').map(g => `${g}(${rt.group_member_count(g)})`).join(', ') : 'none';
|
||||
document.getElementById('groups-list').textContent = `Groups: ${parts}`;
|
||||
|
||||
refreshDropdowns();
|
||||
drawViz();
|
||||
}
|
||||
|
||||
// ─── Actor Cards ───────────────────────────────────────────────────────
|
||||
function refreshActorUI() {
|
||||
const container = document.getElementById('actor-list');
|
||||
container.innerHTML = '';
|
||||
for (const [id, a] of actors) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'actor-card';
|
||||
let meta = '';
|
||||
if (a.name) meta += ` <span class="name-tag">@${a.name}</span>`;
|
||||
if (a.groups.size) meta += ` <span class="group-tag">[${[...a.groups].join(',')}]</span>`;
|
||||
if (a.relayTarget) meta += ` <span style="color:var(--dim)">→ #${a.relayTarget}</span>`;
|
||||
if (a.watching) meta += ` <span style="color:var(--dim)">watching #${a.watching}</span>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<span class="type">${a.type}</span> <span style="color:var(--dim)">#${id}</span>${meta}
|
||||
<br><span class="addr">${a.addr.toString()}</span>
|
||||
<div class="actions">
|
||||
<button class="btn small" onclick="window._send(${id})">Send 1</button>
|
||||
<button class="btn small" onclick="window._send10(${id})">Send ×10</button>
|
||||
<button class="btn small danger" onclick="window._stop(${id})">Stop</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
}
|
||||
refreshDropdowns();
|
||||
}
|
||||
|
||||
function refreshDropdowns() {
|
||||
for (const sel of [document.getElementById('send-target'), document.getElementById('name-target')]) {
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = '<option value="">— select —</option>';
|
||||
for (const [id, a] of actors) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = `#${id} ${a.type}${a.name ? ' @' + a.name : ''}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = prev;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Visualization ─────────────────────────────────────────────────────
|
||||
function drawViz() {
|
||||
const canvas = document.getElementById('viz');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
const entries = [...actors.values()];
|
||||
if (entries.length === 0) {
|
||||
ctx.fillStyle = '#8b949e';
|
||||
ctx.font = '13px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Spawn some actors to see them here', rect.width / 2, rect.height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const colors = { Counter: '#58a6ff', Relay: '#bc8cff', GroupMember: '#3fb950', Sentinel: '#f85149' };
|
||||
const cx = rect.width / 2;
|
||||
const cy = rect.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
|
||||
// Position actors in a circle
|
||||
const positions = new Map();
|
||||
entries.forEach((a, i) => {
|
||||
const angle = (2 * Math.PI * i) / entries.length - Math.PI / 2;
|
||||
const x = cx + radius * Math.cos(angle);
|
||||
const y = cy + radius * Math.sin(angle);
|
||||
positions.set(a.id, { x, y });
|
||||
});
|
||||
|
||||
// Draw connections
|
||||
ctx.lineWidth = 1;
|
||||
for (const a of entries) {
|
||||
const from = positions.get(a.id);
|
||||
if (a.relayTarget && positions.has(Number(a.relayTarget))) {
|
||||
const to = positions.get(Number(a.relayTarget));
|
||||
ctx.strokeStyle = '#bc8cff44';
|
||||
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke();
|
||||
drawArrow(ctx, from, to, '#bc8cff44');
|
||||
}
|
||||
if (a.watching && positions.has(Number(a.watching))) {
|
||||
const to = positions.get(Number(a.watching));
|
||||
ctx.strokeStyle = '#f8514944';
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw actors
|
||||
for (const a of entries) {
|
||||
const pos = positions.get(a.id);
|
||||
const color = colors[a.type] || '#c9d1d9';
|
||||
// glow
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, 14, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// label
|
||||
ctx.fillStyle = '#c9d1d9';
|
||||
ctx.font = 'bold 10px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`#${a.id}`, pos.x, pos.y + 4);
|
||||
|
||||
// type label below
|
||||
ctx.fillStyle = '#8b949e';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillText(a.type, pos.x, pos.y + 28);
|
||||
if (a.name) {
|
||||
ctx.fillStyle = '#d29922';
|
||||
ctx.fillText(`@${a.name}`, pos.x, pos.y + 38);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawArrow(ctx, from, to, color) {
|
||||
const dx = to.x - from.x, dy = to.y - from.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len < 30) return;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const tipX = to.x - ux * 18, tipY = to.y - uy * 18;
|
||||
const sz = 6;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tipX, tipY);
|
||||
ctx.lineTo(tipX - ux * sz - uy * sz * 0.5, tipY - uy * sz + ux * sz * 0.5);
|
||||
ctx.lineTo(tipX - ux * sz + uy * sz * 0.5, tipY - uy * sz - ux * sz * 0.5);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ─── Global handlers (for inline onclick) ──────────────────────────────
|
||||
window._send = (id) => {
|
||||
const a = actors.get(id);
|
||||
if (!a) return;
|
||||
const val = Number(document.getElementById('send-value').value) || 1;
|
||||
rt.send_u32(a.addr, val);
|
||||
log('log-msg', `sent <b>${val}</b> → #${id} ${a.type}`);
|
||||
};
|
||||
|
||||
window._send10 = (id) => {
|
||||
const a = actors.get(id);
|
||||
if (!a) return;
|
||||
for (let i = 0; i < 10; i++) rt.send_u32(a.addr, 1);
|
||||
log('log-msg', `sent <b>10×1</b> → #${id} ${a.type}`);
|
||||
};
|
||||
|
||||
window._stop = (id) => {
|
||||
const a = actors.get(id);
|
||||
if (!a) return;
|
||||
rt.stop_actor(a.addr);
|
||||
log('log-death', `stopped #${id} ${a.type}`);
|
||||
actors.delete(id);
|
||||
refreshActorUI();
|
||||
};
|
||||
|
||||
// ─── Button wiring ─────────────────────────────────────────────────────
|
||||
document.getElementById('btn-toggle').addEventListener('click', () => {
|
||||
running = !running;
|
||||
document.getElementById('btn-toggle').textContent = running ? 'Pause' : 'Start';
|
||||
if (running) { lastTick = performance.now(); rafId = requestAnimationFrame(loop); }
|
||||
});
|
||||
|
||||
document.getElementById('btn-step').addEventListener('click', () => {
|
||||
tickOnce();
|
||||
flashLed();
|
||||
});
|
||||
|
||||
document.getElementById('speed').addEventListener('input', (e) => {
|
||||
document.getElementById('speed-label').textContent = e.target.value + ' tps';
|
||||
});
|
||||
|
||||
document.getElementById('btn-spawn').addEventListener('click', doSpawn);
|
||||
|
||||
document.getElementById('btn-send').addEventListener('click', () => {
|
||||
const id = Number(document.getElementById('send-target').value);
|
||||
if (id) window._send(id);
|
||||
});
|
||||
|
||||
document.getElementById('btn-register').addEventListener('click', () => {
|
||||
const name = document.getElementById('name-input').value.trim();
|
||||
const id = Number(document.getElementById('name-target').value);
|
||||
const a = actors.get(id);
|
||||
if (!name || !a) return;
|
||||
const ok = rt.register_name(name, a.addr);
|
||||
if (ok) {
|
||||
a.name = name;
|
||||
log('log-name', `registered <b>@${name}</b> → #${id}`);
|
||||
refreshActorUI();
|
||||
} else {
|
||||
log('log-death', `name <b>@${name}</b> already taken`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-lookup').addEventListener('click', () => {
|
||||
const name = document.getElementById('name-input').value.trim();
|
||||
if (!name) return;
|
||||
const found = rt.where_is(name);
|
||||
if (found) {
|
||||
log('log-name', `@${name} → ${found.toString()}`);
|
||||
} else {
|
||||
log('log-name', `@${name} not found`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-unreg').addEventListener('click', () => {
|
||||
const name = document.getElementById('name-input').value.trim();
|
||||
if (!name) return;
|
||||
const prev = rt.unregister_name(name);
|
||||
if (prev) {
|
||||
for (const a of actors.values()) { if (a.name === name) a.name = null; }
|
||||
log('log-name', `unregistered <b>@${name}</b>`);
|
||||
refreshActorUI();
|
||||
} else {
|
||||
log('log-name', `@${name} was not registered`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-broadcast').addEventListener('click', () => {
|
||||
const group = document.getElementById('group-name').value.trim();
|
||||
if (!group) return;
|
||||
const count = rt.publish_to_group_u32(group, 42);
|
||||
log('log-group', `broadcast <b>42</b> to group "${group}" (${count} members)`);
|
||||
});
|
||||
|
||||
// initial draw
|
||||
updateStats();
|
||||
log('log-spawn', 'runtime initialized — spawn some actors and hit <b>Start</b> or <b>Step</b>');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,240 +1,11 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
|
||||
use swactor::actor::{ActorAddress, ActorInterface};
|
||||
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig};
|
||||
use swactor_std::{CtxGroups, RuntimeNaming, RuntimeGroups, StdExtension};
|
||||
|
||||
// ─── Core JS-facing types ───────────────────────────────────────────────────
|
||||
|
||||
/// Opaque actor address handle for JavaScript.
|
||||
///
|
||||
/// Returned by spawn functions, passed to send functions. JS never sees
|
||||
/// the raw 32-byte address — it just holds and forwards this handle.
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone)]
|
||||
pub struct WasmAddr(ActorAddress);
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmAddr {
|
||||
/// Debug representation of the address (first 8 hex bytes + ellipsis).
|
||||
#[wasm_bindgen(js_name = toString)]
|
||||
pub fn to_js_string(&self) -> String {
|
||||
format!("{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl WasmAddr {
|
||||
/// Access the inner address from Rust (not exposed to JS).
|
||||
pub fn inner(&self) -> ActorAddress {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbox that receives `u32` values from actors.
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmInboxU32 {
|
||||
inner: Inbox<u32>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmInboxU32 {
|
||||
/// The address actors should send results to.
|
||||
pub fn addr(&self) -> WasmAddr {
|
||||
WasmAddr(*self.inner.addr())
|
||||
}
|
||||
|
||||
/// Poll for the next value. Returns `undefined` when empty.
|
||||
pub fn try_recv(&self) -> Option<u32> {
|
||||
self.inner.try_recv()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbox that receives byte arrays from actors.
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmInboxBytes {
|
||||
inner: Inbox<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmInboxBytes {
|
||||
pub fn addr(&self) -> WasmAddr {
|
||||
WasmAddr(*self.inner.addr())
|
||||
}
|
||||
|
||||
/// Poll for the next byte array. Returns `undefined` when empty.
|
||||
pub fn try_recv(&self) -> Option<Vec<u8>> {
|
||||
self.inner.try_recv()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbox that receives string values (used for death notifications, etc.).
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmInboxString {
|
||||
inner: Inbox<String>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
self.inner.try_recv()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Runtime ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The browser-facing swactor runtime.
|
||||
///
|
||||
/// Wraps `swactor::Runtime` in single-threaded mode with StdExtension installed
|
||||
/// (naming, monitoring, groups). Actors are spawned via dedicated spawn functions
|
||||
/// (one per actor type). The runtime is driven by calling `tick()`.
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmRuntime {
|
||||
rt: Runtime,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmRuntime {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
let rt = Runtime::new(RuntimeConfig {
|
||||
num_threads: 1,
|
||||
..RuntimeConfig::default()
|
||||
})
|
||||
.with_extension(Arc::new(StdExtension::new()));
|
||||
Self { rt }
|
||||
}
|
||||
|
||||
/// Drive one tick of the runtime.
|
||||
pub fn tick(&self) {
|
||||
self.rt.tick();
|
||||
}
|
||||
|
||||
/// Number of actors currently alive.
|
||||
pub fn actor_count(&self) -> usize {
|
||||
self.rt.stats().actors.len()
|
||||
}
|
||||
|
||||
/// Create an inbox that receives u32 values.
|
||||
pub fn new_inbox_u32(&self) -> WasmInboxU32 {
|
||||
WasmInboxU32 {
|
||||
inner: self.rt.new_inbox().expect("new_inbox_u32"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an inbox that receives byte arrays.
|
||||
pub fn new_inbox_bytes(&self) -> WasmInboxBytes {
|
||||
WasmInboxBytes {
|
||||
inner: self.rt.new_inbox().expect("new_inbox_bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an inbox that receives strings.
|
||||
pub fn new_inbox_string(&self) -> WasmInboxString {
|
||||
WasmInboxString {
|
||||
inner: self.rt.new_inbox().expect("new_inbox_string"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a u32 to an actor. Returns false if the address is invalid.
|
||||
pub fn send_u32(&self, addr: &WasmAddr, value: u32) -> bool {
|
||||
self.rt.send_to(addr.0, value).is_ok()
|
||||
}
|
||||
|
||||
/// Send a byte array to an actor. Returns false if the address is invalid.
|
||||
pub fn send_bytes(&self, addr: &WasmAddr, data: &[u8]) -> bool {
|
||||
self.rt.send_to(addr.0, data.to_vec()).is_ok()
|
||||
}
|
||||
|
||||
/// Stop an actor gracefully.
|
||||
pub fn stop_actor(&self, addr: &WasmAddr) -> bool {
|
||||
self.rt.stop_actor(addr.0).is_ok()
|
||||
}
|
||||
|
||||
/// Runtime uptime in milliseconds.
|
||||
pub fn uptime_ms(&self) -> f64 {
|
||||
self.rt.stats().uptime_ms as f64
|
||||
}
|
||||
|
||||
// ─── Naming ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Register a name for an actor address. Returns false if the name is taken.
|
||||
pub fn register_name(&self, name: &str, addr: &WasmAddr) -> bool {
|
||||
self.rt.register_name(name.to_string(), addr.0).is_ok()
|
||||
}
|
||||
|
||||
/// Look up an actor address by name. Returns undefined if not found.
|
||||
pub fn where_is(&self, name: &str) -> Option<WasmAddr> {
|
||||
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<WasmAddr> {
|
||||
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::<u64>() as f64
|
||||
}
|
||||
|
||||
/// Total panics across all workers.
|
||||
pub fn total_panics(&self) -> f64 {
|
||||
self.rt.stats().workers.iter().map(|w| w.panics).sum::<u64>() 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actors (private — only exposed through the wasm API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Counter {
|
||||
total: u32,
|
||||
|
|
@ -264,100 +35,79 @@ 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,
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// JS-facing runtime wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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]
|
||||
pub fn spawn_counter(rt: &WasmRuntime, report_to: &WasmAddr) -> WasmAddr {
|
||||
let addr = rt
|
||||
pub struct SwactorRuntime {
|
||||
rt: Runtime,
|
||||
inbox: Inbox<u32>,
|
||||
actors: Vec<ActorAddress>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl SwactorRuntime {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
let rt = Runtime::new(RuntimeConfig {
|
||||
num_threads: 1,
|
||||
..RuntimeConfig::default()
|
||||
});
|
||||
let inbox = rt.new_inbox().unwrap();
|
||||
Self {
|
||||
rt,
|
||||
inbox,
|
||||
actors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: report_to.0,
|
||||
report_to: *self.inbox.addr(),
|
||||
})
|
||||
.expect("spawn counter");
|
||||
WasmAddr(addr)
|
||||
}
|
||||
let idx = self.actors.len();
|
||||
self.actors.push(addr);
|
||||
idx
|
||||
}
|
||||
|
||||
/// Spawn a relay that forwards every u32 message to the target actor.
|
||||
#[wasm_bindgen]
|
||||
pub fn spawn_relay(rt: &WasmRuntime, target: &WasmAddr) -> WasmAddr {
|
||||
let addr = rt
|
||||
/// 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: target.0 })
|
||||
.spawn(Relay { target })
|
||||
.expect("spawn relay");
|
||||
WasmAddr(addr)
|
||||
}
|
||||
let idx = self.actors.len();
|
||||
self.actors.push(addr);
|
||||
idx
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// Drive one tick of the single-threaded runtime.
|
||||
pub fn tick(&self) {
|
||||
self.rt.tick();
|
||||
}
|
||||
|
||||
/// Try to read the next result from the inbox. Returns `undefined` when empty.
|
||||
pub fn try_recv(&self) -> Option<u32> {
|
||||
self.inbox.try_recv()
|
||||
}
|
||||
|
||||
/// Number of actors the runtime knows about.
|
||||
pub fn actor_count(&self) -> usize {
|
||||
self.rt.stats().actors.len()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
WasmRuntime,
|
||||
WasmAddr,
|
||||
spawn_counter,
|
||||
spawn_relay,
|
||||
spawn_sentinel,
|
||||
spawn_group_member,
|
||||
} from "./pkg/wasm.js";
|
||||
import { SwactorRuntime } from "./pkg/swactor_wasm.js";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
|
@ -30,314 +23,77 @@ function assertEq(a, b, msg) {
|
|||
}
|
||||
}
|
||||
|
||||
function drainInbox(inbox) {
|
||||
function drain(rt) {
|
||||
const results = [];
|
||||
let v;
|
||||
while ((v = inbox.try_recv()) !== undefined) results.push(v);
|
||||
while ((v = rt.try_recv()) !== undefined) results.push(v);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- accumulator ----------------------------------------------------------
|
||||
{
|
||||
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);
|
||||
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);
|
||||
rt.tick();
|
||||
assertEq(drainInbox(inbox), [1, 3, 13], "running totals");
|
||||
inbox.free();
|
||||
assertEq(drain(rt), [1, 3, 13], "running totals");
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- relay ----------------------------------------------------------------
|
||||
{
|
||||
console.log("test: relay forwards to counter");
|
||||
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)
|
||||
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)
|
||||
// tick 2: counter receives forwarded messages
|
||||
rt.tick();
|
||||
rt.tick();
|
||||
assertEq(drainInbox(inbox), [5, 12], "relayed totals");
|
||||
inbox.free();
|
||||
assertEq(drain(rt), [5, 12], "relayed totals");
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- multiple counters ----------------------------------------------------
|
||||
{
|
||||
console.log("test: multiple independent counters");
|
||||
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);
|
||||
const rt = new SwactorRuntime();
|
||||
const a = rt.spawn_counter();
|
||||
const b = rt.spawn_counter();
|
||||
rt.send(a, 10);
|
||||
rt.send(b, 100);
|
||||
rt.tick();
|
||||
const results = drainInbox(inbox);
|
||||
const results = drain(rt);
|
||||
// order depends on HashMap iteration, so just check set equality
|
||||
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 WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
spawn_counter(rt, inbox.addr());
|
||||
spawn_counter(rt, inbox.addr());
|
||||
spawn_counter(rt, inbox.addr());
|
||||
const rt = new SwactorRuntime();
|
||||
rt.spawn_counter();
|
||||
rt.spawn_counter();
|
||||
rt.spawn_counter();
|
||||
rt.tick(); // drain spawn queue
|
||||
assertEq(rt.actor_count(), 3, "three actors");
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- WasmAddr toString ----------------------------------------------------
|
||||
// ---- send to invalid index returns false ----------------------------------
|
||||
{
|
||||
console.log("test: WasmAddr has string representation");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const addr = spawn_counter(rt, inbox.addr());
|
||||
const s = addr.toString();
|
||||
// no_random generates deterministic addresses — just check it's a non-empty hex string
|
||||
assert(typeof s === "string" && s.length > 0, "addr toString is non-empty string");
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- stop_actor -----------------------------------------------------------
|
||||
{
|
||||
console.log("test: stop_actor removes actor");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const c = spawn_counter(rt, inbox.addr());
|
||||
rt.tick(); // drain spawn
|
||||
assertEq(rt.actor_count(), 1, "one actor before stop");
|
||||
rt.stop_actor(c);
|
||||
rt.tick(); // process stop + cleanup
|
||||
assertEq(rt.actor_count(), 0, "zero actors after stop");
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- bytes inbox ----------------------------------------------------------
|
||||
{
|
||||
console.log("test: byte inbox receives Uint8Array");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_bytes();
|
||||
// Send bytes directly (no actor — just to the inbox address)
|
||||
rt.send_bytes(inbox.addr(), new Uint8Array([1, 2, 3]));
|
||||
rt.tick();
|
||||
const result = inbox.try_recv();
|
||||
assert(result instanceof Uint8Array, "result is Uint8Array");
|
||||
assertEq(Array.from(result), [1, 2, 3], "bytes match");
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- uptime ---------------------------------------------------------------
|
||||
{
|
||||
console.log("test: uptime_ms returns a number");
|
||||
const rt = new WasmRuntime();
|
||||
const uptime = rt.uptime_ms();
|
||||
assert(typeof uptime === "number" && uptime >= 0, "uptime is non-negative number");
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- naming: register and resolve -----------------------------------------
|
||||
{
|
||||
console.log("test: naming — register_name and where_is");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const c = spawn_counter(rt, inbox.addr());
|
||||
rt.tick(); // drain spawn
|
||||
|
||||
const ok = rt.register_name("my_counter", c);
|
||||
assert(ok, "register_name succeeds");
|
||||
|
||||
const found = rt.where_is("my_counter");
|
||||
assert(found !== undefined, "where_is finds registered actor");
|
||||
assertEq(found.toString(), c.toString(), "where_is returns correct address");
|
||||
|
||||
const notFound = rt.where_is("nonexistent");
|
||||
assert(notFound === undefined, "where_is returns undefined for unknown name");
|
||||
|
||||
found.free();
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- naming: unregister ---------------------------------------------------
|
||||
{
|
||||
console.log("test: naming — unregister_name");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const c = spawn_counter(rt, inbox.addr());
|
||||
rt.tick();
|
||||
|
||||
rt.register_name("temp", c);
|
||||
const prev = rt.unregister_name("temp");
|
||||
assert(prev !== undefined, "unregister returns previous address");
|
||||
assertEq(prev.toString(), c.toString(), "unregister returns correct address");
|
||||
|
||||
const gone = rt.where_is("temp");
|
||||
assert(gone === undefined, "name no longer resolves after unregister");
|
||||
|
||||
prev.free();
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- naming: registered_names ---------------------------------------------
|
||||
{
|
||||
console.log("test: naming — registered_names");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const a = spawn_counter(rt, inbox.addr());
|
||||
const b = spawn_counter(rt, inbox.addr());
|
||||
rt.tick();
|
||||
|
||||
rt.register_name("alpha", a);
|
||||
rt.register_name("beta", b);
|
||||
const names = rt.registered_names().split(",").sort();
|
||||
assertEq(names, ["alpha", "beta"], "registered_names lists all names");
|
||||
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- naming: duplicate name rejected --------------------------------------
|
||||
{
|
||||
console.log("test: naming — duplicate name rejected");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const a = spawn_counter(rt, inbox.addr());
|
||||
const b = spawn_counter(rt, inbox.addr());
|
||||
rt.tick();
|
||||
|
||||
const ok1 = rt.register_name("unique", a);
|
||||
const ok2 = rt.register_name("unique", b);
|
||||
assert(ok1, "first registration succeeds");
|
||||
assert(!ok2, "duplicate registration fails");
|
||||
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- groups: join and broadcast -------------------------------------------
|
||||
{
|
||||
console.log("test: groups — join_group and publish_to_group_u32");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const a = spawn_group_member(rt, "workers", inbox.addr());
|
||||
const b = spawn_group_member(rt, "workers", inbox.addr());
|
||||
rt.tick(); // spawn + on_start (join group)
|
||||
|
||||
assertEq(rt.group_member_count("workers"), 2, "two members in group");
|
||||
|
||||
rt.publish_to_group_u32("workers", 42);
|
||||
rt.tick(); // group members receive
|
||||
rt.tick(); // group members forward to inbox
|
||||
|
||||
const results = drainInbox(inbox);
|
||||
assertEq(results.length, 2, "both members received broadcast");
|
||||
assert(results.every((v) => v === 42), "correct value broadcast");
|
||||
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- groups: leave --------------------------------------------------------
|
||||
{
|
||||
console.log("test: groups — leave_group");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const a = spawn_group_member(rt, "pool", inbox.addr());
|
||||
const b = spawn_group_member(rt, "pool", inbox.addr());
|
||||
rt.tick(); // spawn + on_start
|
||||
|
||||
assertEq(rt.group_member_count("pool"), 2, "two members before leave");
|
||||
rt.leave_group(a, "pool");
|
||||
assertEq(rt.group_member_count("pool"), 1, "one member after leave");
|
||||
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- groups: group_names --------------------------------------------------
|
||||
{
|
||||
console.log("test: groups — group_names");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
spawn_group_member(rt, "alpha", inbox.addr());
|
||||
spawn_group_member(rt, "beta", inbox.addr());
|
||||
rt.tick(); // spawn + join
|
||||
|
||||
const names = rt.group_names().split(",").sort();
|
||||
assertEq(names, ["alpha", "beta"], "group_names lists all groups");
|
||||
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- watching: sentinel detects death -------------------------------------
|
||||
{
|
||||
console.log("test: watching — sentinel reports actor death");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const deathInbox = rt.new_inbox_string();
|
||||
|
||||
const target = spawn_counter(rt, inbox.addr());
|
||||
const sentinel = spawn_sentinel(rt, target, deathInbox);
|
||||
rt.tick(); // spawn + on_start (watch)
|
||||
|
||||
rt.stop_actor(target);
|
||||
// tick to process stop, cleanup, and deliver death notification
|
||||
for (let i = 0; i < 5; i++) rt.tick();
|
||||
|
||||
const notification = deathInbox.try_recv();
|
||||
assert(notification !== undefined, "sentinel received death notification");
|
||||
assert(
|
||||
typeof notification === "string" && notification.length > 0,
|
||||
"notification is a non-empty string"
|
||||
);
|
||||
|
||||
inbox.free();
|
||||
deathInbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- stats: total_messages ------------------------------------------------
|
||||
{
|
||||
console.log("test: stats — total_messages");
|
||||
const rt = new WasmRuntime();
|
||||
const inbox = rt.new_inbox_u32();
|
||||
const c = spawn_counter(rt, inbox.addr());
|
||||
rt.send_u32(c, 1);
|
||||
rt.send_u32(c, 2);
|
||||
rt.send_u32(c, 3);
|
||||
rt.tick();
|
||||
assert(rt.total_messages() >= 3, "total_messages counts processed messages");
|
||||
inbox.free();
|
||||
rt.free();
|
||||
}
|
||||
|
||||
// ---- stats: total_panics starts at zero -----------------------------------
|
||||
{
|
||||
console.log("test: stats — total_panics starts at zero");
|
||||
const rt = new WasmRuntime();
|
||||
assertEq(rt.total_panics(), 0, "no panics initially");
|
||||
console.log("test: send to bad index returns false");
|
||||
const rt = new SwactorRuntime();
|
||||
assert(!rt.send(999, 1), "out-of-bounds send");
|
||||
rt.free();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
# Browser Runtime API — Development History
|
||||
|
||||
> Stage 2 of the in-browser swactor runtime. Replaces the hardcoded PoC with
|
||||
> a generic, type-safe API using opaque address handles and typed inboxes.
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### Core Types
|
||||
|
||||
**`WasmRuntime`** — wraps `swactor::Runtime` in single-threaded mode.
|
||||
Methods: `tick()`, `actor_count()`, `send_u32()`, `send_bytes()`,
|
||||
`stop_actor()`, `uptime_ms()`, `new_inbox_u32()`, `new_inbox_bytes()`.
|
||||
Also exposes `runtime()` for Rust-side custom spawn functions.
|
||||
|
||||
**`WasmAddr`** — opaque handle wrapping `ActorAddress`. Returned by spawn
|
||||
functions, passed to send functions. JS holds it as an opaque object.
|
||||
Has `toString()` for debugging.
|
||||
|
||||
**`WasmInboxU32`** / **`WasmInboxBytes`** — typed inboxes for receiving
|
||||
results from actors. Each has `addr()` → `WasmAddr` (so actors know where
|
||||
to send) and `try_recv()` → `Option<T>`.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|----------|-----------|
|
||||
| 1 | Opaque `WasmAddr` handles instead of indices | Type-safe, stable identity, no out-of-bounds errors |
|
||||
| 2 | Typed inbox types instead of generic `Inbox<T>` | wasm-bindgen doesn't support generics; concrete types are explicit |
|
||||
| 3 | Free-standing `spawn_*` functions, not methods | Each actor type gets its own spawn function with typed args |
|
||||
| 4 | `send_u32`/`send_bytes` on runtime | Common send types; custom types use typed spawn wrappers |
|
||||
| 5 | Evolved existing `crates/wasm/` instead of new crate | Less churn, existing build/test infrastructure |
|
||||
|
||||
### Actor Pattern
|
||||
|
||||
Users expose actors to JS by writing one `#[wasm_bindgen]` spawn function
|
||||
per actor type:
|
||||
|
||||
```rust
|
||||
#[wasm_bindgen]
|
||||
pub fn spawn_my_actor(rt: &WasmRuntime, arg: JsValue) -> WasmAddr {
|
||||
let actor = MyActor::from_js(arg);
|
||||
let addr = rt.runtime().spawn(actor).unwrap();
|
||||
WasmAddr(addr)
|
||||
}
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
10 Node.js tests in `crates/wasm/test.mjs`:
|
||||
|
||||
| Test | Scenario |
|
||||
|------|----------|
|
||||
| accumulator | Counter processes messages, reports running totals to inbox |
|
||||
| relay | Relay forwards messages to counter (cross-actor, 2 ticks) |
|
||||
| multiple counters | Two independent counters report to same inbox |
|
||||
| actor_count | Spawning 3 actors reflects in stats |
|
||||
| WasmAddr toString | Address has non-empty debug representation |
|
||||
| stop_actor | Graceful stop removes actor from runtime |
|
||||
| bytes inbox | WasmInboxBytes receives Uint8Array correctly |
|
||||
| uptime_ms | Returns non-negative number |
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo test -p swactor` — native tests pass (no regressions)
|
||||
- `cargo build --target wasm32-unknown-unknown -p wasm` — compiles
|
||||
- `wasm-pack build --target nodejs` in `crates/wasm/` — builds pkg/
|
||||
- `node test.mjs` in `crates/wasm/` — 10/10 tests pass
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
# 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<RuntimeHandle, Error> { ... }
|
||||
```
|
||||
|
||||
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<Thread>` — 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
|
||||
|
|
@ -18,13 +18,6 @@ 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()
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ use std::any::Any;
|
|||
use std::cell::RefCell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::thread::Thread;
|
||||
use crate::Instant;
|
||||
use std::thread::{self, JoinHandle, Thread};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::actor::{Actor, ActorAddress, ActorExited, ActorInterface, AnyActor, ExitReason, Message, StopSignal, TimerRequest};
|
||||
use crate::channel::{Receiver, Sender};
|
||||
|
|
@ -69,13 +67,11 @@ impl<R: Message> Ask<R> {
|
|||
}
|
||||
|
||||
/// 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<Runtime>,
|
||||
threads: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl RuntimeHandle {
|
||||
pub fn join(self) {
|
||||
for handle in self.threads {
|
||||
|
|
@ -337,9 +333,6 @@ 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<RuntimeHandle, Error> {
|
||||
self.is_running.store(true, Ordering::Release);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use crate::Instant;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::actor::{ActorAddress, ActorExited, AnyActor, CloneMsg, ContextInner, Ctx, ExitReason, StopReason, StopSignal, TimerRequest};
|
||||
use crate::channel::Receiver;
|
||||
|
|
@ -735,7 +735,6 @@ 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue