Compare commits
5 commits
cd1816e398
...
60a9269f98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a9269f98 | ||
|
|
3e44579e5c | ||
|
|
82e72ff235 | ||
|
|
2649a54feb | ||
|
|
63cba1c4eb |
26 changed files with 2389 additions and 117 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -2723,6 +2723,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"swactor-std",
|
"swactor-std",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"web-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3299,6 +3300,7 @@ name = "wasm"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"swactor",
|
"swactor",
|
||||||
|
"swactor-std",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3735,6 +3737,16 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,13 @@ serde = ["dep:serde"]
|
||||||
tracing = ["dep:tracing"]
|
tracing = ["dep:tracing"]
|
||||||
no_random = [] # compile without access to a source of randomness
|
no_random = [] # compile without access to a source of randomness
|
||||||
transport = [] # transport-agnostic messaging (no mandatory deps; codec is user-provided)
|
transport = [] # transport-agnostic messaging (no mandatory deps; codec is user-provided)
|
||||||
|
wasm = ["no_random", "dep:web-time"] # browser/wasm32 target support
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
getrandom = { version = "0.2", optional = true }
|
getrandom = { version = "0.2", optional = true }
|
||||||
serde = { version = "1", features = ["derive"], optional = true }
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
tracing = { version = "0.1", optional = true }
|
tracing = { version = "0.1", optional = true }
|
||||||
|
web-time = { version = "0.2", optional = true }
|
||||||
crossbeam-queue = "0.3.12"
|
crossbeam-queue = "0.3.12"
|
||||||
crossbeam-utils = "0.8.21"
|
crossbeam-utils = "0.8.21"
|
||||||
|
|
||||||
|
|
|
||||||
58
big-feature-phase/notes/constraints.md
Normal file
58
big-feature-phase/notes/constraints.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Constraints — In-Browser Swactor Runtime
|
||||||
|
|
||||||
|
## Threading Model
|
||||||
|
|
||||||
|
- **wasm-threads is mandatory** — the runtime uses SharedArrayBuffer + WebAssembly atomics for multi-worker parallelism. There is no single-threaded degraded mode for MVP.
|
||||||
|
- Browsers must serve pages with COOP/COEP headers:
|
||||||
|
- `Cross-Origin-Opener-Policy: same-origin`
|
||||||
|
- `Cross-Origin-Embedder-Policy: require-corp`
|
||||||
|
- Build requires nightly Rust + `-Z build-std=std,panic_abort` + target features `+atomics,+bulk-memory,+mutable-globals`.
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
|
||||||
|
- **Platform abstractions live in core swactor** (`src/`), gated by `#[cfg(target_arch = "wasm32")]`. They do not belong in the wasm crate.
|
||||||
|
- **Do not add new modules** to `src/` — modify existing files only (TASK.md style rule).
|
||||||
|
- **Do not restructure** existing module boundaries. The abstraction is a thin layer (type aliases, cfg-gated imports), not a trait-based HAL.
|
||||||
|
- The browser crate (`crates/wasm-browser/` or evolved `crates/wasm/`) is a **thin wasm-bindgen shell**. All scheduling, routing, and actor logic stays in core Rust.
|
||||||
|
|
||||||
|
## Actor Model
|
||||||
|
|
||||||
|
- **Rust-only actors** — actors are written in Rust and compiled to wasm. JavaScript does not define actor behavior.
|
||||||
|
- JS interacts through the wasm-bindgen API: create runtime, spawn actors (by registered type), send messages, receive results.
|
||||||
|
- Actor types are registered at compile time via Rust generics, not dynamically from JS.
|
||||||
|
|
||||||
|
## Performance Priorities
|
||||||
|
|
||||||
|
- Maximize throughput: auto-scheduling via `setTimeout(0)` tight loop, not `requestAnimationFrame` (which caps at display refresh rate).
|
||||||
|
- Web Worker count defaults to `navigator.hardwareConcurrency` for full core utilization.
|
||||||
|
- Zero-copy where possible: SharedArrayBuffer eliminates serialization between workers.
|
||||||
|
- Minimize JS↔Wasm boundary crossings — batch operations where feasible.
|
||||||
|
|
||||||
|
## Feature Scope
|
||||||
|
|
||||||
|
- All core features that compile for wasm32: spawn, send, receive, tick, actor lifecycle, watching, extensions.
|
||||||
|
- swactor-std features (naming, groups, monitoring) should work if they compile.
|
||||||
|
- Transport: WebSocket adapter for distributed clusters. STUN/TURN (WebRTC) deferred to later.
|
||||||
|
- Features that require OS primitives not available in wasm (filesystem, raw TCP) are excluded.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Tests must pass on both native (`cargo test`) and wasm targets.
|
||||||
|
- Wasm tests use `wasm-pack test --headless --chrome` or Node.js with `--experimental-wasm-threads`.
|
||||||
|
- No test-only code paths that diverge native vs wasm behavior — if it works differently, it's a bug.
|
||||||
|
- Prefer scenario tests over structural tests (per project testing rules).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `web-time` — drop-in replacement for `std::time::Instant` on wasm32
|
||||||
|
- `wasm-bindgen` + `js-sys` + `web-sys` — browser API bindings (in the wasm crate only, not core)
|
||||||
|
- `gloo-timers` — optional, for ergonomic setTimeout/setInterval
|
||||||
|
- No new dependencies in core swactor beyond `web-time` (which is no-op on native)
|
||||||
|
|
||||||
|
## What We Don't Do
|
||||||
|
|
||||||
|
- No async/await runtime (tokio, async-std) — swactor is synchronous tick-based
|
||||||
|
- No Emscripten — target is `wasm32-unknown-unknown` only
|
||||||
|
- No WASI — browser environment, not server-side wasm
|
||||||
|
- No JS actor definitions — Rust only
|
||||||
|
- No polyfills for missing atomics — if SharedArrayBuffer isn't available, the runtime doesn't start
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Stage 1 — Platform Abstraction Layer
|
||||||
|
|
||||||
|
**Priority**: P0
|
||||||
|
**Depends on**: Nothing
|
||||||
|
**Enables**: All subsequent stages
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make core swactor compile for `wasm32-unknown-unknown` with `+atomics,+bulk-memory,+mutable-globals` target features. No behavioral changes on native targets. No new modules — only modify existing files with `cfg` gates.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### 1. Instant → web_time::Instant
|
||||||
|
|
||||||
|
**Files**: `src/runtime.rs`, `src/worker.rs`
|
||||||
|
|
||||||
|
Add `web-time` to `[dependencies]` (it's a no-op on non-wasm targets). Replace:
|
||||||
|
```rust
|
||||||
|
use std::time::Instant;
|
||||||
|
```
|
||||||
|
with:
|
||||||
|
```rust
|
||||||
|
use web_time::Instant;
|
||||||
|
```
|
||||||
|
|
||||||
|
`web-time` is a drop-in replacement. The `Instant` type has identical API on native (delegates to `std::time::Instant`) and on wasm32 (uses `performance.now()`).
|
||||||
|
|
||||||
|
**Scope**: 2 `use` statements, 0 logic changes.
|
||||||
|
|
||||||
|
### 2. Thread Parking → ParkHandle
|
||||||
|
|
||||||
|
**Files**: `src/runtime.rs`, `src/delivery.rs`, `src/worker.rs`
|
||||||
|
|
||||||
|
Currently uses `OnceLock<Thread>` + `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.
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
# Stage 2 — Single-Worker Browser Runtime
|
||||||
|
|
||||||
|
**Priority**: P0
|
||||||
|
**Depends on**: Stage 1 (platform abstraction)
|
||||||
|
**Enables**: Stage 3 (multi-worker), Stage 4 (feature parity)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A working browser runtime on a single dedicated Web Worker with a JS API that supports spawning arbitrary (pre-registered) actor types, sending messages, receiving results, and auto-scheduled ticking.
|
||||||
|
|
||||||
|
## What's Built
|
||||||
|
|
||||||
|
### 1. New Crate: `crates/wasm-browser/`
|
||||||
|
|
||||||
|
Replaces the PoC `crates/wasm/`. Structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/wasm-browser/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── src/
|
||||||
|
│ ├── lib.rs # wasm-bindgen entry point
|
||||||
|
│ ├── runtime.rs # BrowserRuntime wrapping swactor::Runtime
|
||||||
|
│ ├── worker_glue.rs # Web Worker spawn/communication glue
|
||||||
|
│ └── scheduling.rs # Auto-tick scheduling (setTimeout loop)
|
||||||
|
├── js/
|
||||||
|
│ ├── worker.js # Web Worker bootstrap script
|
||||||
|
│ └── index.js # Main thread API wrapper (optional)
|
||||||
|
└── tests/
|
||||||
|
└── browser.rs # wasm-pack test suite
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. BrowserRuntime (wasm-bindgen API)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct BrowserRuntime { ... }
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl BrowserRuntime {
|
||||||
|
#[wasm_bindgen(constructor)]
|
||||||
|
pub fn new(config: JsValue) -> Self;
|
||||||
|
|
||||||
|
/// Spawn an actor by type name. Returns an opaque handle.
|
||||||
|
pub fn spawn(&mut self, type_name: &str, init: JsValue) -> JsValue;
|
||||||
|
|
||||||
|
/// Send a message to an actor.
|
||||||
|
pub fn send(&self, addr: JsValue, msg: JsValue) -> bool;
|
||||||
|
|
||||||
|
/// Drive one tick manually.
|
||||||
|
pub fn tick(&self);
|
||||||
|
|
||||||
|
/// Start auto-scheduling. Calls tick() in a tight setTimeout(0) loop.
|
||||||
|
pub fn start(&self);
|
||||||
|
|
||||||
|
/// Stop auto-scheduling.
|
||||||
|
pub fn stop(&self);
|
||||||
|
|
||||||
|
/// Poll for results from a JS-visible inbox.
|
||||||
|
pub fn try_recv(&self) -> JsValue;
|
||||||
|
|
||||||
|
/// Runtime stats snapshot.
|
||||||
|
pub fn stats(&self) -> JsValue;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Actor Registration
|
||||||
|
|
||||||
|
Since Rust generics can't be dynamically dispatched from JS, actor types are registered at compile time:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In the user's wasm crate that depends on wasm-browser:
|
||||||
|
register_actors! {
|
||||||
|
"counter" => Counter,
|
||||||
|
"relay" => Relay,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This macro generates a factory map that `BrowserRuntime::spawn` indexes by string name. Each entry knows how to deserialize `JsValue` init args into the actor's constructor.
|
||||||
|
|
||||||
|
### 4. Auto-Scheduling
|
||||||
|
|
||||||
|
The runtime self-drives via a `setTimeout(0)` loop:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function tickLoop() {
|
||||||
|
runtime.tick();
|
||||||
|
if (runtime.is_running()) {
|
||||||
|
setTimeout(tickLoop, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs as fast as the browser allows (~4ms between ticks in most browsers, faster in Web Workers). The Rust side just calls `tick()` — no async runtime needed.
|
||||||
|
|
||||||
|
### 5. Message Serialization
|
||||||
|
|
||||||
|
JS ↔ Wasm boundary requires serialization. Options:
|
||||||
|
- **serde-wasm-bindgen**: Serialize Rust types to/from JsValue via serde. Zero-copy for simple types.
|
||||||
|
- **Manual**: Convert JsValue to bytes, route as `ByteMessage`.
|
||||||
|
|
||||||
|
For Stage 2, use `serde-wasm-bindgen` for typed messages. Actor `Incoming` types must implement `serde::Deserialize`.
|
||||||
|
|
||||||
|
### 6. Single Worker Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ postMessage ┌──────────────────────┐
|
||||||
|
│ Main Thread │ ◄──────────────────────► │ Web Worker │
|
||||||
|
│ │ │ │
|
||||||
|
│ JS application │ "spawn", "send", │ BrowserRuntime │
|
||||||
|
│ calls API methods │ "tick", "recv" │ swactor::Runtime │
|
||||||
|
│ │ │ (1 worker, tick()) │
|
||||||
|
└─────────────────────┘ └──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The Web Worker runs the swactor runtime. The main thread sends commands via `postMessage`. This keeps the UI thread free.
|
||||||
|
|
||||||
|
Alternative: run everything on the main thread (simpler, but blocks UI during tick). Support both modes — the user picks.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `wasm-pack build --target web` succeeds
|
||||||
|
2. `wasm-pack test --headless --chrome` passes
|
||||||
|
3. Manual test: HTML page spawns actors, sends messages, receives results
|
||||||
|
4. Auto-scheduling: actors process messages continuously without manual tick calls
|
||||||
|
5. Performance: measure ticks/sec, compare to native single-threaded
|
||||||
|
|
||||||
|
## Estimated Scope
|
||||||
|
|
||||||
|
~300-500 lines of Rust + ~50 lines of JS glue.
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Stage 3 — Multi-Worker Parallelism
|
||||||
|
|
||||||
|
**Priority**: P1
|
||||||
|
**Depends on**: Stage 1 (platform abstraction), Stage 2 (single-worker browser)
|
||||||
|
**Enables**: Stage 5 (transport)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Spawn N Web Workers sharing the same swactor runtime via SharedArrayBuffer. Actors distributed across workers for true multi-core parallelism. Worker count configurable, defaults to `navigator.hardwareConcurrency`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ Main Thread │ postMessage API
|
||||||
|
│ (JS app) │◄─────────────────────┐
|
||||||
|
└──────┬───────┘ │
|
||||||
|
│ spawn workers │
|
||||||
|
▼ │
|
||||||
|
┌──────────────┐ SharedArrayBuffer ┌──────────────┐
|
||||||
|
│ Web Worker 0 │◄───────────────────►│ Web Worker 1 │
|
||||||
|
│ swactor │ (InboxRegistry, │ swactor │
|
||||||
|
│ Worker #0 │ AddressMap, │ Worker #1 │
|
||||||
|
│ tick loop │ transfer queues, │ tick loop │
|
||||||
|
└──────────────┘ atomics) └──────────────┘
|
||||||
|
...
|
||||||
|
┌──────────────┐
|
||||||
|
│ Web Worker N │
|
||||||
|
│ swactor │
|
||||||
|
│ Worker #N │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Challenge: Web Worker ↔ SharedArrayBuffer
|
||||||
|
|
||||||
|
Web Workers can share `SharedArrayBuffer` instances. The swactor `Runtime` struct contains `Arc`-wrapped shared state (InboxRegistry, AddressMap, etc.). On native, this memory is shared via the process address space. On wasm with SharedArrayBuffer, it's shared via the underlying wasm linear memory.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Main thread** creates the `Runtime` (allocates shared structures in wasm linear memory)
|
||||||
|
2. **Main thread** spawns N Web Workers, each loading the same `.wasm` module with `shared: true` memory
|
||||||
|
3. Each Web Worker receives a pointer (offset) to the `Runtime` shared state
|
||||||
|
4. Each Worker runs `worker.run(&tc, &is_running)` — the same tick loop as native
|
||||||
|
5. Crossbeam queues, atomics, Mutex/RwLock all work because the underlying memory is shared
|
||||||
|
|
||||||
|
### wasm-bindgen + Web Workers
|
||||||
|
|
||||||
|
The `web-sys::Worker` API creates workers. Each worker loads the same wasm module:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// worker.js (loaded by each Web Worker)
|
||||||
|
import init, { worker_entry } from './pkg/wasm_browser.js';
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const { module, memory, worker_id, runtime_ptr } = e.data;
|
||||||
|
await init({ module, memory }); // shared memory!
|
||||||
|
worker_entry(worker_id, runtime_ptr);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The Rust side:
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn worker_entry(worker_id: usize, runtime_ptr: u32) {
|
||||||
|
// Reconstruct the shared Runtime reference from the raw pointer
|
||||||
|
// Run the tick loop for this worker
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory Sharing
|
||||||
|
|
||||||
|
With `wasm-threads`, the wasm linear memory is backed by a `SharedArrayBuffer`. All workers see the same memory. `Arc` increments are atomic operations on shared memory. Crossbeam queues use atomic compare-and-swap on shared memory. This is identical to how it works with OS threads.
|
||||||
|
|
||||||
|
**Critical**: The wasm module must be compiled with `--shared-memory` and the `atomics` feature. The `Memory` import must use `shared: true`.
|
||||||
|
|
||||||
|
## Park / Unpark
|
||||||
|
|
||||||
|
The `ParkHandle` from Stage 1 uses `memory_atomic_wait32` / `memory_atomic_notify` — the wasm equivalents of futex. These work across Web Workers sharing the same memory.
|
||||||
|
|
||||||
|
- `park_timeout_us(micros)` → `Atomics.wait(ptr, expected, timeout)` — blocks the Worker thread
|
||||||
|
- `unpark(handle)` → `Atomics.notify(ptr, 1)` — wakes one waiting Worker
|
||||||
|
|
||||||
|
This gives the same backoff behavior as native: hot spin → yield → sleep with exponential backoff.
|
||||||
|
|
||||||
|
## Worker Spawning
|
||||||
|
|
||||||
|
Replace `std::thread::Builder::new().spawn()` in `Runtime::run()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn run(self) -> Result<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).
|
||||||
109
big-feature-phase/notes/feature-stages/04-feature-parity.md
Normal file
109
big-feature-phase/notes/feature-stages/04-feature-parity.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Stage 4 — Feature Parity
|
||||||
|
|
||||||
|
**Priority**: P1
|
||||||
|
**Depends on**: Stage 2 (single-worker) or Stage 3 (multi-worker)
|
||||||
|
**Enables**: Stage 5 (transport), Stage 6 (DX)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
All swactor features that are feasible in a browser environment work and are tested: actor watching, swactor-std extensions (naming, groups, monitoring), stats introspection.
|
||||||
|
|
||||||
|
## Features to Enable
|
||||||
|
|
||||||
|
### 1. Actor Watching / Death Notifications
|
||||||
|
|
||||||
|
**Status**: Already in core (`src/runtime.rs`, `src/worker.rs`)
|
||||||
|
|
||||||
|
Components:
|
||||||
|
- `ExitReason` enum — normal, panic, stopped
|
||||||
|
- `ActorExited` message — delivered to watchers
|
||||||
|
- `on_actor_exit()` default method on `ActorInterface`
|
||||||
|
- `WatchRegistry` — tracks who watches whom
|
||||||
|
- Phase 5b in `tick_once` — delivers death notifications
|
||||||
|
|
||||||
|
**Wasm concern**: `WatchRegistry` is behind `Arc<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.
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Stage 5 — Transport Foundation
|
||||||
|
|
||||||
|
**Priority**: P2
|
||||||
|
**Depends on**: Stage 3 (multi-worker), Stage 4 (feature parity)
|
||||||
|
**Enables**: Browser nodes joining distributed swactor clusters
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Browser nodes connect to native swactor clusters via WebSocket. A browser can spawn actors that communicate with actors on server nodes. Foundation for future STUN/TURN (WebRTC DataChannel) for browser-to-browser direct connections.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ WebSocket ┌──────────────────┐
|
||||||
|
│ Browser Node │ ◄────────────────────────► │ Server Node │
|
||||||
|
│ (wasm runtime) │ │ (native runtime)│
|
||||||
|
│ │ swactor wire protocol │ │
|
||||||
|
│ Actor A ──────►─┤───── msg for Actor B ─────►├──► Actor B │
|
||||||
|
│ │ │ │
|
||||||
|
│ Actor C ◄───────┤◄──── msg for Actor C ─────┤───── Actor D │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing Transport Infrastructure
|
||||||
|
|
||||||
|
swactor already has a transport layer (feature-gated under `transport`):
|
||||||
|
|
||||||
|
- `src/transport.rs` — `TransportRouter`, `CodecRegistry`, remote message routing
|
||||||
|
- `crates/distribution/` — SWIM protocol, gossip, cluster membership
|
||||||
|
- `crates/distribution/src/driver.rs` — `NodeDriver` bridges `DistributedNode` ↔ TCP
|
||||||
|
- Wire protocol: Ping/Ack/PingReq with piggyback bytes
|
||||||
|
|
||||||
|
The browser transport needs to implement the same wire protocol over WebSocket instead of raw TCP.
|
||||||
|
|
||||||
|
## What's Built
|
||||||
|
|
||||||
|
### 1. WebSocket Transport Adapter
|
||||||
|
|
||||||
|
A new module in `crates/wasm-browser/` (not in core):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct WebSocketTransport {
|
||||||
|
ws: web_sys::WebSocket,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportAdapter for WebSocketTransport {
|
||||||
|
fn send(&self, dest: SocketAddr, data: &[u8]) -> Result<(), Error>;
|
||||||
|
fn recv(&self) -> Option<(SocketAddr, Vec<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.
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Stage 6 — Developer Experience
|
||||||
|
|
||||||
|
**Priority**: P3
|
||||||
|
**Depends on**: Stage 2 (single-worker browser), Stage 4 (feature parity)
|
||||||
|
**Enables**: Adoption, ecosystem growth
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make it easy for Rust developers to build browser applications with swactor. TypeScript type safety, build tooling, and debugging support.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. TypeScript Type Generation
|
||||||
|
|
||||||
|
Derive TypeScript interfaces from Rust actor message types. When an actor defines:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub from: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate:
|
||||||
|
```typescript
|
||||||
|
export interface ChatMessage {
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Approach**: Use `ts-rs` crate or a custom proc macro that emits `.d.ts` files during `wasm-pack build`. This gives TypeScript consumers compile-time type checking for messages.
|
||||||
|
|
||||||
|
### 2. Build Tooling
|
||||||
|
|
||||||
|
A `swactor-build` CLI or build script that wraps:
|
||||||
|
```bash
|
||||||
|
RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals' \
|
||||||
|
cargo +nightly build --target wasm32-unknown-unknown \
|
||||||
|
-Z build-std=std,panic_abort \
|
||||||
|
--release
|
||||||
|
wasm-bindgen --target web --out-dir pkg/ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Into:
|
||||||
|
```bash
|
||||||
|
swactor-build --target browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Detects nightly toolchain, installs if missing
|
||||||
|
- Sets correct RUSTFLAGS for wasm-threads
|
||||||
|
- Runs wasm-bindgen with correct target
|
||||||
|
- Copies worker.js bootstrap into output
|
||||||
|
- Generates example HTML with correct COOP/COEP headers
|
||||||
|
|
||||||
|
### 3. Example Project Template
|
||||||
|
|
||||||
|
A `cargo generate` template or example project:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-swactor-app/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── src/
|
||||||
|
│ └── lib.rs # Define actors, register them
|
||||||
|
├── web/
|
||||||
|
│ ├── index.html # With COOP/COEP headers
|
||||||
|
│ ├── main.js # Import wasm, create runtime, interact
|
||||||
|
│ └── worker.js # Web Worker bootstrap (auto-generated)
|
||||||
|
└── tests/
|
||||||
|
└── browser.rs # wasm-pack tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Browser Dev Tools Integration
|
||||||
|
|
||||||
|
Expose runtime internals for debugging:
|
||||||
|
|
||||||
|
- **Actor Inspector**: List all actors, their types, mailbox depths, message counts
|
||||||
|
- **Message Tracer**: Log messages between actors (opt-in, performance impact)
|
||||||
|
- **Performance Monitor**: Ticks/sec, messages/sec, worker utilization
|
||||||
|
|
||||||
|
Implementation: A `console`-based logger that uses `web-sys::console` to output structured data. Optionally integrates with browser DevTools via custom formatters or a small React/Preact inspector panel.
|
||||||
|
|
||||||
|
### 5. Documentation
|
||||||
|
|
||||||
|
- Getting started guide
|
||||||
|
- Architecture overview for browser runtime
|
||||||
|
- Migration guide from native → browser (what works, what doesn't)
|
||||||
|
- API reference (generated from Rust doc comments)
|
||||||
|
- Example: Chat application with multiple browser tabs
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. TypeScript types match Rust types (compile TS project against generated `.d.ts`)
|
||||||
|
2. Build tool produces working wasm output from example project
|
||||||
|
3. Template project builds and runs out of the box
|
||||||
|
4. Dev tools show actor state in browser console
|
||||||
|
|
||||||
|
## Estimated Scope
|
||||||
|
|
||||||
|
Variable — this stage is a collection of independent DX improvements. Each can be implemented and shipped independently. Total: ~500-1000 lines across Rust, JS, and documentation.
|
||||||
7
big-feature-phase/notes/history.md
Normal file
7
big-feature-phase/notes/history.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# 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.
|
||||||
87
big-feature-phase/notes/research_synthesis.md
Normal file
87
big-feature-phase/notes/research_synthesis.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Research Synthesis — In-Browser Swactor Runtime
|
||||||
|
|
||||||
|
## Ecosystem Landscape
|
||||||
|
|
||||||
|
No established Rust actor framework runs natively in browsers. The closest projects:
|
||||||
|
|
||||||
|
- **Lunatic** — Erlang-inspired Wasm actor runtime using wasmtime (server-side, not browser). Uses preemptive scheduling and work-stealing. Not applicable to browser constraints.
|
||||||
|
- **wasmCloud** — CNCF distributed actor platform. Single-threaded actors, NATS-backed lattice. Cloud/edge focus, no browser target.
|
||||||
|
- **Actix** — Tokio-dependent, network stack doesn't compile for wasm32. Community attempts to port failed due to `net2`/tokio dependencies.
|
||||||
|
|
||||||
|
**Implication**: swactor would be the first Rust actor runtime with true multi-threaded browser execution via wasm-threads. This is a differentiated position.
|
||||||
|
|
||||||
|
## Existing Work in This Codebase
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `crates/wasm/` | Basic PoC | Hardcoded Counter/Relay actors, manual tick, u32-only messages |
|
||||||
|
| `no_random` feature | Working | Deterministic address generation without `getrandom` |
|
||||||
|
| `tick()` method | Working | Single-threaded tick for manual driving |
|
||||||
|
| Actor watching | In core | `ExitReason`, `ActorExited`, `on_actor_exit`, `WatchRegistry` |
|
||||||
|
| swactor-std | Complete | StdExtension, naming, groups, monitoring, supervisor |
|
||||||
|
|
||||||
|
## Platform Dependencies Analysis
|
||||||
|
|
||||||
|
### Works as-is with wasm-threads
|
||||||
|
- `crossbeam-queue` (ArrayQueue, SegQueue) — uses `core::sync::atomic`
|
||||||
|
- `std::sync::{Mutex, RwLock}` — stdlib uses futex on wasm with atomics
|
||||||
|
- `std::sync::atomic::*` — maps to wasm atomic instructions
|
||||||
|
- `Arc<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. |
|
||||||
47
big-feature-phase/notes/state.md
Normal file
47
big-feature-phase/notes/state.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
## 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,8 +5,9 @@ edition = "2024"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["getrandom"]
|
default = ["getrandom"]
|
||||||
getrandom = ["dep:getrandom"]
|
getrandom = ["dep:getrandom", "swactor/getrandom"]
|
||||||
|
wasm = ["swactor/wasm"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
swactor = { path = "../.." }
|
swactor = { path = "../..", default-features = false }
|
||||||
getrandom = { version = "0.2", optional = true }
|
getrandom = { version = "0.2", optional = true }
|
||||||
|
|
|
||||||
|
|
@ -102,11 +102,21 @@ impl<M: Message> Router<M> {
|
||||||
Some(live[idx])
|
Some(live[idx])
|
||||||
}
|
}
|
||||||
RoutingStrategy::Random => {
|
RoutingStrategy::Random => {
|
||||||
|
#[cfg(feature = "getrandom")]
|
||||||
|
{
|
||||||
let mut buf = [0u8; 8];
|
let mut buf = [0u8; 8];
|
||||||
getrandom::getrandom(&mut buf).expect("getrandom failed");
|
getrandom::getrandom(&mut buf).expect("getrandom failed");
|
||||||
let r = u64::from_ne_bytes(buf) as usize;
|
let r = u64::from_ne_bytes(buf) as usize;
|
||||||
Some(live[r % live.len()])
|
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
|
RoutingStrategy::Broadcast => None, // handled separately
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ fn get_ext(rt: &Runtime) -> &StdExtension {
|
||||||
/// Provides `spawn_named`, `where_is`, `unregister`, and `registered_names`
|
/// Provides `spawn_named`, `where_is`, `unregister`, and `registered_names`
|
||||||
/// via the [`StdExtension`] name registry.
|
/// via the [`StdExtension`] name registry.
|
||||||
pub trait RuntimeNaming {
|
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.
|
/// 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>;
|
fn spawn_named<A: ActorInterface>(&self, name: impl Into<String>, actor: A) -> Result<ActorAddress, Error>;
|
||||||
|
|
||||||
|
|
@ -31,6 +34,10 @@ pub trait RuntimeNaming {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeNaming for Runtime {
|
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> {
|
fn spawn_named<A: ActorInterface>(&self, name: impl Into<String>, actor: A) -> Result<ActorAddress, Error> {
|
||||||
let name = name.into();
|
let name = name.into();
|
||||||
let addr = self.spawn(actor)?;
|
let addr = self.spawn(actor)?;
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,6 @@ edition = "2024"
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
swactor = { path = "../..", default-features = false, features = ["no_random"] }
|
swactor = { path = "../..", default-features = false, features = ["wasm"] }
|
||||||
|
swactor-std = { path = "../std", default-features = false, features = ["wasm"] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
|
|
||||||
570
crates/wasm/demo.html
Normal file
570
crates/wasm/demo.html
Normal file
|
|
@ -0,0 +1,570 @@
|
||||||
|
<!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,11 +1,240 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use swactor::actor::{ActorAddress, ActorInterface};
|
use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
|
||||||
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig};
|
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig};
|
||||||
|
use swactor_std::{CtxGroups, RuntimeNaming, RuntimeGroups, StdExtension};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ─── Core JS-facing types ───────────────────────────────────────────────────
|
||||||
// Actors (private — only exposed through the wasm API)
|
|
||||||
// ---------------------------------------------------------------------------
|
/// 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.
|
||||||
|
|
||||||
struct Counter {
|
struct Counter {
|
||||||
total: u32,
|
total: u32,
|
||||||
|
|
@ -35,79 +264,100 @@ impl ActorInterface for Relay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/// A sentinel actor that watches a target and reports its death to an inbox.
|
||||||
// JS-facing runtime wrapper
|
///
|
||||||
// ---------------------------------------------------------------------------
|
/// Uses the std monitoring extension (CtxMonitoring::monitor). When the target
|
||||||
|
/// dies, the sentinel receives a `Down` message and sends the dead actor's
|
||||||
|
/// string representation to the report inbox, then stops itself.
|
||||||
|
struct Sentinel {
|
||||||
|
target: ActorAddress,
|
||||||
|
report_to: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for Sentinel {
|
||||||
|
type Incoming = ();
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn handle(&mut self, _ctx: &Ctx, _msg: ()) {}
|
||||||
|
|
||||||
|
fn on_start(&mut self, ctx: &Ctx) {
|
||||||
|
ctx.watch(self.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_actor_exit(&mut self, ctx: &Ctx, exited: ActorExited) {
|
||||||
|
let msg = format!("{}:{:?}", exited.addr, exited.reason);
|
||||||
|
let _ = ctx.send(self.report_to, msg);
|
||||||
|
ctx.stop_self();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group member that joins a named group and forwards u32 messages to a report inbox.
|
||||||
|
struct GroupMember {
|
||||||
|
group: String,
|
||||||
|
report_to: ActorAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActorInterface for GroupMember {
|
||||||
|
type Incoming = u32;
|
||||||
|
type Response = ();
|
||||||
|
|
||||||
|
fn on_start(&mut self, ctx: &Ctx) {
|
||||||
|
ctx.join_group(self.group.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&mut self, ctx: &Ctx, msg: u32) {
|
||||||
|
let _ = ctx.send(self.report_to, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a counter that accumulates u32 values and reports running totals
|
||||||
|
/// to the given inbox address.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct SwactorRuntime {
|
pub fn spawn_counter(rt: &WasmRuntime, report_to: &WasmAddr) -> WasmAddr {
|
||||||
rt: Runtime,
|
let addr = rt
|
||||||
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
|
.rt
|
||||||
.spawn(Counter {
|
.spawn(Counter {
|
||||||
total: 0,
|
total: 0,
|
||||||
report_to: *self.inbox.addr(),
|
report_to: report_to.0,
|
||||||
})
|
})
|
||||||
.expect("spawn counter");
|
.expect("spawn counter");
|
||||||
let idx = self.actors.len();
|
WasmAddr(addr)
|
||||||
self.actors.push(addr);
|
|
||||||
idx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a relay that forwards every message to `target_idx`.
|
/// Spawn a relay that forwards every u32 message to the target actor.
|
||||||
pub fn spawn_relay(&mut self, target_idx: usize) -> usize {
|
#[wasm_bindgen]
|
||||||
let target = self.actors[target_idx];
|
pub fn spawn_relay(rt: &WasmRuntime, target: &WasmAddr) -> WasmAddr {
|
||||||
let addr = self
|
let addr = rt
|
||||||
.rt
|
.rt
|
||||||
.spawn(Relay { target })
|
.spawn(Relay { target: target.0 })
|
||||||
.expect("spawn relay");
|
.expect("spawn relay");
|
||||||
let idx = self.actors.len();
|
WasmAddr(addr)
|
||||||
self.actors.push(addr);
|
|
||||||
idx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a u32 to the actor at `actor_idx`.
|
/// Spawn a sentinel that watches a target actor and reports its death
|
||||||
pub fn send(&self, actor_idx: usize, value: u32) -> bool {
|
/// to the given string inbox.
|
||||||
if actor_idx >= self.actors.len() {
|
#[wasm_bindgen]
|
||||||
return false;
|
pub fn spawn_sentinel(rt: &WasmRuntime, target: &WasmAddr, report_to: &WasmInboxString) -> WasmAddr {
|
||||||
}
|
let addr = rt
|
||||||
self.rt.send_to(self.actors[actor_idx], value).is_ok()
|
.rt
|
||||||
|
.spawn(Sentinel {
|
||||||
|
target: target.0,
|
||||||
|
report_to: *report_to.inner.addr(),
|
||||||
|
})
|
||||||
|
.expect("spawn sentinel");
|
||||||
|
WasmAddr(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive one tick of the single-threaded runtime.
|
/// Spawn a group member that joins the given group and forwards u32 messages
|
||||||
pub fn tick(&self) {
|
/// to the report inbox.
|
||||||
self.rt.tick();
|
#[wasm_bindgen]
|
||||||
}
|
pub fn spawn_group_member(rt: &WasmRuntime, group: &str, report_to: &WasmAddr) -> WasmAddr {
|
||||||
|
let addr = rt
|
||||||
/// Try to read the next result from the inbox. Returns `undefined` when empty.
|
.rt
|
||||||
pub fn try_recv(&self) -> Option<u32> {
|
.spawn(GroupMember {
|
||||||
self.inbox.try_recv()
|
group: group.to_string(),
|
||||||
}
|
report_to: report_to.0,
|
||||||
|
})
|
||||||
/// Number of actors the runtime knows about.
|
.expect("spawn group_member");
|
||||||
pub fn actor_count(&self) -> usize {
|
WasmAddr(addr)
|
||||||
self.rt.stats().actors.len()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import { SwactorRuntime } from "./pkg/swactor_wasm.js";
|
import {
|
||||||
|
WasmRuntime,
|
||||||
|
WasmAddr,
|
||||||
|
spawn_counter,
|
||||||
|
spawn_relay,
|
||||||
|
spawn_sentinel,
|
||||||
|
spawn_group_member,
|
||||||
|
} from "./pkg/wasm.js";
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
@ -23,77 +30,314 @@ function assertEq(a, b, msg) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drain(rt) {
|
function drainInbox(inbox) {
|
||||||
const results = [];
|
const results = [];
|
||||||
let v;
|
let v;
|
||||||
while ((v = rt.try_recv()) !== undefined) results.push(v);
|
while ((v = inbox.try_recv()) !== undefined) results.push(v);
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- accumulator ----------------------------------------------------------
|
// ---- accumulator ----------------------------------------------------------
|
||||||
{
|
{
|
||||||
console.log("test: accumulator processes messages");
|
console.log("test: accumulator processes messages via WasmAddr");
|
||||||
const rt = new SwactorRuntime();
|
const rt = new WasmRuntime();
|
||||||
const c = rt.spawn_counter();
|
const inbox = rt.new_inbox_u32();
|
||||||
rt.send(c, 1);
|
const c = spawn_counter(rt, inbox.addr());
|
||||||
rt.send(c, 2);
|
rt.send_u32(c, 1);
|
||||||
rt.send(c, 10);
|
rt.send_u32(c, 2);
|
||||||
|
rt.send_u32(c, 10);
|
||||||
rt.tick();
|
rt.tick();
|
||||||
assertEq(drain(rt), [1, 3, 13], "running totals");
|
assertEq(drainInbox(inbox), [1, 3, 13], "running totals");
|
||||||
|
inbox.free();
|
||||||
rt.free();
|
rt.free();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- relay ----------------------------------------------------------------
|
// ---- relay ----------------------------------------------------------------
|
||||||
{
|
{
|
||||||
console.log("test: relay forwards to counter");
|
console.log("test: relay forwards to counter");
|
||||||
const rt = new SwactorRuntime();
|
const rt = new WasmRuntime();
|
||||||
const c = rt.spawn_counter();
|
const inbox = rt.new_inbox_u32();
|
||||||
const r = rt.spawn_relay(c);
|
const c = spawn_counter(rt, inbox.addr());
|
||||||
rt.send(r, 5);
|
const r = spawn_relay(rt, c);
|
||||||
rt.send(r, 7);
|
rt.send_u32(r, 5);
|
||||||
// tick 1: relay receives and forwards (cross-actor, same worker → pending_local)
|
rt.send_u32(r, 7);
|
||||||
|
// tick 1: relay receives and forwards (same worker → pending_local)
|
||||||
// tick 2: counter receives forwarded messages
|
// tick 2: counter receives forwarded messages
|
||||||
rt.tick();
|
rt.tick();
|
||||||
rt.tick();
|
rt.tick();
|
||||||
assertEq(drain(rt), [5, 12], "relayed totals");
|
assertEq(drainInbox(inbox), [5, 12], "relayed totals");
|
||||||
|
inbox.free();
|
||||||
rt.free();
|
rt.free();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- multiple counters ----------------------------------------------------
|
// ---- multiple counters ----------------------------------------------------
|
||||||
{
|
{
|
||||||
console.log("test: multiple independent counters");
|
console.log("test: multiple independent counters");
|
||||||
const rt = new SwactorRuntime();
|
const rt = new WasmRuntime();
|
||||||
const a = rt.spawn_counter();
|
const inbox = rt.new_inbox_u32();
|
||||||
const b = rt.spawn_counter();
|
const a = spawn_counter(rt, inbox.addr());
|
||||||
rt.send(a, 10);
|
const b = spawn_counter(rt, inbox.addr());
|
||||||
rt.send(b, 100);
|
rt.send_u32(a, 10);
|
||||||
|
rt.send_u32(b, 100);
|
||||||
rt.tick();
|
rt.tick();
|
||||||
const results = drain(rt);
|
const results = drainInbox(inbox);
|
||||||
// order depends on HashMap iteration, so just check set equality
|
|
||||||
assert(
|
assert(
|
||||||
results.includes(10) && results.includes(100) && results.length === 2,
|
results.includes(10) && results.includes(100) && results.length === 2,
|
||||||
"both counters report"
|
"both counters report"
|
||||||
);
|
);
|
||||||
|
inbox.free();
|
||||||
rt.free();
|
rt.free();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- actor_count ----------------------------------------------------------
|
// ---- actor_count ----------------------------------------------------------
|
||||||
{
|
{
|
||||||
console.log("test: actor_count tracks spawns");
|
console.log("test: actor_count tracks spawns");
|
||||||
const rt = new SwactorRuntime();
|
const rt = new WasmRuntime();
|
||||||
rt.spawn_counter();
|
const inbox = rt.new_inbox_u32();
|
||||||
rt.spawn_counter();
|
spawn_counter(rt, inbox.addr());
|
||||||
rt.spawn_counter();
|
spawn_counter(rt, inbox.addr());
|
||||||
|
spawn_counter(rt, inbox.addr());
|
||||||
rt.tick(); // drain spawn queue
|
rt.tick(); // drain spawn queue
|
||||||
assertEq(rt.actor_count(), 3, "three actors");
|
assertEq(rt.actor_count(), 3, "three actors");
|
||||||
|
inbox.free();
|
||||||
rt.free();
|
rt.free();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- send to invalid index returns false ----------------------------------
|
// ---- WasmAddr toString ----------------------------------------------------
|
||||||
{
|
{
|
||||||
console.log("test: send to bad index returns false");
|
console.log("test: WasmAddr has string representation");
|
||||||
const rt = new SwactorRuntime();
|
const rt = new WasmRuntime();
|
||||||
assert(!rt.send(999, 1), "out-of-bounds send");
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const addr = spawn_counter(rt, inbox.addr());
|
||||||
|
const s = addr.toString();
|
||||||
|
// no_random generates deterministic addresses — just check it's a non-empty hex string
|
||||||
|
assert(typeof s === "string" && s.length > 0, "addr toString is non-empty string");
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stop_actor -----------------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: stop_actor removes actor");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const c = spawn_counter(rt, inbox.addr());
|
||||||
|
rt.tick(); // drain spawn
|
||||||
|
assertEq(rt.actor_count(), 1, "one actor before stop");
|
||||||
|
rt.stop_actor(c);
|
||||||
|
rt.tick(); // process stop + cleanup
|
||||||
|
assertEq(rt.actor_count(), 0, "zero actors after stop");
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bytes inbox ----------------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: byte inbox receives Uint8Array");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_bytes();
|
||||||
|
// Send bytes directly (no actor — just to the inbox address)
|
||||||
|
rt.send_bytes(inbox.addr(), new Uint8Array([1, 2, 3]));
|
||||||
|
rt.tick();
|
||||||
|
const result = inbox.try_recv();
|
||||||
|
assert(result instanceof Uint8Array, "result is Uint8Array");
|
||||||
|
assertEq(Array.from(result), [1, 2, 3], "bytes match");
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- uptime ---------------------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: uptime_ms returns a number");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const uptime = rt.uptime_ms();
|
||||||
|
assert(typeof uptime === "number" && uptime >= 0, "uptime is non-negative number");
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- naming: register and resolve -----------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: naming — register_name and where_is");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const c = spawn_counter(rt, inbox.addr());
|
||||||
|
rt.tick(); // drain spawn
|
||||||
|
|
||||||
|
const ok = rt.register_name("my_counter", c);
|
||||||
|
assert(ok, "register_name succeeds");
|
||||||
|
|
||||||
|
const found = rt.where_is("my_counter");
|
||||||
|
assert(found !== undefined, "where_is finds registered actor");
|
||||||
|
assertEq(found.toString(), c.toString(), "where_is returns correct address");
|
||||||
|
|
||||||
|
const notFound = rt.where_is("nonexistent");
|
||||||
|
assert(notFound === undefined, "where_is returns undefined for unknown name");
|
||||||
|
|
||||||
|
found.free();
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- naming: unregister ---------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: naming — unregister_name");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const c = spawn_counter(rt, inbox.addr());
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
rt.register_name("temp", c);
|
||||||
|
const prev = rt.unregister_name("temp");
|
||||||
|
assert(prev !== undefined, "unregister returns previous address");
|
||||||
|
assertEq(prev.toString(), c.toString(), "unregister returns correct address");
|
||||||
|
|
||||||
|
const gone = rt.where_is("temp");
|
||||||
|
assert(gone === undefined, "name no longer resolves after unregister");
|
||||||
|
|
||||||
|
prev.free();
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- naming: registered_names ---------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: naming — registered_names");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const a = spawn_counter(rt, inbox.addr());
|
||||||
|
const b = spawn_counter(rt, inbox.addr());
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
rt.register_name("alpha", a);
|
||||||
|
rt.register_name("beta", b);
|
||||||
|
const names = rt.registered_names().split(",").sort();
|
||||||
|
assertEq(names, ["alpha", "beta"], "registered_names lists all names");
|
||||||
|
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- naming: duplicate name rejected --------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: naming — duplicate name rejected");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const a = spawn_counter(rt, inbox.addr());
|
||||||
|
const b = spawn_counter(rt, inbox.addr());
|
||||||
|
rt.tick();
|
||||||
|
|
||||||
|
const ok1 = rt.register_name("unique", a);
|
||||||
|
const ok2 = rt.register_name("unique", b);
|
||||||
|
assert(ok1, "first registration succeeds");
|
||||||
|
assert(!ok2, "duplicate registration fails");
|
||||||
|
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- groups: join and broadcast -------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: groups — join_group and publish_to_group_u32");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const a = spawn_group_member(rt, "workers", inbox.addr());
|
||||||
|
const b = spawn_group_member(rt, "workers", inbox.addr());
|
||||||
|
rt.tick(); // spawn + on_start (join group)
|
||||||
|
|
||||||
|
assertEq(rt.group_member_count("workers"), 2, "two members in group");
|
||||||
|
|
||||||
|
rt.publish_to_group_u32("workers", 42);
|
||||||
|
rt.tick(); // group members receive
|
||||||
|
rt.tick(); // group members forward to inbox
|
||||||
|
|
||||||
|
const results = drainInbox(inbox);
|
||||||
|
assertEq(results.length, 2, "both members received broadcast");
|
||||||
|
assert(results.every((v) => v === 42), "correct value broadcast");
|
||||||
|
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- groups: leave --------------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: groups — leave_group");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const a = spawn_group_member(rt, "pool", inbox.addr());
|
||||||
|
const b = spawn_group_member(rt, "pool", inbox.addr());
|
||||||
|
rt.tick(); // spawn + on_start
|
||||||
|
|
||||||
|
assertEq(rt.group_member_count("pool"), 2, "two members before leave");
|
||||||
|
rt.leave_group(a, "pool");
|
||||||
|
assertEq(rt.group_member_count("pool"), 1, "one member after leave");
|
||||||
|
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- groups: group_names --------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: groups — group_names");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
spawn_group_member(rt, "alpha", inbox.addr());
|
||||||
|
spawn_group_member(rt, "beta", inbox.addr());
|
||||||
|
rt.tick(); // spawn + join
|
||||||
|
|
||||||
|
const names = rt.group_names().split(",").sort();
|
||||||
|
assertEq(names, ["alpha", "beta"], "group_names lists all groups");
|
||||||
|
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- watching: sentinel detects death -------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: watching — sentinel reports actor death");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const deathInbox = rt.new_inbox_string();
|
||||||
|
|
||||||
|
const target = spawn_counter(rt, inbox.addr());
|
||||||
|
const sentinel = spawn_sentinel(rt, target, deathInbox);
|
||||||
|
rt.tick(); // spawn + on_start (watch)
|
||||||
|
|
||||||
|
rt.stop_actor(target);
|
||||||
|
// tick to process stop, cleanup, and deliver death notification
|
||||||
|
for (let i = 0; i < 5; i++) rt.tick();
|
||||||
|
|
||||||
|
const notification = deathInbox.try_recv();
|
||||||
|
assert(notification !== undefined, "sentinel received death notification");
|
||||||
|
assert(
|
||||||
|
typeof notification === "string" && notification.length > 0,
|
||||||
|
"notification is a non-empty string"
|
||||||
|
);
|
||||||
|
|
||||||
|
inbox.free();
|
||||||
|
deathInbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stats: total_messages ------------------------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: stats — total_messages");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
const inbox = rt.new_inbox_u32();
|
||||||
|
const c = spawn_counter(rt, inbox.addr());
|
||||||
|
rt.send_u32(c, 1);
|
||||||
|
rt.send_u32(c, 2);
|
||||||
|
rt.send_u32(c, 3);
|
||||||
|
rt.tick();
|
||||||
|
assert(rt.total_messages() >= 3, "total_messages counts processed messages");
|
||||||
|
inbox.free();
|
||||||
|
rt.free();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stats: total_panics starts at zero -----------------------------------
|
||||||
|
{
|
||||||
|
console.log("test: stats — total_panics starts at zero");
|
||||||
|
const rt = new WasmRuntime();
|
||||||
|
assertEq(rt.total_panics(), 0, "no panics initially");
|
||||||
rt.free();
|
rt.free();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
69
docs/development_history/in-browser/BROWSER_RUNTIME.md
Normal file
69
docs/development_history/in-browser/BROWSER_RUNTIME.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Browser Runtime API — Development History
|
||||||
|
|
||||||
|
> Stage 2 of the in-browser swactor runtime. Replaces the hardcoded PoC with
|
||||||
|
> a generic, type-safe API using opaque address handles and typed inboxes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### Core Types
|
||||||
|
|
||||||
|
**`WasmRuntime`** — wraps `swactor::Runtime` in single-threaded mode.
|
||||||
|
Methods: `tick()`, `actor_count()`, `send_u32()`, `send_bytes()`,
|
||||||
|
`stop_actor()`, `uptime_ms()`, `new_inbox_u32()`, `new_inbox_bytes()`.
|
||||||
|
Also exposes `runtime()` for Rust-side custom spawn functions.
|
||||||
|
|
||||||
|
**`WasmAddr`** — opaque handle wrapping `ActorAddress`. Returned by spawn
|
||||||
|
functions, passed to send functions. JS holds it as an opaque object.
|
||||||
|
Has `toString()` for debugging.
|
||||||
|
|
||||||
|
**`WasmInboxU32`** / **`WasmInboxBytes`** — typed inboxes for receiving
|
||||||
|
results from actors. Each has `addr()` → `WasmAddr` (so actors know where
|
||||||
|
to send) and `try_recv()` → `Option<T>`.
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|----------|-----------|
|
||||||
|
| 1 | Opaque `WasmAddr` handles instead of indices | Type-safe, stable identity, no out-of-bounds errors |
|
||||||
|
| 2 | Typed inbox types instead of generic `Inbox<T>` | wasm-bindgen doesn't support generics; concrete types are explicit |
|
||||||
|
| 3 | Free-standing `spawn_*` functions, not methods | Each actor type gets its own spawn function with typed args |
|
||||||
|
| 4 | `send_u32`/`send_bytes` on runtime | Common send types; custom types use typed spawn wrappers |
|
||||||
|
| 5 | Evolved existing `crates/wasm/` instead of new crate | Less churn, existing build/test infrastructure |
|
||||||
|
|
||||||
|
### Actor Pattern
|
||||||
|
|
||||||
|
Users expose actors to JS by writing one `#[wasm_bindgen]` spawn function
|
||||||
|
per actor type:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn spawn_my_actor(rt: &WasmRuntime, arg: JsValue) -> WasmAddr {
|
||||||
|
let actor = MyActor::from_js(arg);
|
||||||
|
let addr = rt.runtime().spawn(actor).unwrap();
|
||||||
|
WasmAddr(addr)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
10 Node.js tests in `crates/wasm/test.mjs`:
|
||||||
|
|
||||||
|
| Test | Scenario |
|
||||||
|
|------|----------|
|
||||||
|
| accumulator | Counter processes messages, reports running totals to inbox |
|
||||||
|
| relay | Relay forwards messages to counter (cross-actor, 2 ticks) |
|
||||||
|
| multiple counters | Two independent counters report to same inbox |
|
||||||
|
| actor_count | Spawning 3 actors reflects in stats |
|
||||||
|
| WasmAddr toString | Address has non-empty debug representation |
|
||||||
|
| stop_actor | Graceful stop removes actor from runtime |
|
||||||
|
| bytes inbox | WasmInboxBytes receives Uint8Array correctly |
|
||||||
|
| uptime_ms | Returns non-negative number |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo test -p swactor` — native tests pass (no regressions)
|
||||||
|
- `cargo build --target wasm32-unknown-unknown -p wasm` — compiles
|
||||||
|
- `wasm-pack build --target nodejs` in `crates/wasm/` — builds pkg/
|
||||||
|
- `node test.mjs` in `crates/wasm/` — 10/10 tests pass
|
||||||
60
docs/development_history/in-browser/DEMO.md
Normal file
60
docs/development_history/in-browser/DEMO.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Stage 5 — Interactive Browser Demo
|
||||||
|
|
||||||
|
Visual verification page for the in-browser swactor runtime. Single self-contained
|
||||||
|
HTML file that loads the `--target web` wasm build and exposes every API surface
|
||||||
|
through a live dashboard.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for browser (one-time, or after Rust changes)
|
||||||
|
cd crates/wasm && wasm-pack build --target web --out-dir pkg-web
|
||||||
|
|
||||||
|
# Serve (any static server works — needs correct .wasm MIME type)
|
||||||
|
cd crates/wasm && python3 -m http.server 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080/demo.html`.
|
||||||
|
|
||||||
|
## What It Covers
|
||||||
|
|
||||||
|
| Feature | How to verify |
|
||||||
|
|---|---|
|
||||||
|
| Runtime tick loop | Start/Pause button, Step for single tick, adjustable 1–60 tps |
|
||||||
|
| Actor spawning | Spawn Counter, Relay, GroupMember, Sentinel from dropdown |
|
||||||
|
| Message delivery | Send u32 to any actor, inbox polling shows received values |
|
||||||
|
| Cross-actor relay | Spawn Relay → target Counter, send to relay, counter accumulates |
|
||||||
|
| Actor stopping | Stop button on each card, actor disappears from viz |
|
||||||
|
| Watching / death notifications | Spawn Sentinel watching an actor, stop the watched actor |
|
||||||
|
| Name registry | Register/Lookup/Unregister names, live list in sidebar |
|
||||||
|
| Groups | GroupMember auto-joins on spawn, Broadcast sends to all members |
|
||||||
|
| Stats | Live actor count, total messages, total panics, uptime, tick count |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
demo.html
|
||||||
|
├── imports pkg-web/wasm.js (ES module, --target web)
|
||||||
|
├── creates WasmRuntime (single-threaded, StdExtension)
|
||||||
|
├── requestAnimationFrame tick loop
|
||||||
|
├── canvas visualization (actor circle graph + edges)
|
||||||
|
└── event log (spawn, send, recv, death, naming, groups)
|
||||||
|
```
|
||||||
|
|
||||||
|
All state lives in the page. No build step, no bundler, no framework — just
|
||||||
|
the wasm module and vanilla JS.
|
||||||
|
|
||||||
|
## Suggested Walkthrough
|
||||||
|
|
||||||
|
1. **Counter basics** — Spawn a Counter, Step once, click "Send 1", Step again.
|
||||||
|
Inbox log shows the running total.
|
||||||
|
2. **Relay chain** — Spawn Counter #1, then Relay targeting #1. Send to the relay,
|
||||||
|
observe the counter accumulating.
|
||||||
|
3. **Death watching** — Spawn a Counter, then a Sentinel watching it. Stop the
|
||||||
|
counter. The sentinel reports the death and self-terminates.
|
||||||
|
4. **Groups** — Spawn 3 GroupMembers in "workers". Hit "Broadcast 42". All three
|
||||||
|
receive the message.
|
||||||
|
5. **Naming** — Register "@main" for an actor. Lookup confirms it resolves. Unregister
|
||||||
|
and verify it's gone.
|
||||||
|
6. **Burst load** — Spawn several counters, click "Send ×10" on each, start the
|
||||||
|
runtime at 60 tps. Watch messages processed climb.
|
||||||
75
docs/development_history/in-browser/FEATURE_PARITY.md
Normal file
75
docs/development_history/in-browser/FEATURE_PARITY.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Feature Parity — Development History
|
||||||
|
|
||||||
|
> Stage 4 of the in-browser swactor runtime. Enables swactor-std extensions
|
||||||
|
> (naming, monitoring, groups) and core actor watching in the wasm crate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### swactor-std wasm compilation
|
||||||
|
|
||||||
|
- Added `wasm` feature to `crates/std/Cargo.toml` (forwards to `swactor/wasm`)
|
||||||
|
- Changed swactor dependency to `default-features = false`, forwarding `getrandom`
|
||||||
|
feature when active (`getrandom = ["dep:getrandom", "swactor/getrandom"]`)
|
||||||
|
- Cfg-gated `getrandom::getrandom()` call in `router.rs` `RoutingStrategy::Random`
|
||||||
|
— falls back to round-robin when `getrandom` feature is disabled (wasm mode)
|
||||||
|
|
||||||
|
### RuntimeNaming: register_name
|
||||||
|
|
||||||
|
- Added `register_name(name, addr)` method to `RuntimeNaming` trait and impl
|
||||||
|
— allows registering a name for an already-spawned actor from outside the runtime
|
||||||
|
— complements existing `spawn_named` (which spawns + registers atomically)
|
||||||
|
|
||||||
|
### Core watching fix: StopSignal death notifications
|
||||||
|
|
||||||
|
- Fixed gap in `worker.rs` tick_all: externally-stopped actors (via `rt.stop_actor()`)
|
||||||
|
were not added to the `deaths` list, so core WatchRegistry (phase 5b) never fired
|
||||||
|
for them. Added `deaths.push((addr, ExitReason::Stopped))` when StopSignal is
|
||||||
|
intercepted (line 737). All 140 existing native tests continue to pass.
|
||||||
|
|
||||||
|
### WasmRuntime: StdExtension + new APIs
|
||||||
|
|
||||||
|
- `WasmRuntime::new()` now installs `StdExtension` automatically
|
||||||
|
- New inbox type: `WasmInboxString` for receiving string notifications
|
||||||
|
- **Naming API**: `register_name`, `where_is`, `unregister_name`, `registered_names`
|
||||||
|
- **Groups API**: `join_group`, `leave_group`, `publish_to_group_u32`,
|
||||||
|
`group_member_count`, `group_names`
|
||||||
|
- **Stats API**: `total_messages`, `total_panics` (returned as f64 for JS compat)
|
||||||
|
- New demo actors:
|
||||||
|
- `Sentinel` — watches a target via `ctx.watch()`, reports death to string inbox
|
||||||
|
- `GroupMember` — joins a group on start, forwards u32 messages to report inbox
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|----------|-----------|
|
||||||
|
| 1 | StdExtension always installed | Browser runtime should have full naming/groups by default |
|
||||||
|
| 2 | Stats as f64, not u64 | wasm-bindgen maps u64 to BigInt which JSON.stringify rejects |
|
||||||
|
| 3 | Sentinel actor for watching | Demonstrates core watching from JS without exposing Watch API directly |
|
||||||
|
| 4 | register_name on RuntimeNaming | Needed for post-spawn registration from JS (no actor context available) |
|
||||||
|
| 5 | Round-robin fallback for Random routing | wasm mode disables getrandom; graceful degradation preferred |
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
22 new assertions across 10 new test scenarios (30 total, from 10):
|
||||||
|
|
||||||
|
| Test | Scenario |
|
||||||
|
|------|----------|
|
||||||
|
| naming — register_name and where_is | Register name, resolve, verify not-found returns undefined |
|
||||||
|
| naming — unregister_name | Unregister returns previous addr, name no longer resolves |
|
||||||
|
| naming — registered_names | Lists all registered names as CSV |
|
||||||
|
| naming — duplicate name rejected | Second registration with same name fails |
|
||||||
|
| groups — join_group and publish_to_group_u32 | Two members receive broadcast message |
|
||||||
|
| groups — leave_group | Member count decreases after leave |
|
||||||
|
| groups — group_names | Lists all active group names |
|
||||||
|
| watching — sentinel reports actor death | Stop target → sentinel receives death notification |
|
||||||
|
| stats — total_messages | Counts processed messages across workers |
|
||||||
|
| stats — total_panics starts at zero | Fresh runtime has zero panics |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `cargo test -p swactor -p swactor-std` — 157 native tests pass (no regressions)
|
||||||
|
- `cargo build --target wasm32-unknown-unknown -p wasm` — compiles
|
||||||
|
- `wasm-pack build --target nodejs` in `crates/wasm/` — builds pkg/
|
||||||
|
- `node test.mjs` in `crates/wasm/` — 30/30 tests pass
|
||||||
82
docs/development_history/in-browser/PLATFORM_ABSTRACTION.md
Normal file
82
docs/development_history/in-browser/PLATFORM_ABSTRACTION.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Platform Abstraction Layer — Development History
|
||||||
|
|
||||||
|
> Stage 1 of the in-browser swactor runtime. Makes core swactor compile for
|
||||||
|
> `wasm32-unknown-unknown` without behavioral changes on native targets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. `web-time` dependency + `wasm` feature flag
|
||||||
|
|
||||||
|
**File**: `Cargo.toml`
|
||||||
|
|
||||||
|
Added `web-time` as an optional dependency and a `wasm` feature that bundles
|
||||||
|
`no_random` + `web-time`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
wasm = ["no_random", "dep:web-time"]
|
||||||
|
web-time = { version = "0.2", optional = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`web-time` is a drop-in replacement for `std::time::Instant`:
|
||||||
|
- Native: re-exports `std::time::Instant` (zero-cost)
|
||||||
|
- wasm32: uses `performance.now()` via `js-sys`
|
||||||
|
|
||||||
|
### 2. Platform-aware `Instant` re-export
|
||||||
|
|
||||||
|
**File**: `src/lib.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
pub(crate) use web_time::Instant;
|
||||||
|
#[cfg(not(feature = "wasm"))]
|
||||||
|
pub(crate) use std::time::Instant;
|
||||||
|
```
|
||||||
|
|
||||||
|
All modules (`runtime.rs`, `worker.rs`) now use `crate::Instant` instead of
|
||||||
|
`std::time::Instant`. Single point of truth — no cfg noise in consumer code.
|
||||||
|
|
||||||
|
### 3. cfg-gated `Runtime::run()` and `RuntimeHandle`
|
||||||
|
|
||||||
|
**File**: `src/runtime.rs`
|
||||||
|
|
||||||
|
`Runtime::run()` calls `std::thread::spawn()` which is not available on wasm32.
|
||||||
|
Both `run()` and `RuntimeHandle` (which holds `JoinHandle<()>`) are gated:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn run(self) -> Result<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,6 +18,13 @@ pub mod runtime;
|
||||||
#[cfg(feature = "transport")]
|
#[cfg(feature = "transport")]
|
||||||
pub mod 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")]
|
#[cfg(feature = "getrandom")]
|
||||||
pub(crate) fn get_random(buf: &mut [u8]) {
|
pub(crate) fn get_random(buf: &mut [u8]) {
|
||||||
getrandom::getrandom(buf).unwrap()
|
getrandom::getrandom(buf).unwrap()
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ use std::any::Any;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::thread::{self, JoinHandle, Thread};
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::time::Instant;
|
use std::thread::{self, JoinHandle};
|
||||||
|
use std::thread::Thread;
|
||||||
|
use crate::Instant;
|
||||||
|
|
||||||
use crate::actor::{Actor, ActorAddress, ActorExited, ActorInterface, AnyActor, ExitReason, Message, StopSignal, TimerRequest};
|
use crate::actor::{Actor, ActorAddress, ActorExited, ActorInterface, AnyActor, ExitReason, Message, StopSignal, TimerRequest};
|
||||||
use crate::channel::{Receiver, Sender};
|
use crate::channel::{Receiver, Sender};
|
||||||
|
|
@ -67,11 +69,13 @@ impl<R: Message> Ask<R> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle for dealing with a runtime that has started via the `Runtime::run()` method.
|
/// Handle for dealing with a runtime that has started via the `Runtime::run()` method.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub struct RuntimeHandle {
|
pub struct RuntimeHandle {
|
||||||
pub runtime: Arc<Runtime>,
|
pub runtime: Arc<Runtime>,
|
||||||
threads: Vec<JoinHandle<()>>,
|
threads: Vec<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
impl RuntimeHandle {
|
impl RuntimeHandle {
|
||||||
pub fn join(self) {
|
pub fn join(self) {
|
||||||
for handle in self.threads {
|
for handle in self.threads {
|
||||||
|
|
@ -333,6 +337,9 @@ impl Runtime {
|
||||||
///
|
///
|
||||||
/// Works in both single-threaded and multi-threaded configurations.
|
/// Works in both single-threaded and multi-threaded configurations.
|
||||||
/// In single-threaded mode, one background thread is spawned.
|
/// 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> {
|
pub fn run(self) -> Result<RuntimeHandle, Error> {
|
||||||
self.is_running.store(true, Ordering::Release);
|
self.is_running.store(true, Ordering::Release);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ use std::any::Any;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Instant;
|
use crate::Instant;
|
||||||
|
|
||||||
use crate::actor::{ActorAddress, ActorExited, AnyActor, CloneMsg, ContextInner, Ctx, ExitReason, StopReason, StopSignal, TimerRequest};
|
use crate::actor::{ActorAddress, ActorExited, AnyActor, CloneMsg, ContextInner, Ctx, ExitReason, StopReason, StopSignal, TimerRequest};
|
||||||
use crate::channel::Receiver;
|
use crate::channel::Receiver;
|
||||||
|
|
@ -735,6 +735,7 @@ impl ActorPool {
|
||||||
slot.stopping = true;
|
slot.stopping = true;
|
||||||
stats.stops.fetch_add(1, Ordering::Relaxed);
|
stats.stops.fetch_add(1, Ordering::Relaxed);
|
||||||
slot.mailbox.clear();
|
slot.mailbox.clear();
|
||||||
|
deaths.push((addr, ExitReason::Stopped));
|
||||||
#[cfg(feature = "tracing")]
|
#[cfg(feature = "tracing")]
|
||||||
tracing::info!(actor_addr = %addr, "actor.stop_requested");
|
tracing::info!(actor_addr = %addr, "actor.stop_requested");
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue