swactor/docs/development_history/IN_BROWSER_RUNTIME.md

561 lines
21 KiB
Markdown
Raw Normal View History

# In-Browser Runtime via WebAssembly — Development History
> Covers the work to make swactor run in the browser with WebSocket
> connectivity to a native cluster via a gateway pattern.
>
> *Branch: `in-browser`*
---
## Table of Contents
1. [Overview & Motivation](#1-overview--motivation)
2. [What Was Built](#2-what-was-built)
3. [Development Phases](#3-development-phases)
4. [NodeAddr Abstraction](#4-nodeaddr-abstraction)
5. [Core Wasm Compatibility](#5-core-wasm-compatibility)
6. [WebSocket Transport (Browser)](#6-websocket-transport-browser)
7. [WebSocket Gateway (Native)](#7-websocket-gateway-native)
8. [Enhanced Browser Runtime](#8-enhanced-browser-runtime)
9. [Demo & Examples](#9-demo--examples)
10. [Design Decisions & Tradeoffs](#10-design-decisions--tradeoffs)
11. [Dashboard Improvements](#11-dashboard-improvements)
12. [Known Gaps & Future Work](#12-known-gaps--future-work)
---
## 1. Overview & Motivation
Before this work, swactor had a minimal wasm crate (`crates/wasm/`) that could
run hardcoded Counter/Relay actors locally via `tick()` — no networking, no
JS-defined actors, no connection to a cluster.
The goal: **make a browser node that can run actors locally AND connect to a
native cluster** via WebSocket, with the architecture designed so the browser
could eventually become a full cluster peer (WebRTC P2P).
Two design constraints guided the approach:
- **Start thin, design for full.** Use a gateway pattern for v1 connectivity
(browser ↔ WebSocket ↔ native node), but introduce the `NodeAddr` abstraction
now so the distribution layer can eventually support WebSocket and WebRTC
peers natively.
- **Both Rust and JS actors.** Rust-compiled actors work by defining
`ActorInterface` impls in the wasm crate (as the existing Counter/Relay do).
JS actors work via `js_sys::Function` callback wrappers.
---
## 2. What Was Built
| Component | Location | Action | Key Changes |
|-----------|----------|--------|-------------|
| NodeAddr abstraction | `crates/distribution/` | Modified (~30 files) | `SocketAddr` → `NodeAddr` across distribution, simulation, dashboard |
| Core wasm compat | `src/` | Modified (4 files) | `web-time` abstraction, cfg-gated threads, wire encoding extraction |
| WebSocket transport | `crates/wasm/src/` | Created (3 files) | `WsTransport`, `GatewayControl` protocol, `JsActor` wrapper |
| WebSocket gateway | `crates/gateway/` | Created (new crate) | `WsAcceptor`, `WsGateway`, `SessionTransport` |
| Browser runtime | `crates/wasm/src/lib.rs` | Rewritten | `BrowserRuntime` API with JS actor support |
| Demo | `examples/`, `crates/wasm/www/` | Created | Gateway example, HTML demo page |
| Dashboard improvements | `crates/runtime-dashboard/static/` | Created + Modified | Static file extraction, gossip panel, visual fixes, interactivity |
| Gossip accessors | `crates/distribution/src/` | Modified (5 files) | `SwimProbe`/`SwimNode`/`DisseminationQueue`/`DistributedNode` accessors, snapshot types |
---
## 3. Development Phases
### Phase 1 — NodeAddr abstraction in distribution crate
Replaced `SocketAddr` with an extensible `NodeAddr` enum across ~30 files:
distribution crate (15 source files + 12 test files), simulation crate, and
dashboard example. The main challenge was that `NodeAddr` is `Clone` but not
`Copy` (unlike `SocketAddr`), requiring ~25 `.clone()` additions.
### Phase 2 — Core wasm compatibility
Made the core `swactor` crate compile for `wasm32-unknown-unknown`:
- `web-time` behind `cfg(wasm32)` for `Instant`
- cfg-gated `RuntimeHandle`, `run()`, thread imports
- Extracted `encode_wire_envelope`/`decode_wire_envelope` into core `src/transport.rs`
- Distribution crate re-exports the shared wire encoding functions
### Phase 3 — WebSocket transport (browser side)
Created browser-side transport wrapping `web_sys::WebSocket`:
- `WsTransport` — buffers outbound messages until connection opens, decodes
inbound binary frames, implements `Transport` trait
- `GatewayControl` — control protocol for actor registration/resolution/keepalive
- Same length-prefixed binary wire format as TCP
### Phase 4 — WebSocket gateway (native side)
Created `crates/gateway/` — a native-side WebSocket acceptor + gateway:
- `WsAcceptor` — mirrors `TcpAcceptor` pattern (non-blocking accept, WS upgrade,
non-blocking read loop, dead connection cleanup)
- `WsGateway` — routes inbound envelopes to runtime, handles control protocol,
creates `SessionTransport` routes for cluster → browser forwarding
- Sync `tungstenite` in dedicated thread — no tokio dependency
### Phase 5 — Enhanced browser runtime
Replaced the hardcoded wasm MVP with a generic runtime:
- `JsActor` — wraps `js_sys::Function` as an `ActorInterface` impl
- `JsActorCtx` — bridge object passed to JS handlers (`send`, `self_addr`)
- `BrowserRuntime` — JS-facing API: `connect()`, `spawn_js_actor()`,
`send_json()`, `tick()`, `create_inbox()`, `try_recv()`
- `JsMessage` codec for JSON-based communication
- Legacy `SwactorRuntime` preserved for backwards compatibility
### Phase 6 — Demo and testing
- `examples/ws_gateway.rs` — native gateway node with echo actor
- `crates/wasm/www/index.html` — browser demo with connection UI, actor
spawning, message sending, and log panel
---
## 4. NodeAddr Abstraction
### The Type
```rust
// crates/distribution/src/types.rs
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NodeAddr {
Tcp(SocketAddr),
// Future: Ws(String), WebRtc(String)
}
```
All distribution APIs (`SwimNode`, `DistributedNode`, `RoutingTable`, etc.)
now accept and return `NodeAddr`. The `TcpTransport` pattern-matches
`NodeAddr::Tcp(addr)` when connecting:
```rust
fn require_tcp(addr: &NodeAddr) -> Result<SocketAddr, Error> {
match addr {
NodeAddr::Tcp(sa) => Ok(*sa),
}
}
```
### Scope of changes
The refactor touched every file in the distribution crate that previously used
`SocketAddr`:
- `types.rs` — `NodeRecord.addr`, `DirectoryEntry` field type
- `messages.rs` — `PingReq.target_addr`, `JoinRequest.addr`, `FindNodeResponse`, `FindValueResponse`
- `swim/node.rs` — `SwimNode.self_addr`, all `NodeAction` variants
- `swim/probe.rs` — `SwimAction` variants, `ProbePhase`, target selection
- `swim/member_list.rs` — `MemberEntry.addr`, `apply()` signature
- `swim/dissemination.rs` — `membership_update()` function
- `kademlia/routing_table.rs` — `NodeEntry.addr`, `insert()` signature
- `kademlia/lookup.rs` — `LookupAction`, `NodeLookup.known`
- `node.rs` — `DistributedNodeConfig.listen_addr`, all handler methods
- `transport.rs` — `TcpTransport::new()`, `send_to()`
- `snapshot.rs` — `addr_str()`
- All 12 test files in `crates/distribution/tests/`
- `crates/simulation/src/distribution/sim.rs`
- `crates/runtime-dashboard/examples/dashboard_demo.rs`
---
## 5. Core Wasm Compatibility
### Time abstraction
```rust
// src/lib.rs
pub(crate) mod time {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use std::time::Instant;
#[cfg(target_arch = "wasm32")]
pub(crate) use web_time::Instant;
}
```
`src/runtime.rs` and `src/worker.rs` import `crate::time::Instant` instead of
`std::time::Instant`. The `web-time` crate provides a browser-compatible
`Instant` backed by `performance.now()`.
### cfg-gated thread code
```rust
// src/runtime.rs
#[cfg(not(target_arch = "wasm32"))]
use std::thread::{self, JoinHandle};
#[cfg(not(target_arch = "wasm32"))]
pub struct RuntimeHandle { ... }
#[cfg(not(target_arch = "wasm32"))]
pub fn run(self) -> Result<RuntimeHandle, Error> { ... }
```
Same pattern in `src/worker.rs` for `Worker::run()`. On wasm32, only
`tick()` is available.
### Wire encoding extraction
`encode_wire_envelope` and `decode_wire_envelope` were moved from
`crates/distribution/src/transport.rs` into core `src/transport.rs` so both
the distribution crate and the wasm crate can share them. The distribution
crate re-exports:
```rust
pub use swactor::transport::encode_wire_envelope;
pub use swactor::transport::decode_wire_envelope;
```
### Worker module visibility
On wasm32, the worker module is `pub(crate)` (not `pub`) since external code
shouldn't depend on thread-specific worker internals:
```rust
#[cfg(not(target_arch = "wasm32"))]
pub mod worker;
#[cfg(target_arch = "wasm32")]
pub(crate) mod worker;
```
---
## 6. WebSocket Transport (Browser)
```
crates/wasm/src/
├── ws_transport.rs — WsTransport (Transport impl over web_sys::WebSocket)
├── protocol.rs — GatewayControl enum, CONTROL_TYPE_TAG, NULL_ADDRESS
└── js_actor.rs — JsActor, JsActorCtx, JsMessage
```
### WsTransport
Wraps `web_sys::WebSocket` with outbound buffering and inbound envelope
decoding:
```
Browser JS WsTransport Gateway
────────── ─────────── ───────
┌─ Connecting ─┐
rt.connect(url) ──►│ buffer sends │──── WS handshake ────►
└──────────────┘
┌─── Open ─────┐
rt.send_json() ───►│ encode + send│──── binary frame ────►
│ │
│ decode inbound│◄─── binary frame ────
└──────────────┘
rt.tick() ───► drain_inbound() → deliver_raw()
```
The transport uses `Rc<RefCell<WsInner>>` internally — safe because wasm32
is single-threaded. `unsafe impl Send + Sync` matches the pattern used by
`Runtime`'s existing `unsafe impl Sync` for `RefCell<Vec<Worker>>`.
### Control Protocol
Control messages use a reserved null address (`[0u8; 32]`) and the type tag
`"swactor::GatewayControl"`:
```rust
pub enum GatewayControl {
RegisterActor { addr: [u8; 32] },
UnregisterActor { addr: [u8; 32] },
ResolveActor { name: String },
ActorResolved { name: String, addr: [u8; 32] },
Ping,
Pong,
}
```
---
## 7. WebSocket Gateway (Native)
```
crates/gateway/src/
├── lib.rs — WsGateway, SessionTransport, control protocol handler
└── ws_acceptor.rs — WsAcceptor (mirrors TcpAcceptor), WsSession, SessionId
```
### WsAcceptor
Follows the same non-blocking pattern as `TcpAcceptor` in the distribution
crate:
1. Non-blocking TCP accept
2. WebSocket handshake (briefly blocking per new client)
3. Switch to non-blocking for reads
4. Read binary frames from all sessions
5. Dead connection cleanup in reverse index order
### WsGateway
```
Browser WsGateway Runtime / Cluster
─────── ───────── ──────────────────
WireEnvelope ─────► route by dest:
│
├─ control msg? → handle_control()
│ ├─ RegisterActor → add route
│ ├─ Ping → send Pong
│ └─ ...
│
└─ regular msg → codec_registry.receive()
└─ runtime.deliver_raw()
◄───── WireEnvelope ◄── SessionTransport.send()
│
└─ TransportRouter lookup hits
SessionTransport for browser-owned address
```
When a browser registers an actor, the gateway:
1. Stores `addr → session_id` mapping
2. Creates a `SessionTransport` route in the `TransportRouter`
3. Cluster actors sending to that address hit the route, which
forwards via the WebSocket session
---
## 8. Enhanced Browser Runtime
### JsActor
```rust
pub struct JsActor {
handler: js_sys::Function, // (ctx, type_tag, msg_json) => void
}
impl ActorInterface for JsActor {
type Incoming = JsMessage;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: JsMessage) { ... }
}
```
The handler receives a `JsActorCtx` bridge that exposes `send(dest_hex,
type_tag, msg_json)` and `self_addr()` to JavaScript.
### BrowserRuntime API
```
JavaScript BrowserRuntime (Rust/wasm)
────────── ──────────────────────────
new BrowserRuntime() ──► creates Runtime + CodecRegistry + TransportRouter
rt.connect(url) ──► WsTransport::connect(url)
rt.spawn_js_actor(fn) ──► JsActor wrapper → rt.spawn() → register w/ gateway
rt.send_json(hex, tag, j)──► JsMessage → rt.send_to()
rt.tick() ──► drain WS inbound → deliver_raw → rt.tick()
rt.create_inbox() ──► rt.new_inbox::<JsMessage>() → hex address
rt.try_recv(hex) ──► inbox.try_recv() → JSON string or undefined
rt.is_connected() ──► WsTransport::is_connected()
rt.actor_count() ──► rt.stats().actors.len()
```
### JsMessage
A unified message type for all JS actors:
```rust
pub struct JsMessage {
pub type_tag: String, // wire protocol type tag (or "local")
pub payload: String, // JSON payload
}
```
Registered in the `CodecRegistry` with type tag `"swactor::JsMessage"` and a
simple UTF-8 codec.
---
## 9. Demo & Examples
### Gateway example (`examples/ws_gateway.rs`)
```bash
cargo run --example ws_gateway --features transport
```
Starts a native node on `ws://127.0.0.1:9000` with an echo actor. The gateway
accepts WebSocket connections and bridges messages between browser clients and
the runtime.
### Demo HTML page (`crates/wasm/www/index.html`)
Build and serve:
```bash
cd crates/wasm && wasm-pack build --target web --out-dir www/pkg
cd www && python3 -m http.server 8080
# Open http://localhost:8080
```
Features: connection panel with status indicator, JS actor spawning with
click-to-copy addresses, message sending, and a timestamped event log.
---
## 10. Design Decisions & Tradeoffs
| Decision | Rationale |
|----------|-----------|
| Gateway pattern for v1 | Avoids STUN/TURN complexity; one TCP connection per browser; simple to debug. `NodeAddr` abstraction leaves room for direct WebRTC P2P later. |
| Sync tungstenite (no tokio) | Matches existing `TcpAcceptor` pattern in the distribution crate. Keeps the dependency tree small. The gateway runs a poll loop on a dedicated thread. |
| `NodeAddr` enum (not trait) | Enums are exhaustive, serializable, and cheap to match. Adding a variant (e.g., `Ws(String)`) is a compiler-guided refactor. |
| Same wire format over WS as TCP | No protocol translation — a `WireEnvelope` is the same bytes on both transports. Simplifies debugging and testing. |
| `Rc<RefCell>` in WsTransport | wasm32 is single-threaded. `Arc<Mutex>` would compile but add unnecessary overhead. `unsafe impl Send+Sync` is the standard pattern for wasm-bindgen types. |
| JSON for JS actor messages | The browser's native format. Binary codecs could be added later via `CodecRegistry`. |
| Preserved legacy `SwactorRuntime` | The old Counter/Relay API still works. New code uses `BrowserRuntime`. |
---
## 11. Dashboard Improvements
Alongside the in-browser work, the runtime dashboard received significant
improvements: extraction to static files, visual bug fixes, interactivity,
and a new gossip protocol panel on the distribution page.
### Static file extraction
The dashboard HTML was originally embedded as Rust string constants
(`actors_html.rs`, `dashboard_html.rs`, `distribution_html.rs`). These were
extracted to standalone files under `crates/runtime-dashboard/static/`:
```
static/
├── css/shared.css — shared dark-theme styles, stat cards, nav
├── js/shared.js — SSE helper, color palette, colorWithAlpha()
├── index.html — overview page (worker chart, actor table)
├── actors.html — actors page (worker bars, depth chart, actor list)
└── distribution.html — distribution page (graph, gossip, membership)
```
The server now serves these as static files instead of embedding them.
### Visual fixes and performance
- **Bar chart label clipping** — clamped `fillText` y-position to
`Math.max(12, chartH - h - 4)` so labels for tall bars stay visible
- **Worker details click flakiness** — `innerHTML = ''` every 200ms
destroyed DOM elements and their click handlers. Replaced with persistent
`workerNodes` map; headers and handlers are created once, text updated
in-place
- **Legend wrapping** — added `white-space: nowrap; overflow: hidden;
text-overflow: ellipsis` to worker legend
- **Performance** — data fingerprinting (`JSON.stringify` comparison) skips
redundant redraws; differential actor table updates; `requestAnimationFrame`
throttling
### Actors page interactivity
- **Click-to-filter** — clicking a worker bar in the chart filters the actor
table to that worker's actors (click again to clear)
- **Depth chart colors** — changed from green-red heatmap to purple/indigo
palette to distinguish from worker bars
- **Worker-colored rows** — actor rows are tinted with a translucent version
of their worker's color via `colorWithAlpha(hex, 0.08)`
### Gossip protocol panel (distribution page)
Exposed SWIM gossip internals through new Rust accessors and snapshot types,
then added a dedicated panel to the distribution page.
**Rust changes** — new accessor methods on `SwimProbe` (`tick()`,
`sequence()`, `phase_name()`, `probe_target()`, `suspicion_timers()`,
`config()`), `SwimNode` (`probe()`, `dissemination()`), `DisseminationQueue`
(`pending_entries()`), and `DistributedNode` (`swim_node()`). New snapshot
structs: `GossipInfo`, `SuspicionInfo`, `DisseminationInfo`, `GossipConfig`.
Added `gossip: Option<GossipInfo>` to `DistributionNodeSnapshot` with
`#[serde(default)]` for backward compatibility.
**UI panel** shows:
- **Phase badge** — IDLE (green), PINGING (yellow), INDIRECT (orange)
- **Counters** — protocol round, probes sent, incarnation, pending updates
- **Suspicion timers** — table with progress bars toward timeout
- **Membership events** — persistent rolling log of state transitions
(new/alive/suspect/dead/gone) built by diffing consecutive snapshots
client-side, with timestamps and node addresses
- **Recently probed** — list of recent probe targets
- **Config** — human-readable protocol parameters
All node IDs are displayed as socket addresses (via a lookup map built from
the members list) rather than raw hex. When running solo with no peers, the
panel shows "No peers — gossip inactive" instead of zeros.
---
## 12. Known Gaps & Future Work
| Gap | Effort | Impact |
|-----|--------|--------|
| `ResolveActor` control message not implemented | Low | Browser can't discover actors by name |
| No WebSocket reconnection logic | Medium | Browser must refresh on disconnect |
| No authentication on WS connections | Medium | Any client can register actors |
| Gateway doesn't participate in SWIM | Medium | Browser actors aren't in the cluster directory |
| No back-pressure from WS to actors | Low | Fast sender can overwhelm browser |
| WebRTC P2P (direct browser↔browser) | High | Eliminates gateway bottleneck |
| `NodeAddr::Ws` variant in distribution | Medium | Browser as full cluster peer without gateway |
| wasm-pack integration test | Low | Automated browser test in CI |
---
## Verifying
### Native build + tests
```bash
# Full workspace build
cargo build
# All tests (excluding flaky simulation MT test)
cargo test --workspace --exclude simulation
# Distribution tests specifically (134 tests)
cargo test -p distribution
# Gateway crate builds
cargo build -p swactor-gateway
```
### Wasm32 target
```bash
# Core crate compiles for wasm32
cargo build --target wasm32-unknown-unknown -p swactor --no-default-features --features "no_random,transport"
# Wasm crate compiles for wasm32
cargo build --target wasm32-unknown-unknown -p swactor-wasm
```
### Dashboard
```bash
# Build dashboard (includes static files)
cargo build -p runtime-dashboard
# Run the distribution demo (9-node churn simulation with dashboard)
cargo run --example dashboard_demo -p runtime-dashboard --features distribution
# Open http://localhost:3000 → Overview, Actors, Distribution pages
# Verify distribution page gossip panel:
# - Solo node: "No peers — gossip inactive"
# - With peers: phase badge, counters, membership event log
```
### Gateway example (manual)
```bash
# Terminal 1: start gateway
cargo run --example ws_gateway --features transport
# Terminal 2: build wasm + serve demo page
cd crates/wasm && wasm-pack build --target web --out-dir www/pkg
cd www && python3 -m http.server 8080
# Open http://localhost:8080 and click Connect
```