feat: in-browser runtime, WebSocket gateway, dashboard improvements

- NodeAddr abstraction replacing SocketAddr across distribution crate (~30 files)
- Core wasm32 compatibility: web-time, cfg-gated threads, wire encoding extraction
- WebSocket transport (browser) with GatewayControl protocol
- WebSocket gateway (native) with session routing and control protocol
- BrowserRuntime API: JS actor support, connect/spawn/send/tick
- Dashboard: extracted HTML to static files, gossip protocol panel with
  membership event log, visual fixes, click-to-filter, worker-colored rows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Developer 2026-02-12 17:51:36 +00:00
parent d817ad952f
commit 548396ba4a
49 changed files with 3311 additions and 872 deletions

208
Cargo.lock generated
View file

@ -77,6 +77,12 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "cassowary" name = "cassowary"
version = "0.3.0" version = "0.3.0"
@ -380,6 +386,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@ -417,7 +429,7 @@ name = "distribution"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"rand_core", "rand_core 0.6.4",
"serde", "serde",
"serde_json", "serde_json",
"swactor", "swactor",
@ -441,7 +453,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519", "ed25519",
"rand_core", "rand_core 0.6.4",
"serde", "serde",
"sha2", "sha2",
"subtle", "subtle",
@ -503,6 +515,18 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]] [[package]]
name = "half" name = "half"
version = "2.7.1" version = "2.7.1"
@ -543,6 +567,22 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]] [[package]]
name = "httpdate" name = "httpdate"
version = "1.0.3" version = "1.0.3"
@ -837,6 +877,15 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -926,13 +975,48 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.6.4" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
] ]
[[package]] [[package]]
@ -1138,6 +1222,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@ -1195,7 +1290,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@ -1203,7 +1298,7 @@ name = "simulation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"distribution", "distribution",
"getrandom", "getrandom 0.2.17",
"log", "log",
"serde", "serde",
"serde_json", "serde_json",
@ -1285,16 +1380,41 @@ dependencies = [
"criterion", "criterion",
"crossbeam-queue", "crossbeam-queue",
"crossbeam-utils", "crossbeam-utils",
"getrandom", "getrandom 0.2.17",
"serde", "serde",
"swactor-gateway",
"tracing", "tracing",
"web-time",
]
[[package]]
name = "swactor-gateway"
version = "0.1.0"
dependencies = [
"distribution",
"serde",
"serde_json",
"swactor",
"tungstenite",
]
[[package]]
name = "swactor-wasm"
version = "0.1.0"
dependencies = [
"js-sys",
"serde",
"serde_json",
"swactor",
"wasm-bindgen",
"web-sys",
] ]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.114" version = "2.0.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1307,6 +1427,26 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.9"
@ -1436,6 +1576,23 @@ dependencies = [
"tracing-log", "tracing-log",
] ]
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
"utf-8",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@ -1483,6 +1640,12 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@ -1512,11 +1675,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasm" name = "wasip2"
version = "0.1.0" version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [ dependencies = [
"swactor", "wit-bindgen",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -1574,6 +1738,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@ -1702,6 +1876,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.39" version = "0.8.39"
@ -1730,6 +1910,6 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.20" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -1,5 +1,5 @@
[workspace] [workspace]
members = [".", "crates/python", "crates/wasm", "crates/simulation", "crates/runtime-dashboard", "crates/distribution", "crates/simulation-dashboard"] members = [".", "crates/python", "crates/wasm", "crates/simulation", "crates/runtime-dashboard", "crates/distribution", "crates/simulation-dashboard", "crates/gateway"]
exclude = ["tools/depgraph"] exclude = ["tools/depgraph"]
[package] [package]
@ -30,8 +30,12 @@ tracing = { version = "0.1", optional = true }
crossbeam-queue = "0.3.12" crossbeam-queue = "0.3.12"
crossbeam-utils = "0.8.21" crossbeam-utils = "0.8.21"
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-time = "1.1"
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] } criterion = { version = "0.5", features = ["html_reports"] }
swactor_gateway = { path = "crates/gateway", package = "swactor-gateway" }
[[bench]] [[bench]]
name = "runtime_benchmarks" name = "runtime_benchmarks"
@ -44,3 +48,7 @@ harness = false
[[example]] [[example]]
name = "tcp_ping_pong" name = "tcp_ping_pong"
required-features = ["transport"] required-features = ["transport"]
[[example]]
name = "ws_gateway"
required-features = ["transport"]

View file

@ -10,9 +10,8 @@
//! translates into real network requests. //! translates into real network requests.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use crate::types::NodeId; use crate::types::{NodeAddr, NodeId};
use super::routing_table::{RoutingTable, K}; use super::routing_table::{RoutingTable, K};
/// Concurrency parameter — how many queries to issue in parallel per round. /// Concurrency parameter — how many queries to issue in parallel per round.
@ -25,9 +24,9 @@ const MAX_ROUNDS: usize = 20;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum LookupAction { pub enum LookupAction {
/// Send a FIND_NODE query to this node. /// Send a FIND_NODE query to this node.
Query { node_id: NodeId, addr: SocketAddr }, Query { node_id: NodeId, addr: NodeAddr },
/// The lookup is complete — here are the k closest nodes found. /// The lookup is complete — here are the k closest nodes found.
Done { closest: Vec<(NodeId, SocketAddr)> }, Done { closest: Vec<(NodeId, NodeAddr)> },
} }
/// State of a single iterative FIND_NODE lookup. /// State of a single iterative FIND_NODE lookup.
@ -36,7 +35,7 @@ pub struct NodeLookup {
k: usize, k: usize,
alpha: usize, alpha: usize,
/// All nodes discovered during the lookup, with their distances. /// All nodes discovered during the lookup, with their distances.
known: HashMap<NodeId, (SocketAddr, [u8; 32])>, known: HashMap<NodeId, (NodeAddr, [u8; 32])>,
/// Nodes we've already queried. /// Nodes we've already queried.
queried: HashSet<NodeId>, queried: HashSet<NodeId>,
/// Nodes we've sent queries to but haven't received responses yet. /// Nodes we've sent queries to but haven't received responses yet.
@ -63,7 +62,7 @@ impl NodeLookup {
let mut known = HashMap::new(); let mut known = HashMap::new();
for entry in &seeds { for entry in &seeds {
let dist = entry.node_id.xor_distance(&target); let dist = entry.node_id.xor_distance(&target);
known.insert(entry.node_id, (entry.addr, dist)); known.insert(entry.node_id, (entry.addr.clone(), dist));
} }
let mut lookup = Self { let mut lookup = Self {
@ -85,7 +84,7 @@ impl NodeLookup {
pub fn handle_response( pub fn handle_response(
&mut self, &mut self,
from: NodeId, from: NodeId,
closer_nodes: Vec<(NodeId, SocketAddr)>, closer_nodes: Vec<(NodeId, NodeAddr)>,
) -> Vec<LookupAction> { ) -> Vec<LookupAction> {
if self.done { if self.done {
return vec![self.done_action()]; return vec![self.done_action()];
@ -140,7 +139,7 @@ impl NodeLookup {
.known .known
.iter() .iter()
.filter(|(id, _)| !self.queried.contains(id)) .filter(|(id, _)| !self.queried.contains(id))
.map(|(id, (addr, dist))| (*id, *addr, *dist)) .map(|(id, (addr, dist))| (*id, addr.clone(), *dist))
.collect(); .collect();
candidates.sort_by(|a, b| a.2.cmp(&b.2)); candidates.sort_by(|a, b| a.2.cmp(&b.2));
@ -174,11 +173,11 @@ impl NodeLookup {
actions actions
} }
fn k_closest(&self) -> Vec<(NodeId, SocketAddr)> { fn k_closest(&self) -> Vec<(NodeId, NodeAddr)> {
let mut sorted: Vec<_> = self let mut sorted: Vec<_> = self
.known .known
.iter() .iter()
.map(|(id, (addr, dist))| (*id, *addr, *dist)) .map(|(id, (addr, dist))| (*id, addr.clone(), *dist))
.collect(); .collect();
sorted.sort_by(|a, b| a.2.cmp(&b.2)); sorted.sort_by(|a, b| a.2.cmp(&b.2));
sorted.truncate(self.k); sorted.truncate(self.k);

View file

@ -6,9 +6,8 @@
//! replacement cache and only promote when an existing node is evicted. //! replacement cache and only promote when an existing node is evicted.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::net::SocketAddr;
use crate::types::NodeId; use crate::types::{NodeAddr, NodeId};
/// Default replication parameter. /// Default replication parameter.
pub const K: usize = 20; pub const K: usize = 20;
@ -20,7 +19,7 @@ const NUM_BUCKETS: usize = 256;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NodeEntry { pub struct NodeEntry {
pub node_id: NodeId, pub node_id: NodeId,
pub addr: SocketAddr, pub addr: NodeAddr,
} }
/// A single k-bucket with an LRU list and replacement cache. /// A single k-bucket with an LRU list and replacement cache.
@ -119,7 +118,7 @@ impl RoutingTable {
} }
/// Insert or update a node in the routing table. /// Insert or update a node in the routing table.
pub fn insert(&mut self, node_id: NodeId, addr: SocketAddr) -> bool { pub fn insert(&mut self, node_id: NodeId, addr: NodeAddr) -> bool {
if node_id == self.self_id { if node_id == self.self_id {
return false; return false;
} }

View file

@ -1,12 +1,10 @@
//! Protocol messages for SWIM membership and Kademlia directory. //! Protocol messages for SWIM membership and Kademlia directory.
use std::net::SocketAddr;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use swactor::transport::NetworkMessage; use swactor::transport::NetworkMessage;
use crate::types::{DirectoryEntry, MemberState, NodeId, NodeRecord}; use crate::types::{DirectoryEntry, MemberState, NodeAddr, NodeId, NodeRecord};
// ─── SWIM Protocol Messages ──────────────────────────────────────────────── // ─── SWIM Protocol Messages ────────────────────────────────────────────────
@ -41,7 +39,7 @@ impl NetworkMessage for Ack {
pub struct PingReq { pub struct PingReq {
pub from: NodeId, pub from: NodeId,
pub target: NodeId, pub target: NodeId,
pub target_addr: SocketAddr, pub target_addr: NodeAddr,
pub sequence: u64, pub sequence: u64,
} }
@ -55,7 +53,7 @@ impl NetworkMessage for PingReq {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinRequest { pub struct JoinRequest {
pub from: NodeId, pub from: NodeId,
pub addr: SocketAddr, pub addr: NodeAddr,
} }
impl NetworkMessage for JoinRequest { impl NetworkMessage for JoinRequest {
@ -82,7 +80,7 @@ impl NetworkMessage for JoinResponse {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MembershipUpdate { pub struct MembershipUpdate {
pub node_id: NodeId, pub node_id: NodeId,
pub addr: SocketAddr, pub addr: NodeAddr,
pub state: MemberState, pub state: MemberState,
pub incarnation: u64, pub incarnation: u64,
} }
@ -105,7 +103,7 @@ impl NetworkMessage for FindNodeRequest {
/// Kademlia FIND_NODE response — closest known nodes. /// Kademlia FIND_NODE response — closest known nodes.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindNodeResponse { pub struct FindNodeResponse {
pub closest: Vec<(NodeId, SocketAddr)>, pub closest: Vec<(NodeId, NodeAddr)>,
} }
impl NetworkMessage for FindNodeResponse { impl NetworkMessage for FindNodeResponse {
@ -145,7 +143,7 @@ pub enum FindValueResponse {
/// Found the actor — here's the directory entry. /// Found the actor — here's the directory entry.
Found(DirectoryEntry), Found(DirectoryEntry),
/// Don't have it — here are closer nodes to ask. /// Don't have it — here are closer nodes to ask.
Closer(Vec<(NodeId, SocketAddr)>), Closer(Vec<(NodeId, NodeAddr)>),
} }
impl NetworkMessage for FindValueResponse { impl NetworkMessage for FindValueResponse {

View file

@ -3,8 +3,6 @@
//! Composes SWIM membership, Kademlia routing, directory, cache, and //! Composes SWIM membership, Kademlia routing, directory, cache, and
//! transport into a single public API. //! transport into a single public API.
use std::net::SocketAddr;
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use crate::cache::LocationCache; use crate::cache::LocationCache;
@ -14,11 +12,11 @@ use crate::kademlia::repair::{RepairQueue, RepublishTracker};
use crate::kademlia::routing_table::RoutingTable; use crate::kademlia::routing_table::RoutingTable;
use crate::swim::node::{NodeAction, SwimNode}; use crate::swim::node::{NodeAction, SwimNode};
use crate::swim::probe::SwimConfig; use crate::swim::probe::SwimConfig;
use crate::types::{MemberState, NodeId, NodeRecord}; use crate::types::{MemberState, NodeAddr, NodeId, NodeRecord};
/// Configuration for a distributed node. /// Configuration for a distributed node.
pub struct DistributedNodeConfig { pub struct DistributedNodeConfig {
pub listen_addr: SocketAddr, pub listen_addr: NodeAddr,
pub swim: SwimConfig, pub swim: SwimConfig,
pub cache_capacity: usize, pub cache_capacity: usize,
pub republish_interval: u64, pub republish_interval: u64,
@ -27,7 +25,7 @@ pub struct DistributedNodeConfig {
impl Default for DistributedNodeConfig { impl Default for DistributedNodeConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
listen_addr: "127.0.0.1:0".parse().unwrap(), listen_addr: NodeAddr::tcp("127.0.0.1:0".parse().unwrap()),
swim: SwimConfig::default(), swim: SwimConfig::default(),
cache_capacity: 10_000, cache_capacity: 10_000,
republish_interval: 1000, republish_interval: 1000,
@ -78,7 +76,7 @@ impl DistributedNode {
self.keypair.node_id() self.keypair.node_id()
} }
pub fn listen_addr(&self) -> SocketAddr { pub fn listen_addr(&self) -> NodeAddr {
self.swim.self_addr() self.swim.self_addr()
} }
@ -89,7 +87,7 @@ impl DistributedNode {
// ─── Cluster operations ───────────────────────────────────────────── // ─── Cluster operations ─────────────────────────────────────────────
/// Join a cluster by contacting seed nodes. /// Join a cluster by contacting seed nodes.
pub fn join(&self, seeds: &[SocketAddr]) -> Vec<NodeAction> { pub fn join(&self, seeds: &[NodeAddr]) -> Vec<NodeAction> {
self.swim.join(seeds) self.swim.join(seeds)
} }
@ -154,8 +152,8 @@ impl DistributedNode {
// ─── SWIM message handling (delegate to SwimNode) ─────────────────── // ─── SWIM message handling (delegate to SwimNode) ───────────────────
pub fn handle_ping(&mut self, from: NodeId, from_addr: SocketAddr, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> { pub fn handle_ping(&mut self, from: NodeId, from_addr: NodeAddr, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
let actions = self.swim.handle_ping(from, from_addr, sequence, piggyback); let actions = self.swim.handle_ping(from, from_addr.clone(), sequence, piggyback);
self.maybe_update_routing_table(from, from_addr); self.maybe_update_routing_table(from, from_addr);
actions actions
} }
@ -164,12 +162,12 @@ impl DistributedNode {
self.swim.handle_ack(from, sequence, piggyback) self.swim.handle_ack(from, sequence, piggyback)
} }
pub fn handle_ping_req(&mut self, from: NodeId, target: NodeId, target_addr: SocketAddr, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> { pub fn handle_ping_req(&mut self, from: NodeId, target: NodeId, target_addr: NodeAddr, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
self.swim.handle_ping_req(from, target, target_addr, sequence, piggyback) self.swim.handle_ping_req(from, target, target_addr, sequence, piggyback)
} }
pub fn handle_join_request(&mut self, from: NodeId, from_addr: SocketAddr) -> Vec<NodeAction> { pub fn handle_join_request(&mut self, from: NodeId, from_addr: NodeAddr) -> Vec<NodeAction> {
let actions = self.swim.handle_join_request(from, from_addr); let actions = self.swim.handle_join_request(from, from_addr.clone());
self.maybe_update_routing_table(from, from_addr); self.maybe_update_routing_table(from, from_addr);
actions actions
} }
@ -177,7 +175,7 @@ impl DistributedNode {
pub fn handle_join_response(&mut self, members: Vec<NodeRecord>) -> Vec<NodeAction> { pub fn handle_join_response(&mut self, members: Vec<NodeRecord>) -> Vec<NodeAction> {
for m in &members { for m in &members {
if m.state != MemberState::Dead { if m.state != MemberState::Dead {
self.routing_table.insert(m.node_id, m.addr); self.routing_table.insert(m.node_id, m.addr.clone());
} }
} }
self.swim.handle_join_response(members) self.swim.handle_join_response(members)
@ -260,9 +258,14 @@ impl DistributedNode {
self.swim.recent_probe_targets().iter().copied().collect() self.swim.recent_probe_targets().iter().copied().collect()
} }
/// Access the underlying SWIM node.
pub fn swim_node(&self) -> &SwimNode {
&self.swim
}
// ─── Internal ─────────────────────────────────────────────────────── // ─── Internal ───────────────────────────────────────────────────────
fn maybe_update_routing_table(&mut self, node_id: NodeId, addr: SocketAddr) { fn maybe_update_routing_table(&mut self, node_id: NodeId, addr: NodeAddr) {
self.routing_table.insert(node_id, addr); self.routing_table.insert(node_id, addr);
} }
@ -270,7 +273,7 @@ impl DistributedNode {
match state { match state {
MemberState::Alive => { MemberState::Alive => {
if let Some(entry) = self.swim.members().get(&node_id) { if let Some(entry) = self.swim.members().get(&node_id) {
self.routing_table.insert(node_id, entry.addr); self.routing_table.insert(node_id, entry.addr.clone());
} }
} }
MemberState::Dead => { MemberState::Dead => {
@ -291,7 +294,7 @@ pub enum ResolveResult {
/// Found in cache or local directory. /// Found in cache or local directory.
Cached(NodeId), Cached(NodeId),
/// Need to do a Kademlia FIND_VALUE — here are the closest known nodes. /// Need to do a Kademlia FIND_VALUE — here are the closest known nodes.
NeedsLookup { closest_nodes: Vec<(NodeId, SocketAddr)> }, NeedsLookup { closest_nodes: Vec<(NodeId, NodeAddr)> },
/// No nodes known at all. /// No nodes known at all.
NotFound, NotFound,
} }

View file

@ -3,12 +3,10 @@
//! Used by the runtime-dashboard to display distribution monitoring data //! Used by the runtime-dashboard to display distribution monitoring data
//! for a single node without reaching out to other nodes. //! for a single node without reaching out to other nodes.
use std::net::SocketAddr;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::node::DistributedNode; use crate::node::DistributedNode;
use crate::types::{MemberState, NodeId}; use crate::types::{MemberState, NodeAddr, NodeId};
/// Snapshot of a single member in the SWIM membership list. /// Snapshot of a single member in the SWIM membership list.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -33,6 +31,55 @@ pub struct CacheEntryInfo {
pub node_id: String, pub node_id: String,
} }
/// SWIM gossip protocol internal state.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GossipInfo {
/// Current tick counter.
pub tick: u64,
/// Monotonic ping sequence number (total pings sent).
pub sequence: u64,
/// This node's current incarnation number.
pub incarnation: u64,
/// Current probe phase: "idle", "waiting_direct_ack", "waiting_indirect_ack".
pub probe_phase: String,
/// Node being probed (if not idle).
pub probe_target: Option<String>,
/// Active suspicion timers.
pub suspicion_timers: Vec<SuspicionInfo>,
/// Pending gossip dissemination entries.
pub dissemination_queue: Vec<DisseminationInfo>,
/// Protocol configuration.
pub config: GossipConfig,
}
/// A single active suspicion timer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuspicionInfo {
pub node_id: String,
pub started_at: u64,
/// How many ticks this timer has been active.
pub age_ticks: u64,
}
/// A pending gossip dissemination entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisseminationInfo {
pub node_id: String,
pub state: String,
pub incarnation: u64,
/// Remaining piggyback transmissions before eviction.
pub remaining: usize,
}
/// SWIM protocol configuration values.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GossipConfig {
pub probe_interval: u64,
pub probe_timeout: u64,
pub indirect_probes: usize,
pub suspicion_timeout: u64,
}
/// Complete snapshot of a `DistributedNode`'s observable state. /// Complete snapshot of a `DistributedNode`'s observable state.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistributionNodeSnapshot { pub struct DistributionNodeSnapshot {
@ -74,13 +121,18 @@ pub struct DistributionNodeSnapshot {
// ─── Gossip pairs ──────────────────────────────────────────────── // ─── Gossip pairs ────────────────────────────────────────────────
/// Recent SWIM probe targets (most recent last). /// Recent SWIM probe targets (most recent last).
pub recent_probe_targets: Vec<String>, pub recent_probe_targets: Vec<String>,
// ─── Gossip protocol state ───────────────────────────────────────
/// SWIM gossip protocol internals (None for backward compat with old traces).
#[serde(default)]
pub gossip: Option<GossipInfo>,
} }
fn node_id_hex(id: &NodeId) -> String { fn node_id_hex(id: &NodeId) -> String {
id.0.iter().map(|b| format!("{:02x}", b)).collect() id.0.iter().map(|b| format!("{:02x}", b)).collect()
} }
fn addr_str(addr: &SocketAddr) -> String { fn addr_str(addr: &NodeAddr) -> String {
addr.to_string() addr.to_string()
} }
@ -136,6 +188,49 @@ impl DistributedNode {
.map(|id| node_id_hex(id)) .map(|id| node_id_hex(id))
.collect(); .collect();
// Build gossip info from SWIM internals
let swim = self.swim_node();
let probe = swim.probe();
let current_tick = probe.tick();
let cfg = probe.config();
let suspicion_timers: Vec<SuspicionInfo> = probe
.suspicion_timers()
.iter()
.map(|t| SuspicionInfo {
node_id: node_id_hex(&t.node_id),
started_at: t.started_at,
age_ticks: current_tick.saturating_sub(t.started_at),
})
.collect();
let dissemination_queue: Vec<DisseminationInfo> = swim
.dissemination()
.pending_entries()
.map(|(update, remaining)| DisseminationInfo {
node_id: node_id_hex(&update.node_id),
state: state_str(update.state),
incarnation: update.incarnation,
remaining,
})
.collect();
let gossip = GossipInfo {
tick: current_tick,
sequence: probe.sequence(),
incarnation: swim.members().self_incarnation(),
probe_phase: probe.phase_name().to_string(),
probe_target: probe.probe_target().map(|id| node_id_hex(&id)),
suspicion_timers,
dissemination_queue,
config: GossipConfig {
probe_interval: cfg.probe_interval,
probe_timeout: cfg.probe_timeout,
indirect_probes: cfg.indirect_probes,
suspicion_timeout: cfg.suspicion_timeout,
},
};
DistributionNodeSnapshot { DistributionNodeSnapshot {
node_id: node_id_hex(&self.node_id()), node_id: node_id_hex(&self.node_id()),
listen_addr: addr_str(&self.listen_addr()), listen_addr: addr_str(&self.listen_addr()),
@ -151,6 +246,7 @@ impl DistributedNode {
directory_entry_count: self.directory().entry_count(), directory_entry_count: self.directory().entry_count(),
repair_queue_size: self.repair_queue_len(), repair_queue_size: self.repair_queue_len(),
recent_probe_targets: recent_targets, recent_probe_targets: recent_targets,
gossip: Some(gossip),
} }
} }
} }

View file

@ -6,10 +6,8 @@
//! //!
//! Priority ordering: Dead > Suspect > Alive (most urgent first). //! Priority ordering: Dead > Suspect > Alive (most urgent first).
use std::net::SocketAddr;
use crate::messages::MembershipUpdate; use crate::messages::MembershipUpdate;
use crate::types::{MemberState, NodeId}; use crate::types::{MemberState, NodeAddr, NodeId};
/// A queued membership update with a remaining transmit budget. /// A queued membership update with a remaining transmit budget.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -110,6 +108,11 @@ impl DisseminationQueue {
self.entries.is_empty() self.entries.is_empty()
} }
/// Read-only view of queued entries: yields (update, remaining_transmits).
pub fn pending_entries(&self) -> impl Iterator<Item = (&MembershipUpdate, usize)> {
self.entries.iter().map(|e| (&e.update, e.remaining))
}
/// Compute the transmit budget: `Λ * ceil(log2(max(n, 2)))`. /// Compute the transmit budget: `Λ * ceil(log2(max(n, 2)))`.
fn transmit_budget(&self, cluster_size: usize) -> usize { fn transmit_budget(&self, cluster_size: usize) -> usize {
let n = cluster_size.max(2) as f64; let n = cluster_size.max(2) as f64;
@ -121,7 +124,7 @@ impl DisseminationQueue {
/// Convenience: create a `MembershipUpdate` from components. /// Convenience: create a `MembershipUpdate` from components.
pub fn membership_update( pub fn membership_update(
node_id: NodeId, node_id: NodeId,
addr: SocketAddr, addr: NodeAddr,
state: MemberState, state: MemberState,
incarnation: u64, incarnation: u64,
) -> MembershipUpdate { ) -> MembershipUpdate {

View file

@ -6,15 +6,14 @@
//! 2. Same incarnation: higher-priority state wins (Dead > Suspect > Alive). //! 2. Same incarnation: higher-priority state wins (Dead > Suspect > Alive).
use std::collections::HashMap; use std::collections::HashMap;
use std::net::SocketAddr;
use crate::types::{MemberState, NodeId, NodeRecord}; use crate::types::{MemberState, NodeAddr, NodeId, NodeRecord};
/// A single membership entry. /// A single membership entry.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MemberEntry { pub struct MemberEntry {
pub node_id: NodeId, pub node_id: NodeId,
pub addr: SocketAddr, pub addr: NodeAddr,
pub state: MemberState, pub state: MemberState,
pub incarnation: u64, pub incarnation: u64,
} }
@ -23,7 +22,7 @@ impl MemberEntry {
pub fn to_record(&self) -> NodeRecord { pub fn to_record(&self) -> NodeRecord {
NodeRecord { NodeRecord {
node_id: self.node_id, node_id: self.node_id,
addr: self.addr, addr: self.addr.clone(),
state: self.state, state: self.state,
incarnation: self.incarnation, incarnation: self.incarnation,
} }
@ -100,7 +99,7 @@ impl MemberList {
/// - Higher incarnation always wins. /// - Higher incarnation always wins.
/// - Same incarnation: higher-priority state wins. /// - Same incarnation: higher-priority state wins.
/// - Lower incarnation is ignored. /// - Lower incarnation is ignored.
pub fn apply(&mut self, node_id: NodeId, addr: SocketAddr, state: MemberState, incarnation: u64) -> bool { pub fn apply(&mut self, node_id: NodeId, addr: NodeAddr, state: MemberState, incarnation: u64) -> bool {
// Don't store entries about ourselves // Don't store entries about ourselves
if node_id == self.self_id { if node_id == self.self_id {
return false; return false;

View file

@ -3,10 +3,8 @@
//! This is the top-level SWIM state machine that a `DistributedNode` will drive. //! This is the top-level SWIM state machine that a `DistributedNode` will drive.
//! It produces `SwimAction`s that the caller translates into real network I/O. //! It produces `SwimAction`s that the caller translates into real network I/O.
use std::net::SocketAddr;
use crate::messages::MembershipUpdate; use crate::messages::MembershipUpdate;
use crate::types::{MemberState, NodeId, NodeRecord}; use crate::types::{MemberState, NodeAddr, NodeId, NodeRecord};
use super::dissemination::{membership_update, DisseminationQueue}; use super::dissemination::{membership_update, DisseminationQueue};
use super::member_list::MemberList; use super::member_list::MemberList;
@ -18,22 +16,22 @@ use super::probe::{SwimAction, SwimConfig, SwimEvent, SwimProbe};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum NodeAction { pub enum NodeAction {
/// Send a SWIM ping. /// Send a SWIM ping.
SendPing { to: NodeId, to_addr: SocketAddr, sequence: u64, piggyback: Vec<u8> }, SendPing { to: NodeId, to_addr: NodeAddr, sequence: u64, piggyback: Vec<u8> },
/// Send an indirect ping request through a relay. /// Send an indirect ping request through a relay.
SendPingReq { SendPingReq {
relay: NodeId, relay: NodeId,
relay_addr: SocketAddr, relay_addr: NodeAddr,
target: NodeId, target: NodeId,
target_addr: SocketAddr, target_addr: NodeAddr,
sequence: u64, sequence: u64,
piggyback: Vec<u8>, piggyback: Vec<u8>,
}, },
/// Send a SWIM ack. /// Send a SWIM ack.
SendAck { to: NodeId, to_addr: SocketAddr, sequence: u64, piggyback: Vec<u8> }, SendAck { to: NodeId, to_addr: NodeAddr, sequence: u64, piggyback: Vec<u8> },
/// Send a join request to a seed. /// Send a join request to a seed.
SendJoinRequest { to_addr: SocketAddr }, SendJoinRequest { to_addr: NodeAddr },
/// Send a join response with the current member list. /// Send a join response with the current member list.
SendJoinResponse { to: NodeId, to_addr: SocketAddr, members: Vec<NodeRecord> }, SendJoinResponse { to: NodeId, to_addr: NodeAddr, members: Vec<NodeRecord> },
/// Notification: a node state changed (for wiring into Kademlia). /// Notification: a node state changed (for wiring into Kademlia).
MembershipChanged { node_id: NodeId, state: MemberState, incarnation: u64 }, MembershipChanged { node_id: NodeId, state: MemberState, incarnation: u64 },
} }
@ -44,13 +42,13 @@ pub struct SwimNode {
members: MemberList, members: MemberList,
probe: SwimProbe, probe: SwimProbe,
dissemination: DisseminationQueue, dissemination: DisseminationQueue,
self_addr: SocketAddr, self_addr: NodeAddr,
/// Maximum piggybacked updates per message. /// Maximum piggybacked updates per message.
max_piggyback: usize, max_piggyback: usize,
} }
impl SwimNode { impl SwimNode {
pub fn new(self_id: NodeId, self_addr: SocketAddr, config: SwimConfig) -> Self { pub fn new(self_id: NodeId, self_addr: NodeAddr, config: SwimConfig) -> Self {
Self { Self {
members: MemberList::new(self_id), members: MemberList::new(self_id),
probe: SwimProbe::new(config), probe: SwimProbe::new(config),
@ -64,8 +62,8 @@ impl SwimNode {
self.members.self_id() self.members.self_id()
} }
pub fn self_addr(&self) -> SocketAddr { pub fn self_addr(&self) -> NodeAddr {
self.self_addr self.self_addr.clone()
} }
pub fn members(&self) -> &MemberList { pub fn members(&self) -> &MemberList {
@ -77,6 +75,16 @@ impl SwimNode {
self.probe.recent_probe_targets() self.probe.recent_probe_targets()
} }
/// Access the SWIM probe state machine.
pub fn probe(&self) -> &SwimProbe {
&self.probe
}
/// Access the dissemination queue.
pub fn dissemination(&self) -> &DisseminationQueue {
&self.dissemination
}
/// Process a tick — drives the probe cycle. /// Process a tick — drives the probe cycle.
pub fn tick(&mut self) -> Vec<NodeAction> { pub fn tick(&mut self) -> Vec<NodeAction> {
let probe_actions = self.probe.step(SwimEvent::Tick, &mut self.members); let probe_actions = self.probe.step(SwimEvent::Tick, &mut self.members);
@ -84,11 +92,11 @@ impl SwimNode {
} }
/// Handle a received ping. /// Handle a received ping.
pub fn handle_ping(&mut self, from: NodeId, from_addr: SocketAddr, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> { pub fn handle_ping(&mut self, from: NodeId, from_addr: NodeAddr, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
self.apply_piggyback(piggyback); self.apply_piggyback(piggyback);
// Ensure the sender is in our member list // Ensure the sender is in our member list
self.members.apply(from, from_addr, MemberState::Alive, 0); self.members.apply(from, from_addr.clone(), MemberState::Alive, 0);
// Reply with ack // Reply with ack
let pb = self.dissemination.pack_piggyback(self.max_piggyback); let pb = self.dissemination.pack_piggyback(self.max_piggyback);
@ -115,7 +123,7 @@ impl SwimNode {
&mut self, &mut self,
_from: NodeId, _from: NodeId,
target: NodeId, target: NodeId,
target_addr: SocketAddr, target_addr: NodeAddr,
sequence: u64, sequence: u64,
piggyback: &[u8], piggyback: &[u8],
) -> Vec<NodeAction> { ) -> Vec<NodeAction> {
@ -132,15 +140,15 @@ impl SwimNode {
} }
/// Handle a join request from a new node. /// Handle a join request from a new node.
pub fn handle_join_request(&mut self, from: NodeId, from_addr: SocketAddr) -> Vec<NodeAction> { pub fn handle_join_request(&mut self, from: NodeId, from_addr: NodeAddr) -> Vec<NodeAction> {
// Add the new node to our member list // Add the new node to our member list
let changed = self.members.apply(from, from_addr, MemberState::Alive, 0); let changed = self.members.apply(from, from_addr.clone(), MemberState::Alive, 0);
let mut actions = Vec::new(); let mut actions = Vec::new();
if changed { if changed {
// Enqueue the join for dissemination // Enqueue the join for dissemination
self.dissemination.enqueue( self.dissemination.enqueue(
membership_update(from, from_addr, MemberState::Alive, 0), membership_update(from, from_addr.clone(), MemberState::Alive, 0),
self.cluster_size(), self.cluster_size(),
); );
actions.push(NodeAction::MembershipChanged { actions.push(NodeAction::MembershipChanged {
@ -154,7 +162,7 @@ impl SwimNode {
let mut members = self.members.snapshot(); let mut members = self.members.snapshot();
members.push(NodeRecord { members.push(NodeRecord {
node_id: self.members.self_id(), node_id: self.members.self_id(),
addr: self.self_addr, addr: self.self_addr.clone(),
state: MemberState::Alive, state: MemberState::Alive,
incarnation: self.members.self_incarnation(), incarnation: self.members.self_incarnation(),
}); });
@ -189,10 +197,10 @@ impl SwimNode {
} }
/// Initiate joining a cluster by contacting seed nodes. /// Initiate joining a cluster by contacting seed nodes.
pub fn join(&self, seeds: &[SocketAddr]) -> Vec<NodeAction> { pub fn join(&self, seeds: &[NodeAddr]) -> Vec<NodeAction> {
seeds seeds
.iter() .iter()
.map(|addr| NodeAction::SendJoinRequest { to_addr: *addr }) .map(|addr| NodeAction::SendJoinRequest { to_addr: addr.clone() })
.collect() .collect()
} }
@ -201,7 +209,7 @@ impl SwimNode {
self.dissemination.enqueue( self.dissemination.enqueue(
membership_update( membership_update(
self.members.self_id(), self.members.self_id(),
self.self_addr, self.self_addr.clone(),
MemberState::Dead, MemberState::Dead,
self.members.self_incarnation(), self.members.self_incarnation(),
), ),
@ -230,7 +238,7 @@ impl SwimNode {
self.dissemination.enqueue( self.dissemination.enqueue(
membership_update( membership_update(
self.members.self_id(), self.members.self_id(),
self.self_addr, self.self_addr.clone(),
MemberState::Alive, MemberState::Alive,
new_inc, new_inc,
), ),
@ -242,7 +250,7 @@ impl SwimNode {
let changed = self.members.apply( let changed = self.members.apply(
update.node_id, update.node_id,
update.addr, update.addr.clone(),
update.state, update.state,
update.incarnation, update.incarnation,
); );
@ -283,7 +291,7 @@ impl SwimNode {
if self.members.suspect(node_id) { if self.members.suspect(node_id) {
if let Some(entry) = self.members.get(&node_id) { if let Some(entry) = self.members.get(&node_id) {
self.dissemination.enqueue( self.dissemination.enqueue(
membership_update(node_id, entry.addr, MemberState::Suspect, entry.incarnation), membership_update(node_id, entry.addr.clone(), MemberState::Suspect, entry.incarnation),
self.cluster_size(), self.cluster_size(),
); );
} }
@ -297,7 +305,7 @@ impl SwimNode {
SwimAction::DeclareDead(node_id) => { SwimAction::DeclareDead(node_id) => {
if let Some(entry) = self.members.get(&node_id) { if let Some(entry) = self.members.get(&node_id) {
let inc = entry.incarnation; let inc = entry.incarnation;
let addr = entry.addr; let addr = entry.addr.clone();
if self.members.declare_dead(node_id) { if self.members.declare_dead(node_id) {
self.dissemination.enqueue( self.dissemination.enqueue(
membership_update(node_id, addr, MemberState::Dead, inc), membership_update(node_id, addr, MemberState::Dead, inc),
@ -315,7 +323,7 @@ impl SwimNode {
self.dissemination.enqueue( self.dissemination.enqueue(
membership_update( membership_update(
self.members.self_id(), self.members.self_id(),
self.self_addr, self.self_addr.clone(),
MemberState::Alive, MemberState::Alive,
new_incarnation, new_incarnation,
), ),

View file

@ -4,9 +4,8 @@
//! No I/O, no timers — the caller drives the clock. //! No I/O, no timers — the caller drives the clock.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::net::SocketAddr;
use crate::types::NodeId; use crate::types::{NodeAddr, NodeId};
use super::member_list::MemberList; use super::member_list::MemberList;
@ -58,13 +57,13 @@ pub enum SwimEvent {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SwimAction { pub enum SwimAction {
/// Send a direct ping to a node. /// Send a direct ping to a node.
SendPing { to: NodeId, to_addr: SocketAddr, sequence: u64 }, SendPing { to: NodeId, to_addr: NodeAddr, sequence: u64 },
/// Send an indirect ping request through a relay. /// Send an indirect ping request through a relay.
SendPingReq { SendPingReq {
relay: NodeId, relay: NodeId,
relay_addr: SocketAddr, relay_addr: NodeAddr,
target: NodeId, target: NodeId,
target_addr: SocketAddr, target_addr: NodeAddr,
sequence: u64, sequence: u64,
}, },
/// A node is now suspected. /// A node is now suspected.
@ -84,7 +83,7 @@ enum ProbePhase {
/// Direct ping sent, waiting for ack. /// Direct ping sent, waiting for ack.
WaitingDirectAck { WaitingDirectAck {
target: NodeId, target: NodeId,
target_addr: SocketAddr, target_addr: NodeAddr,
sequence: u64, sequence: u64,
sent_at: u64, sent_at: u64,
}, },
@ -98,9 +97,9 @@ enum ProbePhase {
/// Suspicion timer for a single node. /// Suspicion timer for a single node.
#[derive(Debug)] #[derive(Debug)]
struct SuspicionTimer { pub struct SuspicionTimer {
node_id: NodeId, pub node_id: NodeId,
started_at: u64, pub started_at: u64,
} }
/// The SWIM probe state machine. /// The SWIM probe state machine.
@ -162,13 +161,51 @@ impl SwimProbe {
&self.recent_targets &self.recent_targets
} }
/// Current tick counter.
pub fn tick(&self) -> u64 {
self.tick
}
/// Monotonic ping sequence number (total pings sent).
pub fn sequence(&self) -> u64 {
self.sequence
}
/// Name of the current probe phase.
pub fn phase_name(&self) -> &str {
match &self.phase {
ProbePhase::Idle => "idle",
ProbePhase::WaitingDirectAck { .. } => "waiting_direct_ack",
ProbePhase::WaitingIndirectAck { .. } => "waiting_indirect_ack",
}
}
/// The node being probed in the current phase (None if idle).
pub fn probe_target(&self) -> Option<NodeId> {
match &self.phase {
ProbePhase::Idle => None,
ProbePhase::WaitingDirectAck { target, .. } => Some(*target),
ProbePhase::WaitingIndirectAck { target, .. } => Some(*target),
}
}
/// Active suspicion timers.
pub fn suspicion_timers(&self) -> &[SuspicionTimer] {
&self.suspicion_timers
}
/// Protocol configuration.
pub fn config(&self) -> &SwimConfig {
&self.config
}
fn next_sequence(&mut self) -> u64 { fn next_sequence(&mut self) -> u64 {
self.sequence += 1; self.sequence += 1;
self.sequence self.sequence
} }
/// Pick the next probe target using round-robin over a shuffled order. /// Pick the next probe target using round-robin over a shuffled order.
fn pick_probe_target(&mut self, members: &MemberList) -> Option<(NodeId, SocketAddr)> { fn pick_probe_target(&mut self, members: &MemberList) -> Option<(NodeId, NodeAddr)> {
let alive = members.alive_members(); let alive = members.alive_members();
if alive.is_empty() { if alive.is_empty() {
return None; return None;
@ -189,11 +226,11 @@ impl SwimProbe {
let target_id = self.probe_order[self.probe_index]; let target_id = self.probe_order[self.probe_index];
self.probe_index += 1; self.probe_index += 1;
members.get(&target_id).map(|e| (e.node_id, e.addr)) members.get(&target_id).map(|e| (e.node_id, e.addr.clone()))
} }
/// Pick `k` random relay nodes (excluding `target`). /// Pick `k` random relay nodes (excluding `target`).
fn pick_relays(&self, members: &MemberList, target: NodeId) -> Vec<(NodeId, SocketAddr)> { fn pick_relays(&self, members: &MemberList, target: NodeId) -> Vec<(NodeId, NodeAddr)> {
let alive: Vec<_> = members let alive: Vec<_> = members
.alive_members() .alive_members()
.into_iter() .into_iter()
@ -206,7 +243,7 @@ impl SwimProbe {
let mut relays = Vec::with_capacity(k); let mut relays = Vec::with_capacity(k);
for i in 0..k { for i in 0..k {
let idx = (start + i) % alive.len(); let idx = (start + i) % alive.len();
relays.push((alive[idx].node_id, alive[idx].addr)); relays.push((alive[idx].node_id, alive[idx].addr.clone()));
} }
relays relays
} }
@ -231,7 +268,7 @@ impl SwimProbe {
let seq = self.next_sequence(); let seq = self.next_sequence();
actions.push(SwimAction::SendPing { actions.push(SwimAction::SendPing {
to: target, to: target,
to_addr: target_addr, to_addr: target_addr.clone(),
sequence: seq, sequence: seq,
}); });
self.phase = ProbePhase::WaitingDirectAck { self.phase = ProbePhase::WaitingDirectAck {
@ -248,7 +285,7 @@ impl SwimProbe {
ProbePhase::WaitingDirectAck { target, target_addr, sequence, sent_at } => { ProbePhase::WaitingDirectAck { target, target_addr, sequence, sent_at } => {
if self.tick - sent_at >= self.config.probe_timeout { if self.tick - sent_at >= self.config.probe_timeout {
let target = *target; let target = *target;
let target_addr = *target_addr; let target_addr = target_addr.clone();
let sequence = *sequence; let sequence = *sequence;
// Send indirect probes through relays // Send indirect probes through relays
@ -258,7 +295,7 @@ impl SwimProbe {
relay, relay,
relay_addr, relay_addr,
target, target,
target_addr, target_addr: target_addr.clone(),
sequence, sequence,
}); });
} }
@ -350,7 +387,7 @@ impl MemberList {
fn clone_shallow(original: &MemberList) -> MemberList { fn clone_shallow(original: &MemberList) -> MemberList {
let mut copy = MemberList::new(original.self_id()); let mut copy = MemberList::new(original.self_id());
for entry in original.all_members() { for entry in original.all_members() {
copy.apply(entry.node_id, entry.addr, entry.state, entry.incarnation); copy.apply(entry.node_id, entry.addr.clone(), entry.state, entry.incarnation);
} }
copy copy
} }

View file

@ -16,6 +16,8 @@ use swactor::actor::ActorAddress;
use swactor::transport::{Transport, WireEnvelope}; use swactor::transport::{Transport, WireEnvelope};
use swactor::Error; use swactor::Error;
use crate::types::NodeAddr;
// ─── TcpTransport ────────────────────────────────────────────────────────── // ─── TcpTransport ──────────────────────────────────────────────────────────
/// TCP transport with connection pooling. /// TCP transport with connection pooling.
@ -29,13 +31,22 @@ pub struct TcpTransport {
default_dest: Option<SocketAddr>, default_dest: Option<SocketAddr>,
} }
impl TcpTransport { /// Extract the `SocketAddr` from a `NodeAddr::Tcp`. Returns an error for
/// Create a transport that sends to a specific destination. /// non-TCP variants.
pub fn new(dest: SocketAddr) -> Self { fn require_tcp(addr: &NodeAddr) -> Result<SocketAddr, Error> {
Self { match addr {
pool: Mutex::new(HashMap::new()), NodeAddr::Tcp(sa) => Ok(*sa),
default_dest: Some(dest),
} }
}
impl TcpTransport {
/// Create a transport that sends to a specific TCP destination.
pub fn new(dest: NodeAddr) -> Result<Self, Error> {
let sa = require_tcp(&dest)?;
Ok(Self {
pool: Mutex::new(HashMap::new()),
default_dest: Some(sa),
})
} }
/// Create a transport with no default destination. /// Create a transport with no default destination.
@ -67,8 +78,9 @@ impl TcpTransport {
} }
/// Send an envelope to a specific address. /// Send an envelope to a specific address.
pub fn send_to(&self, addr: SocketAddr, envelope: WireEnvelope) -> Result<(), Error> { pub fn send_to(&self, addr: &NodeAddr, envelope: WireEnvelope) -> Result<(), Error> {
let mut stream = self.get_or_connect(addr)?; let sa = require_tcp(addr)?;
let mut stream = self.get_or_connect(sa)?;
let buf = encode_wire_envelope(&envelope); let buf = encode_wire_envelope(&envelope);
stream stream
.write_all(&buf) .write_all(&buf)
@ -81,7 +93,11 @@ impl Transport for TcpTransport {
let dest = self let dest = self
.default_dest .default_dest
.ok_or_else(|| Error::from("TcpTransport: no default destination"))?; .ok_or_else(|| Error::from("TcpTransport: no default destination"))?;
self.send_to(dest, envelope) let mut stream = self.get_or_connect(dest)?;
let buf = encode_wire_envelope(&envelope);
stream
.write_all(&buf)
.map_err(|e| Error::from(format!("TCP send to {dest}: {e}")))
} }
} }
@ -158,19 +174,9 @@ impl TcpAcceptor {
// ─── Wire format encoding/decoding ───────────────────────────────────────── // ─── Wire format encoding/decoding ─────────────────────────────────────────
/// Encode a WireEnvelope to bytes in the length-prefixed wire format. // Re-export the canonical encode function from the core crate.
pub fn encode_wire_envelope(envelope: &WireEnvelope) -> Vec<u8> { pub use swactor::transport::encode_wire_envelope;
let tag_bytes = envelope.type_tag.as_bytes(); pub use swactor::transport::decode_wire_envelope;
let frame_len: u32 = (32 + 4 + tag_bytes.len() + envelope.payload.len()) as u32;
let mut buf = Vec::with_capacity(4 + frame_len as usize);
buf.extend_from_slice(&frame_len.to_be_bytes());
buf.extend_from_slice(&envelope.dest.0);
buf.extend_from_slice(&(tag_bytes.len() as u32).to_be_bytes());
buf.extend_from_slice(tag_bytes);
buf.extend_from_slice(&envelope.payload);
buf
}
enum ReadError { enum ReadError {
WouldBlock, WouldBlock,
@ -189,7 +195,7 @@ impl From<std::io::Error> for ReadError {
} }
} }
/// Read one WireEnvelope from a TCP stream. /// Read one WireEnvelope from a TCP stream (non-blocking).
fn read_wire_envelope(stream: &mut TcpStream) -> Result<WireEnvelope, ReadError> { fn read_wire_envelope(stream: &mut TcpStream) -> Result<WireEnvelope, ReadError> {
let mut len_buf = [0u8; 4]; let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?; stream.read_exact(&mut len_buf)?;
@ -198,24 +204,13 @@ fn read_wire_envelope(stream: &mut TcpStream) -> Result<WireEnvelope, ReadError>
let mut frame = vec![0u8; frame_len]; let mut frame = vec![0u8; frame_len];
stream.read_exact(&mut frame)?; stream.read_exact(&mut frame)?;
let mut dest = [0u8; 32]; decode_wire_envelope(&frame).map_err(|_| ReadError::Other(
dest.copy_from_slice(&frame[0..32]); std::io::Error::new(std::io::ErrorKind::InvalidData, "bad wire frame"),
))
let tag_len = u32::from_be_bytes(frame[32..36].try_into().unwrap()) as usize;
let type_tag = String::from_utf8_lossy(&frame[36..36 + tag_len]).to_string();
let payload = frame[36 + tag_len..].to_vec();
Ok(WireEnvelope {
dest: ActorAddress(dest),
type_tag,
payload,
})
} }
/// Read a single envelope from a blocking stream. Public for use in tests/examples. /// Read a single envelope from a blocking stream. Public for use in tests/examples.
pub fn read_envelope_blocking(stream: &mut TcpStream) -> Result<WireEnvelope, Error> { pub fn read_envelope_blocking(stream: &mut TcpStream) -> Result<WireEnvelope, Error> {
// Temporarily set blocking mode
stream stream
.set_nonblocking(false) .set_nonblocking(false)
.map_err(|e| Error::from(format!("set_blocking: {e}")))?; .map_err(|e| Error::from(format!("set_blocking: {e}")))?;
@ -229,20 +224,7 @@ pub fn read_envelope_blocking(stream: &mut TcpStream) -> Result<WireEnvelope, Er
stream stream
.read_exact(&mut frame) .read_exact(&mut frame)
.map_err(|e| Error::from(format!("read frame: {e}")))?; .map_err(|e| Error::from(format!("read frame: {e}")))?;
let mut dest = [0u8; 32];
dest.copy_from_slice(&frame[0..32]);
let tag_len = u32::from_be_bytes(frame[32..36].try_into().unwrap()) as usize;
let type_tag = String::from_utf8_lossy(&frame[36..36 + tag_len]).to_string();
let payload = frame[36 + tag_len..].to_vec();
let _ = stream.set_nonblocking(true); let _ = stream.set_nonblocking(true);
Ok(WireEnvelope { decode_wire_envelope(&frame)
dest: ActorAddress(dest),
type_tag,
payload,
})
} }

View file

@ -4,6 +4,38 @@ use std::net::SocketAddr;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
// ─── NodeAddr ───────────────────────────────────────────────────────────────
/// Extensible network address for cluster nodes.
///
/// Currently only TCP is supported; future variants (WebSocket, WebRTC) will
/// allow browser and other non-TCP peers to participate natively.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NodeAddr {
Tcp(SocketAddr),
// Future: Ws(String), WebRtc(String)
}
impl NodeAddr {
pub fn tcp(addr: SocketAddr) -> Self {
NodeAddr::Tcp(addr)
}
}
impl fmt::Display for NodeAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NodeAddr::Tcp(a) => write!(f, "tcp://{a}"),
}
}
}
impl From<SocketAddr> for NodeAddr {
fn from(addr: SocketAddr) -> Self {
NodeAddr::Tcp(addr)
}
}
// ─── NodeId ───────────────────────────────────────────────────────────────── // ─── NodeId ─────────────────────────────────────────────────────────────────
/// A node's identity — the raw bytes of an ed25519 public key. /// A node's identity — the raw bytes of an ed25519 public key.
@ -137,7 +169,7 @@ impl Ord for MemberState {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeRecord { pub struct NodeRecord {
pub node_id: NodeId, pub node_id: NodeId,
pub addr: SocketAddr, pub addr: NodeAddr,
pub state: MemberState, pub state: MemberState,
/// Incarnation number — bumped by the node itself to refute suspicion. /// Incarnation number — bumped by the node itself to refute suspicion.
pub incarnation: u64, pub incarnation: u64,

View file

@ -1,16 +1,13 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use distribution::kademlia::lookup::{LookupAction, NodeLookup}; use distribution::kademlia::lookup::{LookupAction, NodeLookup};
use distribution::kademlia::routing_table::RoutingTable; use distribution::kademlia::routing_table::RoutingTable;
use distribution::types::NodeId; use distribution::types::{NodeAddr, NodeId};
fn node(byte: u8) -> NodeId { fn node(byte: u8) -> NodeId {
NodeId([byte; 32]) NodeId([byte; 32])
} }
fn addr(port: u16) -> SocketAddr { fn addr(port: u16) -> NodeAddr {
format!("127.0.0.1:{port}").parse().unwrap() NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
} }
// ─── Basic lookup ─────────────────────────────────────────────────────────── // ─── Basic lookup ───────────────────────────────────────────────────────────

View file

@ -1,12 +1,12 @@
use distribution::kademlia::routing_table::RoutingTable; use distribution::kademlia::routing_table::RoutingTable;
use distribution::types::NodeId; use distribution::types::{NodeAddr, NodeId};
fn node(byte: u8) -> NodeId { fn node(byte: u8) -> NodeId {
NodeId([byte; 32]) NodeId([byte; 32])
} }
fn addr(port: u16) -> std::net::SocketAddr { fn addr(port: u16) -> NodeAddr {
format!("127.0.0.1:{port}").parse().unwrap() NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
} }
// ─── Basic operations ─────────────────────────────────────────────────────── // ─── Basic operations ───────────────────────────────────────────────────────

View file

@ -3,18 +3,16 @@
//! These tests verify the full composed behavior from a consumer's perspective: //! These tests verify the full composed behavior from a consumer's perspective:
//! cluster formation, actor registration/resolution, and fault tolerance. //! cluster formation, actor registration/resolution, and fault tolerance.
use std::net::SocketAddr;
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use distribution::crypto::Keypair; use distribution::crypto::Keypair;
use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult}; use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
use distribution::swim::node::NodeAction; use distribution::swim::node::NodeAction;
use distribution::swim::probe::SwimConfig; use distribution::swim::probe::SwimConfig;
use distribution::types::NodeId; use distribution::types::{NodeAddr, NodeId};
fn test_config(addr: &str) -> DistributedNodeConfig { fn test_config(addr: &str) -> DistributedNodeConfig {
DistributedNodeConfig { DistributedNodeConfig {
listen_addr: addr.parse().unwrap(), listen_addr: NodeAddr::Tcp(addr.parse().unwrap()),
swim: SwimConfig { swim: SwimConfig {
probe_interval: 1, probe_interval: 1,
probe_timeout: 3, probe_timeout: 3,
@ -31,15 +29,15 @@ fn test_config(addr: &str) -> DistributedNodeConfig {
fn deliver_actions( fn deliver_actions(
actions: &[NodeAction], actions: &[NodeAction],
sender_id: NodeId, sender_id: NodeId,
sender_addr: SocketAddr, sender_addr: NodeAddr,
nodes: &mut [(NodeId, SocketAddr, &mut DistributedNode)], nodes: &mut [(NodeId, NodeAddr, &mut DistributedNode)],
) -> Vec<NodeAction> { ) -> Vec<NodeAction> {
let mut responses = Vec::new(); let mut responses = Vec::new();
for action in actions { for action in actions {
match action { match action {
NodeAction::SendPing { to, sequence, piggyback, .. } => { NodeAction::SendPing { to, sequence, piggyback, .. } => {
if let Some((_, _, node)) = nodes.iter_mut().find(|(id, _, _)| id == to) { if let Some((_, _, node)) = nodes.iter_mut().find(|(id, _, _)| id == to) {
responses.extend(node.handle_ping(sender_id, sender_addr, *sequence, piggyback)); responses.extend(node.handle_ping(sender_id, sender_addr.clone(), *sequence, piggyback));
} }
} }
NodeAction::SendAck { to, sequence, piggyback, .. } => { NodeAction::SendAck { to, sequence, piggyback, .. } => {
@ -49,7 +47,7 @@ fn deliver_actions(
} }
NodeAction::SendJoinRequest { to_addr } => { NodeAction::SendJoinRequest { to_addr } => {
if let Some((_, _, node)) = nodes.iter_mut().find(|(_, addr, _)| addr == to_addr) { if let Some((_, _, node)) = nodes.iter_mut().find(|(_, addr, _)| addr == to_addr) {
responses.extend(node.handle_join_request(sender_id, sender_addr)); responses.extend(node.handle_join_request(sender_id, sender_addr.clone()));
} }
} }
NodeAction::SendJoinResponse { to, members, .. } => { NodeAction::SendJoinResponse { to, members, .. } => {
@ -59,7 +57,7 @@ fn deliver_actions(
} }
NodeAction::SendPingReq { relay, target, target_addr, sequence, piggyback, .. } => { NodeAction::SendPingReq { relay, target, target_addr, sequence, piggyback, .. } => {
if let Some((_, _, node)) = nodes.iter_mut().find(|(id, _, _)| id == relay) { if let Some((_, _, node)) = nodes.iter_mut().find(|(id, _, _)| id == relay) {
responses.extend(node.handle_ping_req(sender_id, *target, *target_addr, *sequence, piggyback)); responses.extend(node.handle_ping_req(sender_id, *target, target_addr.clone(), *sequence, piggyback));
} }
} }
NodeAction::MembershipChanged { .. } => { NodeAction::MembershipChanged { .. } => {
@ -84,16 +82,16 @@ fn two_node_cluster_forms_via_join() {
let joiner_addr = joiner.listen_addr(); let joiner_addr = joiner.listen_addr();
// When: the joiner sends a join request to the seed // When: the joiner sends a join request to the seed
let join_actions = joiner.join(&[seed_addr]); let join_actions = joiner.join(&[seed_addr.clone()]);
// Deliver join request to seed // Deliver join request to seed
let mut all_nodes: Vec<(NodeId, SocketAddr, &mut DistributedNode)> = vec![ let mut all_nodes: Vec<(NodeId, NodeAddr, &mut DistributedNode)> = vec![
(seed_id, seed_addr, &mut seed), (seed_id, seed_addr.clone(), &mut seed),
]; ];
let responses = deliver_actions(&join_actions, joiner_id, joiner_addr, &mut all_nodes); let responses = deliver_actions(&join_actions, joiner_id, joiner_addr.clone(), &mut all_nodes);
// Deliver join response back to joiner // Deliver join response back to joiner
let mut all_nodes: Vec<(NodeId, SocketAddr, &mut DistributedNode)> = vec![ let mut all_nodes: Vec<(NodeId, NodeAddr, &mut DistributedNode)> = vec![
(joiner_id, joiner_addr, &mut joiner), (joiner_id, joiner_addr, &mut joiner),
]; ];
let _ = deliver_actions(&responses, seed_id, seed_addr, &mut all_nodes); let _ = deliver_actions(&responses, seed_id, seed_addr, &mut all_nodes);
@ -124,9 +122,9 @@ fn joined_node_appears_in_routing_table() {
let joiner_addr = joiner.listen_addr(); let joiner_addr = joiner.listen_addr();
// When: join completes // When: join completes
let actions = joiner.join(&[seed_addr]); let actions = joiner.join(&[seed_addr.clone()]);
let mut nodes = vec![(seed_id, seed_addr, &mut seed)]; let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
let responses = deliver_actions(&actions, joiner_id, joiner_addr, &mut nodes); let responses = deliver_actions(&actions, joiner_id, joiner_addr.clone(), &mut nodes);
let mut nodes = vec![(joiner_id, joiner_addr, &mut joiner)]; let mut nodes = vec![(joiner_id, joiner_addr, &mut joiner)];
let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes); let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes);
@ -169,9 +167,9 @@ fn unknown_actor_returns_needs_lookup_when_peers_known() {
let joiner_id = joiner.node_id(); let joiner_id = joiner.node_id();
let joiner_addr = joiner.listen_addr(); let joiner_addr = joiner.listen_addr();
let actions = joiner.join(&[seed_addr]); let actions = joiner.join(&[seed_addr.clone()]);
let mut nodes = vec![(seed_id, seed_addr, &mut seed)]; let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
let responses = deliver_actions(&actions, joiner_id, joiner_addr, &mut nodes); let responses = deliver_actions(&actions, joiner_id, joiner_addr.clone(), &mut nodes);
let mut nodes = vec![(joiner_id, joiner_addr, &mut joiner)]; let mut nodes = vec![(joiner_id, joiner_addr, &mut joiner)];
let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes); let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes);
@ -240,9 +238,9 @@ fn cache_invalidation_forces_re_lookup() {
let node_addr = node.listen_addr(); let node_addr = node.listen_addr();
// Form cluster // Form cluster
let actions = node.join(&[seed_addr]); let actions = node.join(&[seed_addr.clone()]);
let mut nodes = vec![(seed_id, seed_addr, &mut seed)]; let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
let responses = deliver_actions(&actions, node_id, node_addr, &mut nodes); let responses = deliver_actions(&actions, node_id, node_addr.clone(), &mut nodes);
let mut nodes = vec![(node_id, node_addr, &mut node)]; let mut nodes = vec![(node_id, node_addr, &mut node)];
let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes); let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes);
@ -271,7 +269,7 @@ fn node_death_clears_routing_table_and_cache_entries() {
let kp_peer = Keypair::generate(); let kp_peer = Keypair::generate();
let mut node = DistributedNode::new(test_config("127.0.0.1:9071")); let mut node = DistributedNode::new(test_config("127.0.0.1:9071"));
let peer_id = kp_peer.node_id(); let peer_id = kp_peer.node_id();
let peer_addr: SocketAddr = "127.0.0.1:9072".parse().unwrap(); let peer_addr: NodeAddr = NodeAddr::Tcp("127.0.0.1:9072".parse().unwrap());
// Simulate peer being known: handle a join so it's in routing table + members // Simulate peer being known: handle a join so it's in routing table + members
let _ = node.handle_join_request(peer_id, peer_addr); let _ = node.handle_join_request(peer_id, peer_addr);
@ -314,9 +312,9 @@ fn graceful_leave_disseminates_death_on_next_probe() {
let node_id = node.node_id(); let node_id = node.node_id();
let node_addr = node.listen_addr(); let node_addr = node.listen_addr();
let actions = node.join(&[seed_addr]); let actions = node.join(&[seed_addr.clone()]);
let mut nodes = vec![(seed_id, seed_addr, &mut seed)]; let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
let responses = deliver_actions(&actions, node_id, node_addr, &mut nodes); let responses = deliver_actions(&actions, node_id, node_addr.clone(), &mut nodes);
let mut nodes = vec![(node_id, node_addr, &mut node)]; let mut nodes = vec![(node_id, node_addr, &mut node)];
let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes); let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes);
@ -348,9 +346,9 @@ fn tick_produces_swim_probe_actions_when_peers_present() {
let node_id = node.node_id(); let node_id = node.node_id();
let node_addr = node.listen_addr(); let node_addr = node.listen_addr();
let actions = node.join(&[seed_addr]); let actions = node.join(&[seed_addr.clone()]);
let mut nodes = vec![(seed_id, seed_addr, &mut seed)]; let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
let responses = deliver_actions(&actions, node_id, node_addr, &mut nodes); let responses = deliver_actions(&actions, node_id, node_addr.clone(), &mut nodes);
let mut nodes = vec![(node_id, node_addr, &mut node)]; let mut nodes = vec![(node_id, node_addr, &mut node)];
let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes); let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes);

View file

@ -1,12 +1,12 @@
use distribution::swim::dissemination::{membership_update, DisseminationQueue}; use distribution::swim::dissemination::{membership_update, DisseminationQueue};
use distribution::types::{MemberState, NodeId}; use distribution::types::{MemberState, NodeAddr, NodeId};
fn node(byte: u8) -> NodeId { fn node(byte: u8) -> NodeId {
NodeId([byte; 32]) NodeId([byte; 32])
} }
fn addr(port: u16) -> std::net::SocketAddr { fn addr(port: u16) -> NodeAddr {
format!("127.0.0.1:{port}").parse().unwrap() NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
} }
// ─── Basic queue operations ───────────────────────────────────────────────── // ─── Basic queue operations ─────────────────────────────────────────────────

View file

@ -1,13 +1,13 @@
use distribution::swim::node::{NodeAction, SwimNode}; use distribution::swim::node::{NodeAction, SwimNode};
use distribution::swim::probe::SwimConfig; use distribution::swim::probe::SwimConfig;
use distribution::types::{MemberState, NodeId, NodeRecord}; use distribution::types::{MemberState, NodeAddr, NodeId, NodeRecord};
fn node(byte: u8) -> NodeId { fn node(byte: u8) -> NodeId {
NodeId([byte; 32]) NodeId([byte; 32])
} }
fn addr(port: u16) -> std::net::SocketAddr { fn addr(port: u16) -> NodeAddr {
format!("127.0.0.1:{port}").parse().unwrap() NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
} }
fn fast_config() -> SwimConfig { fn fast_config() -> SwimConfig {

View file

@ -1,15 +1,13 @@
use std::net::SocketAddr;
use distribution::swim::member_list::MemberList; use distribution::swim::member_list::MemberList;
use distribution::swim::probe::{SwimAction, SwimConfig, SwimEvent, SwimProbe}; use distribution::swim::probe::{SwimAction, SwimConfig, SwimEvent, SwimProbe};
use distribution::types::{MemberState, NodeId}; use distribution::types::{MemberState, NodeAddr, NodeId};
fn node(byte: u8) -> NodeId { fn node(byte: u8) -> NodeId {
NodeId([byte; 32]) NodeId([byte; 32])
} }
fn addr(port: u16) -> SocketAddr { fn addr(port: u16) -> NodeAddr {
format!("127.0.0.1:{port}").parse().unwrap() NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
} }
fn tick_n(probe: &mut SwimProbe, members: &mut MemberList, n: u64) -> Vec<SwimAction> { fn tick_n(probe: &mut SwimProbe, members: &mut MemberList, n: u64) -> Vec<SwimAction> {

View file

@ -4,7 +4,7 @@ use swactor::transport::WireEnvelope;
use distribution::codec::distribution_codec_registry; use distribution::codec::distribution_codec_registry;
use distribution::messages::*; use distribution::messages::*;
use distribution::transport::{TcpAcceptor, TcpTransport}; use distribution::transport::{TcpAcceptor, TcpTransport};
use distribution::types::NodeId; use distribution::types::{NodeAddr, NodeId};
// ─── Wire format round-trip ───────────────────────────────────────────────── // ─── Wire format round-trip ─────────────────────────────────────────────────
@ -20,9 +20,10 @@ fn wire_envelope_roundtrips_through_tcp() {
}; };
let original_clone = original.clone(); let original_clone = original.clone();
let node_addr = NodeAddr::Tcp(addr);
let sender = std::thread::spawn(move || { let sender = std::thread::spawn(move || {
let transport = TcpTransport::new(addr); let transport = TcpTransport::new(node_addr.clone()).unwrap();
transport.send_to(addr, original_clone).unwrap(); transport.send_to(&node_addr, original_clone).unwrap();
}); });
std::thread::sleep(std::time::Duration::from_millis(50)); std::thread::sleep(std::time::Duration::from_millis(50));
@ -56,9 +57,10 @@ fn wire_envelope_minimal_roundtrips() {
}; };
let original_clone = original.clone(); let original_clone = original.clone();
let node_addr = NodeAddr::Tcp(addr);
let sender = std::thread::spawn(move || { let sender = std::thread::spawn(move || {
let transport = TcpTransport::new(addr); let transport = TcpTransport::new(node_addr.clone()).unwrap();
transport.send_to(addr, original_clone).unwrap(); transport.send_to(&node_addr, original_clone).unwrap();
}); });
std::thread::sleep(std::time::Duration::from_millis(50)); std::thread::sleep(std::time::Duration::from_millis(50));
@ -102,8 +104,8 @@ fn distribution_codec_encodes_and_decodes_find_value_response() {
let codecs = distribution_codec_registry(); let codecs = distribution_codec_registry();
let resp = FindValueResponse::Closer(vec![ let resp = FindValueResponse::Closer(vec![
(NodeId([0x11; 32]), "127.0.0.1:8080".parse().unwrap()), (NodeId([0x11; 32]), NodeAddr::Tcp("127.0.0.1:8080".parse().unwrap())),
(NodeId([0x22; 32]), "127.0.0.1:8081".parse().unwrap()), (NodeId([0x22; 32]), NodeAddr::Tcp("127.0.0.1:8081".parse().unwrap())),
]); ]);
let type_id = std::any::TypeId::of::<FindValueResponse>(); let type_id = std::any::TypeId::of::<FindValueResponse>();
@ -173,9 +175,10 @@ fn ping_message_survives_codec_and_tcp_roundtrip() {
}; };
let envelope_clone = envelope.clone(); let envelope_clone = envelope.clone();
let server_node_addr = NodeAddr::Tcp(server_addr);
let sender = std::thread::spawn(move || { let sender = std::thread::spawn(move || {
let transport = TcpTransport::new(server_addr); let transport = TcpTransport::new(server_node_addr.clone()).unwrap();
transport.send_to(server_addr, envelope_clone).unwrap(); transport.send_to(&server_node_addr, envelope_clone).unwrap();
}); });
std::thread::sleep(std::time::Duration::from_millis(50)); std::thread::sleep(std::time::Duration::from_millis(50));

View file

@ -1,6 +1,6 @@
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use distribution::crypto::{self, Keypair}; use distribution::crypto::{self, Keypair};
use distribution::types::{DirectoryEntry, MemberState, NodeId, NodeRecord, Signature}; use distribution::types::{DirectoryEntry, MemberState, NodeAddr, NodeId, NodeRecord, Signature};
// ─── Keypair generation and identity ──────────────────────────────────────── // ─── Keypair generation and identity ────────────────────────────────────────
@ -131,7 +131,7 @@ fn node_record_serde_roundtrip() {
let kp = Keypair::generate(); let kp = Keypair::generate();
let record = NodeRecord { let record = NodeRecord {
node_id: kp.node_id(), node_id: kp.node_id(),
addr: "127.0.0.1:8080".parse().unwrap(), addr: NodeAddr::Tcp("127.0.0.1:8080".parse().unwrap()),
state: MemberState::Alive, state: MemberState::Alive,
incarnation: 5, incarnation: 5,
}; };

11
crates/gateway/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "swactor-gateway"
version = "0.1.0"
edition = "2024"
[dependencies]
swactor = { path = "../..", features = ["transport"] }
distribution = { path = "../distribution" }
tungstenite = "0.26"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

231
crates/gateway/src/lib.rs Normal file
View file

@ -0,0 +1,231 @@
//! WebSocket gateway bridging browser clients into a swactor cluster.
//!
//! A native cluster node runs `WsGateway` to accept WebSocket connections
//! from browsers and route messages between browser actors and the cluster.
pub mod ws_acceptor;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use swactor::actor::ActorAddress;
use swactor::runtime::Runtime;
use swactor::transport::{
encode_wire_envelope, CodecRegistry, Transport, TransportRouter, WireEnvelope,
};
use swactor::Error;
use crate::ws_acceptor::{SessionId, WsAcceptor, WsSession};
/// Control protocol types shared with the browser crate.
/// Kept in sync with `crates/wasm/src/protocol.rs`.
mod protocol {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GatewayControl {
RegisterActor { addr: [u8; 32] },
UnregisterActor { addr: [u8; 32] },
ResolveActor { name: String },
ActorResolved { name: String, addr: [u8; 32] },
Ping,
Pong,
}
pub const CONTROL_TYPE_TAG: &str = "swactor::GatewayControl";
pub const NULL_ADDRESS: [u8; 32] = [0u8; 32];
}
use protocol::*;
/// Per-browser session state.
struct BrowserSession {
/// Actor addresses owned by this browser session.
actors: Vec<ActorAddress>,
}
/// Transport implementation that routes messages to a browser session.
///
/// Registered in the `TransportRouter` for browser-owned actor addresses,
/// so cluster actors sending to those addresses get routed through the gateway.
struct SessionTransport {
/// Index into the gateway's session list. Used to find the WsSession at send time.
session_id: SessionId,
/// Shared reference to the gateway's session list.
sessions: Arc<std::sync::Mutex<Vec<WsSession>>>,
}
// The Mutex<Vec<WsSession>> requires Send+Sync on WsSession.
// WsSession contains a tungstenite WebSocket<TcpStream>, which is Send but not Sync.
// Since we protect access with a Mutex, this is safe.
unsafe impl Sync for SessionTransport {}
impl Transport for SessionTransport {
fn send(&self, envelope: WireEnvelope) -> Result<(), Error> {
let buf = encode_wire_envelope(&envelope);
let mut sessions = self.sessions.lock().unwrap();
if let Some(session) = sessions.iter_mut().find(|s| s.id == self.session_id) {
session.send_binary(&buf)?;
session.flush()?;
Ok(())
} else {
Err(Error::from(format!(
"session {} disconnected",
self.session_id.0
)))
}
}
}
/// WebSocket gateway that bridges browser clients into the actor runtime.
///
/// Call [`tick()`](Self::tick) periodically to accept connections, receive
/// messages, and route them into the runtime.
pub struct WsGateway {
acceptor: WsAcceptor,
/// All active WebSocket sessions, shared with SessionTransport instances.
sessions: Arc<std::sync::Mutex<Vec<WsSession>>>,
/// Maps session ID → browser session metadata.
browser_sessions: HashMap<SessionId, BrowserSession>,
/// Reverse map: actor address → session ID (for cleanup on disconnect).
browser_actors: HashMap<ActorAddress, SessionId>,
}
impl WsGateway {
/// Create a new gateway bound to the given address.
pub fn bind(addr: SocketAddr) -> Result<Self, Error> {
let acceptor = WsAcceptor::bind(addr)?;
Ok(Self {
acceptor,
sessions: Arc::new(std::sync::Mutex::new(Vec::new())),
browser_sessions: HashMap::new(),
browser_actors: HashMap::new(),
})
}
/// The local address this gateway is listening on.
pub fn local_addr(&self) -> SocketAddr {
self.acceptor.local_addr()
}
/// Drive one tick of the gateway: accept connections, receive messages,
/// and route them into the runtime.
///
/// Returns the number of envelopes processed.
pub fn tick(
&mut self,
runtime: &Runtime,
codec_registry: &CodecRegistry,
transport_router: &TransportRouter,
) -> usize {
let mut sessions = self.sessions.lock().unwrap();
let envelopes = self.acceptor.try_recv(&mut sessions);
// Track new sessions
for session in sessions.iter() {
self.browser_sessions
.entry(session.id)
.or_insert_with(|| BrowserSession {
actors: Vec::new(),
});
}
// Clean up disconnected sessions
let active_ids: Vec<SessionId> = sessions.iter().map(|s| s.id).collect();
let removed: Vec<SessionId> = self
.browser_sessions
.keys()
.copied()
.filter(|id| !active_ids.contains(id))
.collect();
for id in &removed {
if let Some(bs) = self.browser_sessions.remove(id) {
for addr in &bs.actors {
self.browser_actors.remove(addr);
}
}
}
drop(sessions); // Release the lock before processing envelopes
let count = envelopes.len();
for (envelope, session_id) in envelopes {
// Check if this is a control message
if envelope.dest.0 == NULL_ADDRESS && envelope.type_tag == CONTROL_TYPE_TAG {
self.handle_control(&envelope.payload, session_id, transport_router);
continue;
}
// Regular message — deliver to the runtime
match codec_registry.receive(envelope) {
Ok((addr, msg)) => {
let _ = runtime.deliver_raw(addr, msg);
}
Err(_) => {
// Unknown type_tag or decode failure — drop the message
}
}
}
count
}
fn handle_control(
&mut self,
payload: &[u8],
session_id: SessionId,
transport_router: &TransportRouter,
) {
let ctrl: GatewayControl = match serde_json::from_slice(payload) {
Ok(c) => c,
Err(_) => return,
};
match ctrl {
GatewayControl::RegisterActor { addr } => {
let actor_addr = ActorAddress(addr);
self.browser_actors.insert(actor_addr, session_id);
if let Some(bs) = self.browser_sessions.get_mut(&session_id) {
bs.actors.push(actor_addr);
}
// Register a transport route so cluster messages to this address
// get forwarded to the browser session.
let transport = Arc::new(SessionTransport {
session_id,
sessions: self.sessions.clone(),
});
transport_router.add_route(actor_addr, transport);
}
GatewayControl::UnregisterActor { addr } => {
let actor_addr = ActorAddress(addr);
self.browser_actors.remove(&actor_addr);
if let Some(bs) = self.browser_sessions.get_mut(&session_id) {
bs.actors.retain(|a| *a != actor_addr);
}
}
GatewayControl::Ping => {
// Send Pong back to the session
let pong_payload = serde_json::to_vec(&GatewayControl::Pong).unwrap();
let pong_envelope = WireEnvelope {
dest: ActorAddress(NULL_ADDRESS),
type_tag: CONTROL_TYPE_TAG.to_string(),
payload: pong_payload,
};
let buf = encode_wire_envelope(&pong_envelope);
let mut sessions = self.sessions.lock().unwrap();
if let Some(session) = sessions.iter_mut().find(|s| s.id == session_id) {
let _ = session.send_binary(&buf);
let _ = session.flush();
}
}
GatewayControl::ResolveActor { .. } | GatewayControl::ActorResolved { .. } => {
// TODO: implement actor resolution via the cluster directory
}
GatewayControl::Pong => {
// Keepalive response — nothing to do
}
}
}
}

View file

@ -0,0 +1,152 @@
//! WebSocket acceptor for the gateway.
//!
//! Mirrors the `TcpAcceptor` pattern from the distribution crate:
//! non-blocking accept + read loop, dead connection cleanup.
use std::net::{SocketAddr, TcpListener, TcpStream};
use tungstenite::protocol::Message as WsMessage;
use tungstenite::WebSocket;
use swactor::transport::{decode_wire_envelope, WireEnvelope};
use swactor::Error;
/// Opaque session identifier for a connected browser.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SessionId(pub usize);
/// A connected WebSocket client session.
pub struct WsSession {
pub id: SessionId,
ws: WebSocket<TcpStream>,
peer: SocketAddr,
}
impl WsSession {
/// The peer address of this session.
pub fn peer_addr(&self) -> SocketAddr {
self.peer
}
/// Send a raw binary frame to this session.
pub fn send_binary(&mut self, data: &[u8]) -> Result<(), Error> {
self.ws
.send(WsMessage::Binary(data.to_vec().into()))
.map_err(|e| Error::from(format!("ws send to session {}: {e}", self.id.0)))
}
/// Flush the WebSocket write buffer.
pub fn flush(&mut self) -> Result<(), Error> {
self.ws.flush()
.map_err(|e| Error::from(format!("ws flush session {}: {e}", self.id.0)))
}
}
/// Non-blocking WebSocket acceptor.
///
/// Accepts new TCP connections, upgrades them to WebSocket, and reads
/// binary frames from all active sessions.
pub struct WsAcceptor {
listener: TcpListener,
next_session_id: usize,
}
impl WsAcceptor {
/// Bind to a local address.
pub fn bind(addr: SocketAddr) -> Result<Self, Error> {
let listener = TcpListener::bind(addr)
.map_err(|e| Error::from(format!("WsAcceptor bind {addr}: {e}")))?;
listener
.set_nonblocking(true)
.map_err(|e| Error::from(format!("set_nonblocking: {e}")))?;
Ok(Self {
listener,
next_session_id: 0,
})
}
/// The local address this acceptor is bound to.
pub fn local_addr(&self) -> SocketAddr {
self.listener.local_addr().unwrap()
}
/// Non-blocking: accept new WebSocket connections, read envelopes from all sessions.
///
/// Returns all envelopes that could be read without blocking, tagged with the session ID.
pub fn try_recv(&mut self, sessions: &mut Vec<WsSession>) -> Vec<(WireEnvelope, SessionId)> {
// Accept new TCP connections and upgrade to WebSocket
loop {
match self.listener.accept() {
Ok((stream, peer)) => {
// WebSocket handshake (briefly blocking per new client)
let _ = stream.set_nonblocking(false);
match tungstenite::accept(stream) {
Ok(ws) => {
let id = SessionId(self.next_session_id);
self.next_session_id += 1;
// Switch back to non-blocking for reads
if let Ok(raw) = ws.get_ref().try_clone() {
let _ = raw.set_nonblocking(true);
}
sessions.push(WsSession { id, ws, peer });
}
Err(_) => {
// Handshake failed — skip this connection
}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
// Read from all sessions
let mut envelopes = Vec::new();
let mut dead = Vec::new();
for (i, session) in sessions.iter_mut().enumerate() {
loop {
match session.ws.read() {
Ok(WsMessage::Binary(data)) => {
// Wire format: 4-byte length prefix + frame.
// Browser sends the full encode_wire_envelope output.
if data.len() >= 4 {
let frame = &data[4..];
if let Ok(env) = decode_wire_envelope(frame) {
envelopes.push((env, session.id));
}
}
}
Ok(WsMessage::Ping(payload)) => {
let _ = session.ws.send(WsMessage::Pong(payload));
}
Ok(WsMessage::Close(_)) => {
dead.push(i);
break;
}
Ok(_) => {
// Text frames, pongs, etc. — ignore
}
Err(tungstenite::Error::Io(ref e))
if e.kind() == std::io::ErrorKind::WouldBlock =>
{
break;
}
Err(_) => {
dead.push(i);
break;
}
}
}
}
// Remove dead sessions in reverse order
dead.sort_unstable();
dead.dedup();
for i in dead.into_iter().rev() {
sessions.swap_remove(i);
}
envelopes
}
}

View file

@ -12,7 +12,7 @@ use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
use distribution::snapshot::DistributionNodeSnapshot; use distribution::snapshot::DistributionNodeSnapshot;
use distribution::swim::node::NodeAction; use distribution::swim::node::NodeAction;
use distribution::swim::probe::SwimConfig; use distribution::swim::probe::SwimConfig;
use distribution::types::NodeId; use distribution::types::{NodeAddr, NodeId};
use runtime_dashboard::collector::StatsCollector; use runtime_dashboard::collector::StatsCollector;
use runtime_dashboard::distribution_collector::DistributionStatsProvider; use runtime_dashboard::distribution_collector::DistributionStatsProvider;
@ -86,7 +86,7 @@ impl DistributionStatsProvider for SnapshotProvider {
fn tick_all_and_deliver( fn tick_all_and_deliver(
nodes: &mut [Option<DistributedNode>], nodes: &mut [Option<DistributedNode>],
node_ids: &[NodeId], node_ids: &[NodeId],
addrs: &[SocketAddr], addrs: &[NodeAddr],
) { ) {
let n = nodes.len(); let n = nodes.len();
@ -106,7 +106,7 @@ fn tick_all_and_deliver(
let tagged_responses = deliver_actions_tagged( let tagged_responses = deliver_actions_tagged(
&actions, &actions,
node_ids[sender_idx], node_ids[sender_idx],
addrs[sender_idx], addrs[sender_idx].clone(),
nodes, nodes,
node_ids, node_ids,
addrs, addrs,
@ -115,7 +115,7 @@ fn tick_all_and_deliver(
deliver_actions_tagged( deliver_actions_tagged(
&response_actions, &response_actions,
node_ids[responder_idx], node_ids[responder_idx],
addrs[responder_idx], addrs[responder_idx].clone(),
nodes, nodes,
node_ids, node_ids,
addrs, addrs,
@ -130,10 +130,10 @@ fn tick_all_and_deliver(
fn deliver_actions_tagged( fn deliver_actions_tagged(
actions: &[NodeAction], actions: &[NodeAction],
sender_id: NodeId, sender_id: NodeId,
sender_addr: SocketAddr, sender_addr: NodeAddr,
nodes: &mut [Option<DistributedNode>], nodes: &mut [Option<DistributedNode>],
node_ids: &[NodeId], node_ids: &[NodeId],
node_addrs: &[SocketAddr], node_addrs: &[NodeAddr],
) -> Vec<(usize, Vec<NodeAction>)> { ) -> Vec<(usize, Vec<NodeAction>)> {
let mut tagged_responses: Vec<(usize, Vec<NodeAction>)> = Vec::new(); let mut tagged_responses: Vec<(usize, Vec<NodeAction>)> = Vec::new();
@ -148,7 +148,7 @@ fn deliver_actions_tagged(
if let Some(idx) = node_ids.iter().position(|id| id == to) { if let Some(idx) = node_ids.iter().position(|id| id == to) {
if let Some(ref mut node) = nodes[idx] { if let Some(ref mut node) = nodes[idx] {
let resp = let resp =
node.handle_ping(sender_id, sender_addr, *sequence, piggyback); node.handle_ping(sender_id, sender_addr.clone(), *sequence, piggyback);
if !resp.is_empty() { if !resp.is_empty() {
tagged_responses.push((idx, resp)); tagged_responses.push((idx, resp));
} }
@ -173,7 +173,7 @@ fn deliver_actions_tagged(
NodeAction::SendJoinRequest { to_addr } => { NodeAction::SendJoinRequest { to_addr } => {
if let Some(idx) = node_addrs.iter().position(|a| a == to_addr) { if let Some(idx) = node_addrs.iter().position(|a| a == to_addr) {
if let Some(ref mut node) = nodes[idx] { if let Some(ref mut node) = nodes[idx] {
let resp = node.handle_join_request(sender_id, sender_addr); let resp = node.handle_join_request(sender_id, sender_addr.clone());
if !resp.is_empty() { if !resp.is_empty() {
tagged_responses.push((idx, resp)); tagged_responses.push((idx, resp));
} }
@ -203,7 +203,7 @@ fn deliver_actions_tagged(
let resp = node.handle_ping_req( let resp = node.handle_ping_req(
sender_id, sender_id,
*target, *target,
*target_addr, target_addr.clone(),
*sequence, *sequence,
piggyback, piggyback,
); );
@ -282,12 +282,13 @@ fn main() {
let num_nodes = 9; // 1 main + 8 peers let num_nodes = 9; // 1 main + 8 peers
let mut nodes: Vec<Option<DistributedNode>> = Vec::with_capacity(num_nodes); let mut nodes: Vec<Option<DistributedNode>> = Vec::with_capacity(num_nodes);
let mut node_ids: Vec<NodeId> = Vec::with_capacity(num_nodes); let mut node_ids: Vec<NodeId> = Vec::with_capacity(num_nodes);
let mut addrs: Vec<SocketAddr> = Vec::with_capacity(num_nodes); let mut addrs: Vec<NodeAddr> = Vec::with_capacity(num_nodes);
for i in 0..num_nodes { for i in 0..num_nodes {
let addr: SocketAddr = format!("127.0.0.1:{}", 7000 + i).parse().unwrap(); let sock_addr: SocketAddr = format!("127.0.0.1:{}", 7000 + i).parse().unwrap();
let addr = NodeAddr::Tcp(sock_addr);
let config = DistributedNodeConfig { let config = DistributedNodeConfig {
listen_addr: addr, listen_addr: addr.clone(),
swim: swim_config.clone(), swim: swim_config.clone(),
cache_capacity: if i == 0 { 1000 } else { 100 }, cache_capacity: if i == 0 { 1000 } else { 100 },
republish_interval: 500, republish_interval: 500,
@ -299,13 +300,13 @@ fn main() {
} }
// Join handshakes: nodes[1..] join via seed (node 0). // Join handshakes: nodes[1..] join via seed (node 0).
let seed_addr = addrs[0]; let seed_addr = addrs[0].clone();
for i in 1..num_nodes { for i in 1..num_nodes {
let join_actions = nodes[i].as_ref().unwrap().join(&[seed_addr]); let join_actions = nodes[i].as_ref().unwrap().join(&[seed_addr.clone()]);
let tagged_responses = deliver_actions_tagged( let tagged_responses = deliver_actions_tagged(
&join_actions, &join_actions,
node_ids[i], node_ids[i],
addrs[i], addrs[i].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -314,7 +315,7 @@ fn main() {
deliver_actions_tagged( deliver_actions_tagged(
&response_actions, &response_actions,
node_ids[responder_idx], node_ids[responder_idx],
addrs[responder_idx], addrs[responder_idx].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -444,20 +445,20 @@ fn main() {
// Revive peer 8 (new node + rejoin) // Revive peer 8 (new node + rejoin)
if churn_pos == 150 { if churn_pos == 150 {
let config = DistributedNodeConfig { let config = DistributedNodeConfig {
listen_addr: addrs[8], listen_addr: addrs[8].clone(),
swim: swim_config.clone(), swim: swim_config.clone(),
cache_capacity: 100, cache_capacity: 100,
republish_interval: 500, republish_interval: 500,
}; };
let revived = DistributedNode::new(config); let revived = DistributedNode::new(config);
let join_actions = revived.join(&[seed_addr]); let join_actions = revived.join(&[seed_addr.clone()]);
nodes[8] = Some(revived); nodes[8] = Some(revived);
node_ids[8] = nodes[8].as_ref().unwrap().node_id(); node_ids[8] = nodes[8].as_ref().unwrap().node_id();
let tagged_responses = deliver_actions_tagged( let tagged_responses = deliver_actions_tagged(
&join_actions, &join_actions,
node_ids[8], node_ids[8],
addrs[8], addrs[8].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -466,7 +467,7 @@ fn main() {
deliver_actions_tagged( deliver_actions_tagged(
&response_actions, &response_actions,
node_ids[responder_idx], node_ids[responder_idx],
addrs[responder_idx], addrs[responder_idx].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -485,7 +486,7 @@ fn main() {
let tagged_responses = deliver_actions_tagged( let tagged_responses = deliver_actions_tagged(
&leave_actions, &leave_actions,
node_ids[7], node_ids[7],
addrs[7], addrs[7].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -494,7 +495,7 @@ fn main() {
deliver_actions_tagged( deliver_actions_tagged(
&response_actions, &response_actions,
node_ids[responder_idx], node_ids[responder_idx],
addrs[responder_idx], addrs[responder_idx].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -508,20 +509,20 @@ fn main() {
// Rejoin peer 7 // Rejoin peer 7
if churn_pos == 350 { if churn_pos == 350 {
let config = DistributedNodeConfig { let config = DistributedNodeConfig {
listen_addr: addrs[7], listen_addr: addrs[7].clone(),
swim: swim_config.clone(), swim: swim_config.clone(),
cache_capacity: 100, cache_capacity: 100,
republish_interval: 500, republish_interval: 500,
}; };
let revived = DistributedNode::new(config); let revived = DistributedNode::new(config);
let join_actions = revived.join(&[seed_addr]); let join_actions = revived.join(&[seed_addr.clone()]);
nodes[7] = Some(revived); nodes[7] = Some(revived);
node_ids[7] = nodes[7].as_ref().unwrap().node_id(); node_ids[7] = nodes[7].as_ref().unwrap().node_id();
let tagged_responses = deliver_actions_tagged( let tagged_responses = deliver_actions_tagged(
&join_actions, &join_actions,
node_ids[7], node_ids[7],
addrs[7], addrs[7].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -530,7 +531,7 @@ fn main() {
deliver_actions_tagged( deliver_actions_tagged(
&response_actions, &response_actions,
node_ids[responder_idx], node_ids[responder_idx],
addrs[responder_idx], addrs[responder_idx].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,

View file

@ -2,15 +2,11 @@ pub mod collector;
pub mod investigate; pub mod investigate;
pub mod layer; pub mod layer;
pub mod trace; pub mod trace;
mod actors_html;
mod dashboard_html;
mod server; mod server;
#[cfg(feature = "tui")] #[cfg(feature = "tui")]
pub mod tui; pub mod tui;
#[cfg(feature = "distribution")]
mod distribution_html;
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
pub mod distribution_collector; pub mod distribution_collector;

View file

@ -8,17 +8,13 @@ use std::collections::HashMap;
use swactor::runtime::Runtime; use swactor::runtime::Runtime;
use crate::actors_html::ACTORS_HTML;
use crate::collector::StatsCollector; use crate::collector::StatsCollector;
use crate::dashboard_html::DASHBOARD_HTML;
use crate::investigate; use crate::investigate;
use crate::layer::EventStore; use crate::layer::EventStore;
use crate::trace::RuntimeTrace; use crate::trace::RuntimeTrace;
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
use crate::distribution_collector::DistributionStatsProvider; use crate::distribution_collector::DistributionStatsProvider;
#[cfg(feature = "distribution")]
use crate::distribution_html::DISTRIBUTION_HTML;
/// Format a server-sent event. /// Format a server-sent event.
fn format_sse(event: &str, data: &str) -> Vec<u8> { fn format_sse(event: &str, data: &str) -> Vec<u8> {
@ -97,14 +93,76 @@ fn make_sse_response(
) )
} }
fn respond_html(request: tiny_http::Request, html_template: &str, mode: &str) { // ── Static file serving ─────────────────────────────────────────────────
let html = html_template.replace("__DASHBOARD_MODE__", mode);
let response = tiny_http::Response::from_string(html).with_header( /// Root directory for static assets (resolved at compile time).
fn static_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("static")
}
/// Serve an HTML page from static/, injecting a dashboard-mode meta tag.
fn serve_html_page(request: tiny_http::Request, filename: &str, mode: &str) {
let path = static_dir().join(filename);
match std::fs::read_to_string(&path) {
Ok(html) => {
// Inject mode meta tag after <head> or <meta charset>
let injected = inject_mode_meta(&html, mode);
let response = tiny_http::Response::from_string(injected).with_header(
"Content-Type: text/html; charset=utf-8" "Content-Type: text/html; charset=utf-8"
.parse::<tiny_http::Header>() .parse::<tiny_http::Header>()
.unwrap(), .unwrap(),
); );
let _ = request.respond(response); let _ = request.respond(response);
}
Err(_) => respond_404(request),
}
}
/// Inject `<meta name="dashboard-mode" content="...">` into the HTML <head>.
fn inject_mode_meta(html: &str, mode: &str) -> String {
let meta_tag = format!(r#"<meta name="dashboard-mode" content="{mode}">"#);
// Insert after the charset meta tag for clean placement
if let Some(pos) = html.find("<meta charset=") {
if let Some(end) = html[pos..].find('>') {
let insert_at = pos + end + 1;
let mut result = String::with_capacity(html.len() + meta_tag.len() + 1);
result.push_str(&html[..insert_at]);
result.push('\n');
result.push_str(&meta_tag);
result.push_str(&html[insert_at..]);
return result;
}
}
// Fallback: prepend to <head>
html.replace("<head>", &format!("<head>\n{meta_tag}"))
}
/// Serve a static asset (CSS, JS) from the static/ directory.
fn serve_static_asset(request: tiny_http::Request, rel_path: &str) {
// Security: reject path traversal
if rel_path.contains("..") {
respond_404(request);
return;
}
let path = static_dir().join(rel_path);
let content_type = match path.extension().and_then(|e| e.to_str()) {
Some("css") => "text/css; charset=utf-8",
Some("js") => "application/javascript; charset=utf-8",
Some("html") => "text/html; charset=utf-8",
_ => "application/octet-stream",
};
match std::fs::read(&path) {
Ok(data) => {
let header = format!("Content-Type: {content_type}");
let response = tiny_http::Response::from_data(data).with_header(
header.parse::<tiny_http::Header>().unwrap(),
);
let _ = request.respond(response);
}
Err(_) => respond_404(request),
}
} }
fn respond_404(request: tiny_http::Request) { fn respond_404(request: tiny_http::Request) {
@ -146,10 +204,12 @@ pub(crate) fn spawn_http_server(
let url = request.url().to_string(); let url = request.url().to_string();
let path = url.split('?').next().unwrap_or(&url); let path = url.split('?').next().unwrap_or(&url);
match path { match path {
"/" => respond_html(request, DASHBOARD_HTML, "live"), "/" => serve_html_page(request, "index.html", "live"),
"/actors" => respond_html(request, ACTORS_HTML, "live"), "/actors" => serve_html_page(request, "actors.html", "live"),
#[cfg(feature = "distribution")] #[cfg(feature = "distribution")]
"/distribution" => respond_html(request, DISTRIBUTION_HTML, "live"), "/distribution" => serve_html_page(request, "distribution.html", "live"),
"/css/shared.css" => serve_static_asset(request, "css/shared.css"),
"/js/shared.js" => serve_static_asset(request, "js/shared.js"),
"/events" => { "/events" => {
handle_live_sse( handle_live_sse(
request, request,
@ -340,9 +400,12 @@ pub(crate) fn spawn_replay_server(trace: Arc<RuntimeTrace>, port: u16, speed: f6
}; };
let url = request.url().to_string(); let url = request.url().to_string();
match url.as_str() { let path = url.split('?').next().unwrap_or(&url);
"/" => respond_html(request, DASHBOARD_HTML, "replay"), match path {
"/actors" => respond_html(request, ACTORS_HTML, "replay"), "/" => serve_html_page(request, "index.html", "replay"),
"/actors" => serve_html_page(request, "actors.html", "replay"),
"/css/shared.css" => serve_static_asset(request, "css/shared.css"),
"/js/shared.js" => serve_static_asset(request, "js/shared.js"),
"/events" => { "/events" => {
handle_replay_sse(request, Arc::clone(&trace), speed); handle_replay_sse(request, Arc::clone(&trace), speed);
} }

View file

@ -1,80 +1,19 @@
pub const ACTORS_HTML: &str = r##"<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swactor Runtime – Actors</title> <title>Swactor Runtime – Actors</title>
<link rel="stylesheet" href="/css/shared.css">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
}
.header-left { display: flex; align-items: center; }
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
display: inline-block; margin-left: 8px; vertical-align: middle;
}
.status-dot.disconnected { background: #f44336; }
.status-dot.done { background: #ff9800; }
.status-dot.replaying { background: #2196f3; animation: pulse 1.5s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.replay-badge {
display: none; background: #2196f3; color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 8px; border-radius: 3px; margin-left: 10px; letter-spacing: 1px;
vertical-align: middle;
}
.replay-badge.visible { display: inline-block; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link {
color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.header-right { display: flex; align-items: center; gap: 12px; }
.progress-bar-wrap {
display: none; width: 100%; height: 3px; background: #2a2d3e;
}
.progress-bar-wrap.visible { display: block; }
.progress-fill {
height: 100%; width: 0%; background: #2196f3; transition: width 0.3s;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; padding: 12px; gap: 12px; padding: 12px;
} }
.panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; overflow: hidden;
}
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
.full-width { grid-column: 1 / -1; } .full-width { grid-column: 1 / -1; }
.stats-cards {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
.stat-card {
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
}
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
canvas { width: 100%; height: 200px; } canvas { width: 100%; height: 200px; }
.search-wrap { margin-bottom: 10px; display: flex; align-items: center; gap: 12px; } .search-wrap { margin-bottom: 10px; display: flex; align-items: center; gap: 12px; }
@ -102,14 +41,18 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
height: 8px; border-radius: 2px; max-width: 120px; min-width: 2px; height: 8px; border-radius: 2px; max-width: 120px; min-width: 2px;
} }
.msg-type { color: #4caf50; }
.msg-type.none { color: #555; font-style: italic; }
tr.focused { background: #1c1f2e; } tr.focused { background: #1c1f2e; }
tr.clickable { cursor: pointer; } tr.clickable { cursor: pointer; }
tr.clickable:hover { background: #1a1d2c; } tr.clickable:hover { background: #1a1d2c; }
.detail-panel { display: none; } .detail-panel {
display: none;
position: fixed; bottom: 0; left: 0; right: 0;
max-height: 280px; z-index: 100;
border-top: 2px solid #6366f1;
border-radius: 0;
overflow-y: auto;
}
.detail-panel.visible { display: block; } .detail-panel.visible { display: block; }
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } .detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.detail-close { .detail-close {
@ -129,10 +72,6 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
} }
.healthy-badge { color: #4caf50; font-size: 12px; } .healthy-badge { color: #4caf50; font-size: 12px; }
canvas.sparkline { width: 100%; height: 60px; } canvas.sparkline { width: 100%; height: 60px; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
</style> </style>
</head> </head>
<body> <body>
@ -207,8 +146,11 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
</div> </div>
</div> </div>
<!-- Actor detail panel -->
<div id="detailPanel" class="panel full-width detail-panel"> </div>
<!-- Actor detail panel (fixed overlay at bottom) -->
<div id="detailPanel" class="panel detail-panel">
<div class="detail-header"> <div class="detail-header">
<h2>Actor Detail <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2> <h2>Actor Detail <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2>
<button class="detail-close" id="detailClose">Close</button> <button class="detail-close" id="detailClose">Close</button>
@ -223,52 +165,11 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
</div> </div>
<h2>Mailbox Depth History</h2> <h2>Mailbox Depth History</h2>
<canvas id="sparkline" class="sparkline"></canvas> <canvas id="sparkline" class="sparkline"></canvas>
</div>
</div> </div>
<script src="/js/shared.js"></script>
<script> <script>
(function() { (function() {
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
var isReplay = (DASHBOARD_MODE === 'replay');
var lastUptimeMs = null;
var lastStatsTime = null;
var dot = document.getElementById('statusDot');
var uptimeLabel = document.getElementById('uptimeLabel');
var replayBadge = document.getElementById('replayBadge');
var replaySpeed = document.getElementById('replaySpeed');
var replayPct = document.getElementById('replayPct');
var progressBarWrap = document.getElementById('progressBarWrap');
var progressFill = document.getElementById('progressFill');
if (isReplay) {
replayBadge.className = 'replay-badge visible';
progressBarWrap.className = 'progress-bar-wrap visible';
dot.className = 'status-dot replaying';
uptimeLabel.style.display = 'none';
}
function updateUptime() {
if (isReplay) return;
var up = lastUptimeMs;
if (up !== null && lastStatsTime !== null) {
up += (Date.now() - lastStatsTime);
}
if (up === null) { uptimeLabel.textContent = ''; return; }
var s = Math.floor(up / 1000);
var d = Math.floor(s / 86400);
var h = Math.floor((s % 86400) / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
var parts = [];
if (d > 0) parts.push(d + 'd');
if (h > 0 || d > 0) parts.push(h + 'h');
parts.push(m + 'm');
parts.push(sec + 's');
uptimeLabel.textContent = parts.join(' ');
}
setInterval(updateUptime, 1000);
// ── State ────────────────────────────────────────────── // ── State ──────────────────────────────────────────────
var currentActors = []; var currentActors = [];
var sortCol = 'worker'; var sortCol = 'worker';
@ -277,39 +178,10 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
var focusedAddrHex = null; var focusedAddrHex = null;
var depthHistory = {}; var depthHistory = {};
var HISTORY_MAX = 120; var HISTORY_MAX = 120;
var lastStatsJSON = ''; // fingerprint for skipping redundant redraws
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63']; var renderPending = false; // rAF throttle flag
// ── Helpers ──────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────
function addrToHex(addr) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
var hex = '';
for (var j = 0; j < Math.min(8, bytes.length); j++) {
hex += ('0' + bytes[j].toString(16)).slice(-2);
}
return hex + '\u2026';
}
function addrToFullHex(addr) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
var hex = '';
for (var j = 0; j < bytes.length; j++) {
hex += ('0' + bytes[j].toString(16)).slice(-2);
}
return hex;
}
function shortTypeName(full) {
if (!full) return '';
var parts = full.split('::');
return parts[parts.length - 1];
}
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function depthColor(d) { function depthColor(d) {
if (d === 0) return '#4caf50'; if (d === 0) return '#4caf50';
if (d <= 2) return '#8bc34a'; if (d <= 2) return '#8bc34a';
@ -350,7 +222,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
{label: '50+', min: 51, max: Infinity} {label: '50+', min: 51, max: Infinity}
]; ];
var bucketColors = ['#4caf50','#8bc34a','#cddc39','#ff9800','#ff5722','#f44336','#d32f2f']; var bucketColors = ['#5c6bc0','#7e57c2','#ab47bc','#ce93d8','#ba68c8','#9575cd','#7986cb'];
function drawDepthChart(actors) { function drawDepthChart(actors) {
var dpr = window.devicePixelRatio || 1; var dpr = window.devicePixelRatio || 1;
@ -388,7 +260,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
depthCtx.fillStyle = '#e0e0e0'; depthCtx.fillStyle = '#e0e0e0';
depthCtx.font = '10px monospace'; depthCtx.font = '10px monospace';
depthCtx.textAlign = 'center'; depthCtx.textAlign = 'center';
depthCtx.fillText(counts[i], x + barW / 2, chartH - h - 4); depthCtx.fillText(counts[i], x + barW / 2, Math.max(12, chartH - h - 4));
} }
depthCtx.globalAlpha = 1; depthCtx.globalAlpha = 1;
@ -402,6 +274,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
// ── Actors per worker chart ──────────────────────────── // ── Actors per worker chart ────────────────────────────
var workerCanvas = document.getElementById('workerChart'); var workerCanvas = document.getElementById('workerChart');
var workerCtx = workerCanvas.getContext('2d'); var workerCtx = workerCanvas.getContext('2d');
var workerBarHitRegions = []; // [{x, y, w, h, wid}]
function drawWorkerChart(actors) { function drawWorkerChart(actors) {
var dpr = window.devicePixelRatio || 1; var dpr = window.devicePixelRatio || 1;
@ -411,6 +284,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
workerCtx.scale(dpr, dpr); workerCtx.scale(dpr, dpr);
var W = rect.width, H = rect.height; var W = rect.width, H = rect.height;
workerCtx.clearRect(0, 0, W, H); workerCtx.clearRect(0, 0, W, H);
workerBarHitRegions = [];
var workerCounts = {}; var workerCounts = {};
for (var i = 0; i < actors.length; i++) { for (var i = 0; i < actors.length; i++) {
@ -433,17 +307,42 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
workerCtx.globalAlpha = 0.85; workerCtx.globalAlpha = 0.85;
workerCtx.fillRect(x, chartH - h, barW, h); workerCtx.fillRect(x, chartH - h, barW, h);
workerBarHitRegions.push({ x: x, y: chartH - h, w: barW, h: h, wid: entries[i].id });
workerCtx.globalAlpha = 1; workerCtx.globalAlpha = 1;
workerCtx.fillStyle = '#e0e0e0'; workerCtx.fillStyle = '#e0e0e0';
workerCtx.font = '10px monospace'; workerCtx.font = '10px monospace';
workerCtx.textAlign = 'center'; workerCtx.textAlign = 'center';
workerCtx.fillText(entries[i].count, x + barW / 2, chartH - h - 4); workerCtx.fillText(entries[i].count, x + barW / 2, Math.max(12, chartH - h - 4));
workerCtx.fillStyle = '#888'; workerCtx.fillStyle = '#888';
workerCtx.fillText('W' + entries[i].id, x + barW / 2, H - 4); workerCtx.fillText('W' + entries[i].id, x + barW / 2, H - 4);
} }
} }
workerCanvas.style.cursor = 'pointer';
workerCanvas.addEventListener('click', function(e) {
var rect = workerCanvas.getBoundingClientRect();
var mx = e.clientX - rect.left;
var my = e.clientY - rect.top;
for (var i = 0; i < workerBarHitRegions.length; i++) {
var r = workerBarHitRegions[i];
// Use full column height for easier clicking (from label area to top)
if (mx >= r.x && mx <= r.x + r.w) {
var input = document.getElementById('actorSearch');
var filter = 'w' + r.wid;
if (input.value === filter) {
// Click same bar again → clear filter
input.value = '';
} else {
input.value = filter;
}
renderActorTable();
return;
}
}
});
// ── Actor table ──────────────────────────────────────── // ── Actor table ────────────────────────────────────────
var MAX_TABLE_ROWS = 2000; var MAX_TABLE_ROWS = 2000;
@ -505,6 +404,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
var msgTitle = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : ''; var msgTitle = hasMsg ? ' title="' + escapeHtml(a.last_msg_type) + '"' : '';
var tr = document.createElement('tr'); var tr = document.createElement('tr');
tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : ''); tr.className = 'clickable' + (fullHex === focusedAddrHex ? ' focused' : '');
tr.style.backgroundColor = colorWithAlpha(colors[a.worker_id % colors.length], 0.08);
if (a.poisoned) tr.style.opacity = '0.6'; if (a.poisoned) tr.style.opacity = '0.6';
tr.setAttribute('data-addr', fullHex); tr.setAttribute('data-addr', fullHex);
tr.innerHTML = tr.innerHTML =
@ -531,14 +431,14 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
// ── Focus / detail panel ─────────────────────────────── // ── Focus / detail panel ───────────────────────────────
function focusActor(fullHex) { function focusActor(fullHex) {
focusedAddrHex = fullHex; focusedAddrHex = fullHex;
document.getElementById('detailPanel').className = 'panel full-width detail-panel visible'; document.getElementById('detailPanel').className = 'panel detail-panel visible';
updateDetailPanel(); updateDetailPanel();
renderActorTable(); renderActorTable();
} }
function clearFocus() { function clearFocus() {
focusedAddrHex = null; focusedAddrHex = null;
document.getElementById('detailPanel').className = 'panel full-width detail-panel'; document.getElementById('detailPanel').className = 'panel detail-panel';
renderActorTable(); renderActorTable();
} }
@ -653,31 +553,26 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
searchTimer = setTimeout(renderActorTable, 150); searchTimer = setTimeout(renderActorTable, 150);
}); });
// ── Status helpers ───────────────────────────────────── // ── Throttled render via requestAnimationFrame ────────
function setStatus(s) { function scheduleRender() {
if (s === 'done') { if (renderPending) return;
dot.className = 'status-dot done'; renderPending = true;
if (isReplay) { requestAnimationFrame(function() {
replayPct.textContent = '100%'; renderPending = false;
progressFill.style.width = '100%'; renderActorTable();
} });
} else if (s === 'disconnected') {
dot.className = 'status-dot disconnected';
} else {
dot.className = isReplay ? 'status-dot replaying' : 'status-dot';
}
} }
// ── SSE connection ───────────────────────────────────── // ── SSE connection ─────────────────────────────────────
var es = new EventSource('/events'); createSSE({
stats: function(data) {
trackUptime(data);
// Skip full rebuild if data hasn't changed
var json = JSON.stringify(data);
if (json === lastStatsJSON) return;
lastStatsJSON = json;
es.addEventListener('stats', function(e) {
try {
var data = JSON.parse(e.data);
if (typeof data.uptime_ms === 'number') {
lastUptimeMs = data.uptime_ms;
lastStatsTime = Date.now();
}
currentActors = data.actor_details || []; currentActors = data.actor_details || [];
updateStatCards(currentActors); updateStatCards(currentActors);
drawDepthChart(currentActors); drawDepthChart(currentActors);
@ -697,44 +592,11 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
if (!liveAddrs[key]) delete depthHistory[key]; if (!liveAddrs[key]) delete depthHistory[key];
} }
renderActorTable(); scheduleRender();
if (focusedAddrHex) updateDetailPanel(); if (focusedAddrHex) updateDetailPanel();
} catch(err) { console.error('stats parse error', err); } }
}); });
es.addEventListener('replay_meta', function(e) {
try {
var meta = JSON.parse(e.data);
replaySpeed.textContent = meta.speed + 'x';
replaySpeed.style.display = 'inline';
replayPct.style.display = 'inline';
replayPct.textContent = '0%';
} catch(err) { console.error('replay_meta parse error', err); }
});
es.addEventListener('replay_progress', function(e) {
try {
var data = JSON.parse(e.data);
var pct = Math.round(data.progress * 100);
replayPct.textContent = pct + '%';
progressFill.style.width = pct + '%';
} catch(err) { console.error('replay_progress parse error', err); }
});
es.addEventListener('done', function() {
setStatus('done');
es.close();
});
es.onerror = function() {
setStatus('disconnected');
};
es.onopen = function() {
setStatus('connected');
};
})(); })();
</script> </script>
</body> </body>
</html> </html>
"##;

View file

@ -0,0 +1,89 @@
/* Swactor Dashboard — Shared Styles */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
/* ── Header & Navigation ────────────────────────────────────── */
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
}
.header-left { display: flex; align-items: center; }
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
display: inline-block; margin-left: 8px; vertical-align: middle;
}
.status-dot.disconnected { background: #f44336; }
.status-dot.done { background: #ff9800; }
.status-dot.replaying { background: #2196f3; animation: pulse 1.5s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.replay-badge {
display: none; background: #2196f3; color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 8px; border-radius: 3px; margin-left: 10px; letter-spacing: 1px;
vertical-align: middle;
}
.replay-badge.visible { display: inline-block; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link {
color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.header-right { display: flex; align-items: center; gap: 12px; }
/* ── Progress Bar (replay mode) ─────────────────────────────── */
.progress-bar-wrap {
display: none; width: 100%; height: 3px; background: #2a2d3e;
}
.progress-bar-wrap.visible { display: block; }
.progress-fill {
height: 100%; width: 0%; background: #2196f3; transition: width 0.3s;
}
/* ── Panel & Cards ──────────────────────────────────────────── */
.panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; overflow: hidden;
}
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
.stats-cards {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
.stat-card {
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
}
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
/* ── Message Type Labels ────────────────────────────────────── */
.msg-type { color: #4caf50; }
.msg-type.none { color: #555; font-style: italic; }
/* ── Log Level Colors ───────────────────────────────────────── */
.level-ERROR { color: #f44336; font-weight: 700; }
.level-WARN { color: #ff9800; }
.level-INFO { color: #4caf50; }
.level-DEBUG { color: #2196f3; }
.level-TRACE { color: #666; }
/* ── Scrollbar ──────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }

View file

@ -1,65 +1,34 @@
pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swactor Runtime – Distribution</title> <title>Swactor Runtime – Distribution</title>
<link rel="stylesheet" href="/css/shared.css">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } .stats-cards { grid-template-columns: repeat(3, 1fr); }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
}
.header-left { display: flex; align-items: center; }
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
display: inline-block; margin-left: 8px; vertical-align: middle;
}
.status-dot.disconnected { background: #f44336; }
.status-dot.done { background: #ff9800; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link {
color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.header-right { display: flex; align-items: center; gap: 12px; }
.main { .main {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr auto; grid-template-rows: auto 1fr auto;
height: calc(100vh - 48px); height: calc(100vh - 48px);
overflow: hidden;
} }
.graph-panel { .graph-panel {
grid-row: 1 / 3; border-right: 1px solid #2a2d3e; position: relative; grid-row: 1 / 3; border-right: 1px solid #2a2d3e; position: relative;
min-height: 0; min-height: 0; min-width: 0; overflow: hidden;
} }
.graph-panel canvas { width: 100%; height: 100%; display: block; } .graph-panel canvas { width: 100%; height: 100%; display: block; }
.side-panel { display: flex; flex-direction: column; overflow: hidden; min-height: 0; } .side-panel { display: flex; flex-direction: column; overflow: hidden; min-height: 0; min-width: 0; }
.stats-panel { .stats-panel {
flex-shrink: 0; padding: 12px 16px; border-bottom: 1px solid #2a2d3e; background: #161822; flex-shrink: 0; padding: 12px 16px; border-bottom: 1px solid #2a2d3e; background: #161822;
} }
.stats-panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; } .stats-panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.stats-cards {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
}
.stat-card {
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
}
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
.table-panel { .table-panel {
flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden;
} }
@ -94,6 +63,48 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
flex: 1; overflow-y: auto; font-size: 11px; flex: 1; overflow-y: auto; font-size: 11px;
} }
.gossip-header {
display: flex; align-items: center; gap: 10px; margin-bottom: 6px;
}
.gossip-header h2 { margin-bottom: 0; }
.phase-badge {
display: inline-block; padding: 1px 6px; border-radius: 3px;
font-size: 10px; font-weight: 700; letter-spacing: 0.5px;
}
.phase-idle { background: #2e7d32; color: #fff; }
.phase-waiting_direct_ack { background: #f9a825; color: #000; }
.phase-waiting_indirect_ack { background: #e65100; color: #fff; }
.gossip-counters {
display: flex; gap: 12px; font-size: 11px; color: #aaa; margin-bottom: 6px;
}
.gossip-counters .gc-val { color: #fff; font-weight: 700; }
.gossip-table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
.gossip-table th, .gossip-table td {
padding: 2px 6px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 10px;
}
.gossip-table th { color: #666; font-weight: 500; }
.suspicion-bar {
height: 4px; border-radius: 2px; background: #ff9800; max-width: 60px; min-width: 2px;
}
.gossip-section-label {
font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 0.5px;
margin: 4px 0 2px;
}
.gossip-config {
display: flex; flex-wrap: wrap; gap: 8px; font-size: 10px; color: #555; margin-top: 4px;
}
.gossip-config span { color: #777; }
.event-row {
padding: 1px 0; border-bottom: 1px solid #1c1f2e; color: #aaa;
display: flex; gap: 6px; align-items: center;
}
.event-time { color: #555; flex-shrink: 0; }
.event-arrow { color: #555; }
.event-state { font-weight: 600; }
.ego-hint { .ego-hint {
position: absolute; bottom: 12px; left: 12px; font-size: 10px; position: absolute; bottom: 12px; left: 12px; font-size: 10px;
color: #555; pointer-events: none; color: #555; pointer-events: none;
@ -104,10 +115,6 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
color: #888; max-width: 50%; overflow: hidden; text-overflow: ellipsis; color: #888; max-width: 50%; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
</style> </style>
</head> </head>
<body> <body>
@ -175,9 +182,37 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
</table> </table>
</div> </div>
</div> </div>
<div class="bottom-section"> <div class="bottom-section" style="flex:1.5;">
<h2>Recent Probes</h2> <div class="gossip-header">
<div class="scroll-wrap" id="probesWrap"></div> <h2>Gossip Protocol</h2>
<span id="phaseBadge" class="phase-badge phase-idle">IDLE</span>
<span id="probeTargetLabel" style="font-size:10px;color:#888;"></span>
</div>
<div class="scroll-wrap" id="gossipWrap">
<div id="gossipEmpty" style="color:#555;font-size:11px;padding:6px 0;">No peers — gossip inactive</div>
<div id="gossipActive" style="display:none;">
<div class="gossip-counters">
<span>Protocol round <span class="gc-val" id="gcTick">0</span></span>
<span>Probes sent <span class="gc-val" id="gcPings">0</span></span>
<span>Incarnation <span class="gc-val" id="gcInc">0</span></span>
<span>Pending updates <span class="gc-val" id="gcQueue">0</span></span>
</div>
<div id="suspicionSection" style="display:none;">
<div class="gossip-section-label">Suspicion Timers</div>
<table class="gossip-table">
<thead><tr><th>Node</th><th>Countdown</th><th></th></tr></thead>
<tbody id="suspicionBody"></tbody>
</table>
</div>
<div id="eventLogSection">
<div class="gossip-section-label">Membership Events</div>
<div id="eventLogWrap" style="max-height:80px;overflow-y:auto;font-size:10px;"></div>
</div>
<div class="gossip-section-label">Recently Probed</div>
<div id="probesWrap" style="font-size:10px;"></div>
</div>
<div id="gossipConfig" class="gossip-config"></div>
</div>
</div> </div>
<div class="bottom-section"> <div class="bottom-section">
<h2>Routing Buckets</h2> <h2>Routing Buckets</h2>
@ -186,9 +221,9 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
</div> </div>
</div> </div>
<script src="/js/shared.js"></script>
<script> <script>
(function() { (function() {
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
var dot = document.getElementById('statusDot'); var dot = document.getElementById('statusDot');
// ── State ─────────────────────────────────────────────────────── // ── State ───────────────────────────────────────────────────────
@ -196,6 +231,10 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
var selfNodeId = ''; // this node's hex id var selfNodeId = ''; // this node's hex id
var focusIdx = -1; // ego-centric focus (-1 = none, 0 = self) var focusIdx = -1; // ego-centric focus (-1 = none, 0 = self)
// Membership event log (client-side diff of snapshots)
var prevMemberStates = {}; // node_id → { state, addr }
var eventLog = []; // [{ time, addr, from, to }] newest first, max 100
// Graph state // Graph state
var N = 0; var N = 0;
var posX = new Float64Array(0); var posX = new Float64Array(0);
@ -390,7 +429,7 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
var useBH = N > 60, th2 = 0.64; var useBH = N > 60, th2 = 0.64;
var dx = new Float64Array(N), dy = new Float64Array(N); var dx = new Float64Array(N), dy = new Float64Array(N);
// Edges: self→each member (star topology from this node's perspective) // Edges: self->each member (star topology from this node's perspective)
var selfIdx = 0; // node 0 is always 'self' var selfIdx = 0; // node 0 is always 'self'
var edges = []; var edges = [];
for (var i = 1; i < N; i++) edges.push([selfIdx, i]); for (var i = 1; i < N; i++) edges.push([selfIdx, i]);
@ -469,23 +508,20 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
if (focusIdx >= 0) { if (focusIdx >= 0) {
highlighted = {}; highlighted = {};
highlighted[focusIdx] = true; highlighted[focusIdx] = true;
// If focus is self (0), highlight all connected members
// If focus is a member, highlight self and that member
if (focusIdx === 0) { if (focusIdx === 0) {
for (var i = 1; i < N; i++) highlighted[i] = true; for (var i = 1; i < N; i++) highlighted[i] = true;
} else { } else {
highlighted[0] = true; // always show self highlighted[0] = true;
} }
} }
// Draw edges (self → each member) // Draw edges (self -> each member)
for (var i = 1; i < N; i++) { for (var i = 1; i < N; i++) {
var alpha = 1; var alpha = 1;
var dash = false; var dash = false;
if (highlighted) { if (highlighted) {
if (!highlighted[i]) { alpha = 0.08; } if (!highlighted[i]) { alpha = 0.08; }
else { else {
// Check if this node is a routing neighbor
var nid = nodeIds[i]; var nid = nodeIds[i];
if (routingSet[nid]) { dash = true; alpha = 0.7; } if (routingSet[nid]) { dash = true; alpha = 0.7; }
else { alpha = 0.5; } else { alpha = 0.5; }
@ -595,7 +631,6 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
canvas.parentElement.getBoundingClientRect().height); canvas.parentElement.getBoundingClientRect().height);
runLayout(); runLayout();
} else { } else {
// Just update states, redraw
drawGraph(); drawGraph();
} }
@ -618,7 +653,6 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
document.getElementById('statRepair').textContent = d.repair_queue_size; document.getElementById('statRepair').textContent = d.repair_queue_size;
document.getElementById('statProbes').textContent = d.recent_probe_targets.length; document.getElementById('statProbes').textContent = d.recent_probe_targets.length;
// Color alive/suspect/dead
document.getElementById('statAlive').style.color = '#4caf50'; document.getElementById('statAlive').style.color = '#4caf50';
document.getElementById('statSuspect').style.color = d.suspect_count > 0 ? '#ff9800' : '#fff'; document.getElementById('statSuspect').style.color = d.suspect_count > 0 ? '#ff9800' : '#fff';
document.getElementById('statDead').style.color = d.dead_count > 0 ? '#f44336' : '#fff'; document.getElementById('statDead').style.color = d.dead_count > 0 ? '#f44336' : '#fff';
@ -653,15 +687,138 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
cacheBody.appendChild(tr); cacheBody.appendChild(tr);
} }
// Recent probes // ── Build node_id → addr lookup from members ──────────────
var addrOf = {};
for (var i = 0; i < d.members.length; i++) {
addrOf[d.members[i].node_id] = d.members[i].addr;
}
function nodeLabel(hexId) {
return addrOf[hexId] || hexId.substring(0, 12) + '\u2026';
}
// ── Membership event log (diff current vs previous snapshot) ──
var now = new Date();
var timeStr = String(now.getHours()).padStart(2,'0') + ':' +
String(now.getMinutes()).padStart(2,'0') + ':' +
String(now.getSeconds()).padStart(2,'0');
var currentStates = {};
for (var i = 0; i < d.members.length; i++) {
var mm = d.members[i];
currentStates[mm.node_id] = { state: mm.state, addr: mm.addr };
}
for (var nid in currentStates) {
var cur = currentStates[nid];
var prev = prevMemberStates[nid];
if (!prev) {
eventLog.unshift({ time: timeStr, addr: cur.addr, from: null, to: cur.state });
} else if (prev.state !== cur.state) {
eventLog.unshift({ time: timeStr, addr: cur.addr, from: prev.state, to: cur.state });
}
}
for (var nid in prevMemberStates) {
if (!currentStates[nid]) {
eventLog.unshift({ time: timeStr, addr: prevMemberStates[nid].addr, from: prevMemberStates[nid].state, to: 'gone' });
}
}
prevMemberStates = currentStates;
if (eventLog.length > 100) eventLog.length = 100;
// ── Gossip protocol panel ─────────────────────────────────
var g = d.gossip;
var hasPeers = d.members.length > 0 || eventLog.length > 0;
document.getElementById('gossipEmpty').style.display = hasPeers ? 'none' : '';
document.getElementById('gossipActive').style.display = hasPeers ? '' : 'none';
if (g && hasPeers) {
// Phase badge
var badge = document.getElementById('phaseBadge');
var phaseLabels = {
idle: 'IDLE',
waiting_direct_ack: 'PINGING',
waiting_indirect_ack: 'INDIRECT'
};
badge.textContent = phaseLabels[g.probe_phase] || g.probe_phase.toUpperCase();
badge.className = 'phase-badge phase-' + g.probe_phase;
// Probe target — show address
var ptLabel = document.getElementById('probeTargetLabel');
if (g.probe_target) {
ptLabel.textContent = '\u2192 ' + nodeLabel(g.probe_target);
} else {
ptLabel.textContent = '';
}
// Counters
document.getElementById('gcTick').textContent = g.tick;
document.getElementById('gcPings').textContent = g.sequence;
document.getElementById('gcInc').textContent = g.incarnation;
document.getElementById('gcQueue').textContent = g.dissemination_queue.length;
// Suspicion timers
var suspSec = document.getElementById('suspicionSection');
var suspBody = document.getElementById('suspicionBody');
if (g.suspicion_timers.length > 0) {
suspSec.style.display = '';
suspBody.innerHTML = '';
for (var i = 0; i < g.suspicion_timers.length; i++) {
var st = g.suspicion_timers[i];
var pct = Math.min(100, Math.round((st.age_ticks / g.config.suspicion_timeout) * 100));
var tr = document.createElement('tr');
tr.innerHTML =
'<td style="color:#ff9800;">' + nodeLabel(st.node_id) + '</td>' +
'<td>' + st.age_ticks + '/' + g.config.suspicion_timeout + ' ticks</td>' +
'<td><div class="suspicion-bar" style="width:' + pct + '%;"></div></td>';
suspBody.appendChild(tr);
}
} else {
suspSec.style.display = 'none';
}
// Config — compact, only show once
var cfgDiv = document.getElementById('gossipConfig');
cfgDiv.innerHTML =
'<span>probe every ' + g.config.probe_interval + ' ticks</span>' +
'<span>ack timeout ' + g.config.probe_timeout + ' ticks</span>' +
'<span>' + g.config.indirect_probes + ' indirect relays</span>' +
'<span>suspect for ' + g.config.suspicion_timeout + ' ticks</span>';
}
// Recent probes — show addresses
var probesWrap = document.getElementById('probesWrap'); var probesWrap = document.getElementById('probesWrap');
probesWrap.innerHTML = ''; probesWrap.innerHTML = '';
if (d.recent_probe_targets.length > 0) {
for (var i = d.recent_probe_targets.length - 1; i >= 0; i--) { for (var i = d.recent_probe_targets.length - 1; i >= 0; i--) {
var div = document.createElement('div'); var div = document.createElement('div');
div.style.cssText = 'padding:2px 0;color:#aaa;font-size:11px;border-bottom:1px solid #1c1f2e;'; div.style.cssText = 'padding:1px 0;color:#aaa;border-bottom:1px solid #1c1f2e;';
div.textContent = d.recent_probe_targets[i].substring(0, 16) + '\u2026'; div.textContent = nodeLabel(d.recent_probe_targets[i]);
probesWrap.appendChild(div); probesWrap.appendChild(div);
} }
}
// Render event log (always, even when no gossip data)
var logWrap = document.getElementById('eventLogWrap');
var stateColor = { alive: '#4caf50', suspect: '#ff9800', dead: '#f44336', gone: '#666' };
if (eventLog.length === 0) {
logWrap.innerHTML = '<div style="color:#555;padding:2px 0;">No events yet</div>';
} else {
logWrap.innerHTML = '';
for (var i = 0; i < eventLog.length; i++) {
var ev = eventLog[i];
var row = document.createElement('div');
row.className = 'event-row';
var fromHtml = ev.from
? '<span class="event-state" style="color:' + (stateColor[ev.from]||'#aaa') + ';">' + ev.from + '</span>'
: '<span class="event-state" style="color:#555;">new</span>';
var toHtml = '<span class="event-state" style="color:' + (stateColor[ev.to]||'#aaa') + ';">' + ev.to + '</span>';
row.innerHTML =
'<span class="event-time">' + ev.time + '</span>' +
'<span>' + ev.addr + '</span> ' +
fromHtml +
' <span class="event-arrow">\u2192</span> ' +
toHtml;
logWrap.appendChild(row);
}
}
// Routing bucket histogram // Routing bucket histogram
drawBucketChart(d.routing_buckets); drawBucketChart(d.routing_buckets);
@ -716,32 +873,13 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
} }
// ── SSE connection ───────────────────────────────────────────── // ── SSE connection ─────────────────────────────────────────────
var es = new EventSource('/events'); createSSE({
distribution: function(d) { updateFromSnapshot(d); }
es.addEventListener('distribution', function(e) {
try {
var d = JSON.parse(e.data);
updateFromSnapshot(d);
} catch(err) { console.error('distribution parse error', err); }
}); });
es.addEventListener('done', function() {
dot.className = 'status-dot done';
es.close();
});
es.onerror = function() {
dot.className = 'status-dot disconnected';
};
es.onopen = function() {
dot.className = 'status-dot';
};
resizeCanvas(); resizeCanvas();
drawGraph(); drawGraph();
})(); })();
</script> </script>
</body> </body>
</html> </html>
"##;

View file

@ -1,57 +1,11 @@
pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Swactor Runtime Dashboard</title> <title>Swactor Runtime Dashboard</title>
<link rel="stylesheet" href="/css/shared.css">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Menlo', 'Consolas', 'Monaco', monospace; background: #0f1117; color: #e0e0e0; font-size: 13px; }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; background: #161822; border-bottom: 1px solid #2a2d3e;
}
.header h1 { font-size: 16px; font-weight: 600; color: #fff; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%; background: #4caf50;
display: inline-block; margin-left: 8px; vertical-align: middle;
}
.status-dot.disconnected { background: #f44336; }
.status-dot.done { background: #ff9800; }
.status-dot.replaying { background: #2196f3; animation: pulse 1.5s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.replay-badge {
display: none; background: #2196f3; color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 8px; border-radius: 3px; margin-left: 10px; letter-spacing: 1px;
vertical-align: middle;
}
.replay-badge.visible { display: inline-block; }
.header-left { display: flex; align-items: center; }
.nav-links { display: flex; gap: 4px; margin-left: 20px; }
.nav-link {
color: #888; text-decoration: none; font-size: 12px;
padding: 4px 10px; border-radius: 3px; transition: color 0.2s;
}
.nav-link:hover { color: #e0e0e0; }
.nav-link.active { color: #fff; background: #2a2d3e; }
.header-right { display: flex; align-items: center; gap: 12px; }
.progress-bar-wrap {
display: none; width: 100%; height: 3px; background: #2a2d3e;
}
.progress-bar-wrap.visible { display: block; }
.progress-fill {
height: 100%; width: 0%; background: #2196f3; transition: width 0.3s;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -59,23 +13,9 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
gap: 12px; padding: 12px; gap: 12px; padding: 12px;
} }
.panel {
background: #161822; border: 1px solid #2a2d3e; border-radius: 6px;
padding: 14px; overflow: hidden;
}
.panel h2 { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
.stats-cards {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
.stat-card {
background: #1c1f2e; border-radius: 4px; padding: 10px; text-align: center;
}
.stat-card .value { font-size: 22px; font-weight: 700; color: #fff; }
.stat-card .label { font-size: 10px; color: #888; text-transform: uppercase; margin-top: 2px; }
.chart-panel { grid-row: span 2; } .chart-panel { grid-row: span 2; }
canvas#workerChart { width: 100%; height: 200px; } canvas#workerChart { width: 100%; height: 200px; }
#workerLegend { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.actor-table-wrap { max-height: 200px; overflow-y: auto; } .actor-table-wrap { max-height: 200px; overflow-y: auto; }
.actor-table-wrap table { width: 100%; border-collapse: collapse; } .actor-table-wrap table { width: 100%; border-collapse: collapse; }
@ -84,9 +24,7 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
} }
.actor-table-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; } .actor-table-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
.log-panel { .log-panel { grid-column: 1 / -1; }
grid-column: 1 / -1;
}
.log-wrap { max-height: 300px; overflow-y: auto; } .log-wrap { max-height: 300px; overflow-y: auto; }
.log-wrap table { width: 100%; border-collapse: collapse; } .log-wrap table { width: 100%; border-collapse: collapse; }
.log-wrap th, .log-wrap td { .log-wrap th, .log-wrap td {
@ -96,12 +34,6 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
.log-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; } .log-wrap th { color: #888; font-weight: 500; position: sticky; top: 0; background: #161822; }
.log-wrap td.msg { white-space: normal; word-break: break-all; max-width: 400px; } .log-wrap td.msg { white-space: normal; word-break: break-all; max-width: 400px; }
.level-ERROR { color: #f44336; font-weight: 700; }
.level-WARN { color: #ff9800; }
.level-INFO { color: #4caf50; }
.level-DEBUG { color: #2196f3; }
.level-TRACE { color: #666; }
.worker-detail-panel { grid-column: 1 / -1; } .worker-detail-panel { grid-column: 1 / -1; }
.worker-detail-scroll { max-height: 360px; overflow-y: auto; } .worker-detail-scroll { max-height: 360px; overflow-y: auto; }
.worker-group { margin-bottom: 8px; border: 1px solid #2a2d3e; border-radius: 4px; overflow: hidden; } .worker-group { margin-bottom: 8px; border: 1px solid #2a2d3e; border-radius: 4px; overflow: hidden; }
@ -120,12 +52,6 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
padding: 3px 10px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px; padding: 3px 10px; text-align: left; border-bottom: 1px solid #1c1f2e; font-size: 11px;
} }
.worker-group-body th { color: #888; font-weight: 500; background: #161822; } .worker-group-body th { color: #888; font-weight: 500; background: #161822; }
.msg-type { color: #4caf50; }
.msg-type.none { color: #555; font-style: italic; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0f1117; }
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
</style> </style>
</head> </head>
<body> <body>
@ -195,54 +121,17 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
</div> </div>
</div> </div>
<script src="/js/shared.js"></script>
<script> <script>
(function() { (function() {
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
var logRowCount = 0; var logRowCount = 0;
var MAX_LOG_ROWS = 2000; var MAX_LOG_ROWS = 2000;
var isReplay = (DASHBOARD_MODE === 'replay'); var workerOpenState = {}; // persistent open/close state for worker groups
var lastUptimeMs = null; var workerNodes = {}; // persistent wid → {div, summaryEl, bodyEl, toggleEl}
var lastStatsTime = null; var lastStatsJSON = ''; // fingerprint for skipping redundant redraws
var dot = document.getElementById('statusDot');
var uptimeLabel = document.getElementById('uptimeLabel');
var replayBadge = document.getElementById('replayBadge');
var replaySpeed = document.getElementById('replaySpeed');
var replayPct = document.getElementById('replayPct');
var progressBarWrap = document.getElementById('progressBarWrap');
var progressFill = document.getElementById('progressFill');
if (isReplay) {
replayBadge.className = 'replay-badge visible';
progressBarWrap.className = 'progress-bar-wrap visible';
dot.className = 'status-dot replaying';
uptimeLabel.style.display = 'none';
}
function updateUptime() {
if (isReplay) return;
var up = lastUptimeMs;
if (up !== null && lastStatsTime !== null) {
up += (Date.now() - lastStatsTime);
}
if (up === null) { uptimeLabel.textContent = ''; return; }
var s = Math.floor(up / 1000);
var d = Math.floor(s / 86400);
var h = Math.floor((s % 86400) / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
var parts = [];
if (d > 0) parts.push(d + 'd');
if (h > 0 || d > 0) parts.push(h + 'h');
parts.push(m + 'm');
parts.push(sec + 's');
uptimeLabel.textContent = parts.join(' ');
}
setInterval(updateUptime, 1000);
var canvas = document.getElementById('workerChart'); var canvas = document.getElementById('workerChart');
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
function drawWorkerChart(workers) { function drawWorkerChart(workers) {
var dpr = window.devicePixelRatio || 1; var dpr = window.devicePixelRatio || 1;
@ -285,10 +174,13 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
} }
function updateStats(data) { function updateStats(data) {
if (typeof data.uptime_ms === 'number') { trackUptime(data);
lastUptimeMs = data.uptime_ms;
lastStatsTime = Date.now(); // Skip full rebuild if data hasn't changed
} var json = JSON.stringify(data);
if (json === lastStatsJSON) return;
lastStatsJSON = json;
var totalActors = data.actors ? data.actors.length : 0; var totalActors = data.actors ? data.actors.length : 0;
var totalMsgs = data.workers ? data.workers.reduce(function(s, w) { return s + w.messages_processed; }, 0) : 0; var totalMsgs = data.workers ? data.workers.reduce(function(s, w) { return s + w.messages_processed; }, 0) : 0;
var totalMailbox = data.workers ? data.workers.reduce(function(s, w) { return s + w.mailbox_depth; }, 0) : 0; var totalMailbox = data.workers ? data.workers.reduce(function(s, w) { return s + w.mailbox_depth; }, 0) : 0;
@ -299,54 +191,59 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
document.getElementById('statMailbox').textContent = totalMailbox; document.getElementById('statMailbox').textContent = totalMailbox;
drawWorkerChart(data.workers); drawWorkerChart(data.workers);
updateActorTable(data.actors);
var tbody = document.getElementById('actorTableBody');
tbody.innerHTML = '';
if (data.actors) {
var shown = data.actors.slice(0, 200);
shown.forEach(function(entry) {
var addr = entry[0];
var wid = entry[1];
var hex = '';
if (addr && addr.length > 0) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
for (var j = 0; j < Math.min(8, bytes.length); j++) {
hex += ('0' + bytes[j].toString(16)).slice(-2);
}
hex += '\u2026';
}
var tr = document.createElement('tr');
tr.innerHTML = '<td style="color:#aaa;font-size:11px;">' + hex + '</td><td>W' + wid + '</td>';
tbody.appendChild(tr);
});
if (data.actors.length > 200) {
var tr2 = document.createElement('tr');
tr2.innerHTML = '<td colspan="2" style="color:#555;">... and ' + (data.actors.length - 200) + ' more</td>';
tbody.appendChild(tr2);
}
}
updateWorkerDetails(data); updateWorkerDetails(data);
} }
function formatAddr(addr) { // ── Differential actor table update ──────────────────────
var hex = ''; var actorRows = []; // pool of <tr> elements
if (addr && addr.length > 0) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr); function updateActorTable(actors) {
for (var j = 0; j < Math.min(8, bytes.length); j++) { var tbody = document.getElementById('actorTableBody');
hex += ('0' + bytes[j].toString(16)).slice(-2); if (!actors) actors = [];
} var shown = actors.slice(0, 200);
hex += '\u2026'; var needOverflow = actors.length > 200;
} var totalNeeded = shown.length + (needOverflow ? 1 : 0);
return hex;
// Remove excess rows
while (actorRows.length > totalNeeded) {
var old = actorRows.pop();
tbody.removeChild(old);
} }
function shortTypeName(full) { // Update or create rows
if (!full) return ''; for (var i = 0; i < shown.length; i++) {
var parts = full.split('::'); var addr = shown[i][0];
return parts[parts.length - 1]; var wid = shown[i][1];
var hex = addrToHex(addr);
var html = '<td style="color:#aaa;font-size:11px;">' + hex + '</td><td>W' + wid + '</td>';
if (i < actorRows.length) {
actorRows[i].innerHTML = html;
} else {
var tr = document.createElement('tr');
tr.innerHTML = html;
tbody.appendChild(tr);
actorRows.push(tr);
}
} }
// Overflow row
if (needOverflow) {
var idx = shown.length;
var html2 = '<td colspan="2" style="color:#555;">... and ' + (actors.length - 200) + ' more</td>';
if (idx < actorRows.length) {
actorRows[idx].innerHTML = html2;
} else {
var tr2 = document.createElement('tr');
tr2.innerHTML = html2;
tbody.appendChild(tr2);
actorRows.push(tr2);
}
}
}
// ── In-place worker details update ───────────────────────
function updateWorkerDetails(data) { function updateWorkerDetails(data) {
var container = document.getElementById('workerDetailContainer'); var container = document.getElementById('workerDetailContainer');
if (!data.workers) return; if (!data.workers) return;
@ -359,43 +256,37 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
}); });
} }
var openState = {};
container.querySelectorAll('.worker-group').forEach(function(g) {
openState[g.dataset.wid] = g.classList.contains('open');
});
container.innerHTML = '';
var wids = Object.keys(groups).sort(function(a, b) { return a - b; }); var wids = Object.keys(groups).sort(function(a, b) { return a - b; });
wids.forEach(function(wid) { // Remove nodes for workers that no longer exist
var widsSet = {};
for (var i = 0; i < wids.length; i++) widsSet[wids[i]] = true;
for (var key in workerNodes) {
if (!widsSet[key]) {
container.removeChild(workerNodes[key].div);
delete workerNodes[key];
delete workerOpenState[key];
}
}
// Update or create nodes for each worker
for (var i = 0; i < wids.length; i++) {
var wid = wids[i];
var g = groups[wid]; var g = groups[wid];
var div = document.createElement('div'); var node = workerNodes[wid];
var isOpen = openState[wid] || false;
div.className = 'worker-group' + (isOpen ? ' open' : '');
div.dataset.wid = wid;
var hdr = document.createElement('div'); // Build the summary and body HTML
hdr.className = 'worker-group-header';
var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : ''; var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : '';
hdr.innerHTML = var summaryHtml = g.actors.length + ' actors, ' +
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' + g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml;
'<span class="summary">' + g.actors.length + ' actors, ' +
g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml + '</span>' +
'<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
hdr.onclick = function() {
div.classList.toggle('open');
hdr.querySelector('.toggle').textContent = div.classList.contains('open') ? '\u25BC' : '\u25B6';
};
div.appendChild(hdr);
var body = document.createElement('div'); var bodyHtml;
body.className = 'worker-group-body';
if (g.actors.length === 0) { if (g.actors.length === 0) {
body.innerHTML = '<div style="padding:6px 12px;color:#555;">No actors</div>'; bodyHtml = '<div style="padding:6px 12px;color:#555;">No actors</div>';
} else { } else {
var rows = ''; var rows = '';
g.actors.forEach(function(a) { g.actors.forEach(function(a) {
var hex = formatAddr(a.address); var hex = addrToHex(a.address);
var hasMsg = !!a.last_msg_type; var hasMsg = !!a.last_msg_type;
var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none'; var msgShort = hasMsg ? shortTypeName(a.last_msg_type) : 'none';
var msgClass = hasMsg ? 'msg-type' : 'msg-type none'; var msgClass = hasMsg ? 'msg-type' : 'msg-type none';
@ -404,11 +295,45 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
'<td>' + a.mailbox_depth + '</td>' + '<td>' + a.mailbox_depth + '</td>' +
'<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>'; '<td class="' + msgClass + '"' + title + '>' + escapeHtml(msgShort) + '</td></tr>';
}); });
body.innerHTML = '<table><thead><tr><th>Address</th><th>Mailbox</th><th>Last Message</th></tr></thead><tbody>' + rows + '</tbody></table>'; bodyHtml = '<table><thead><tr><th>Address</th><th>Mailbox</th><th>Last Message</th></tr></thead><tbody>' + rows + '</tbody></table>';
} }
if (node) {
// Update existing node in place — header and click handler stay stable
node.summaryEl.innerHTML = summaryHtml;
node.bodyEl.innerHTML = bodyHtml;
} else {
// Create new node
var isOpen = !!workerOpenState[wid];
var div = document.createElement('div');
div.className = 'worker-group' + (isOpen ? ' open' : '');
var hdr = document.createElement('div');
hdr.className = 'worker-group-header';
hdr.innerHTML =
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
'<span class="summary">' + summaryHtml + '</span>' +
'<span class="toggle">' + (isOpen ? '\u25BC' : '\u25B6') + '</span>';
var summaryEl = hdr.querySelector('.summary');
var toggleEl = hdr.querySelector('.toggle');
hdr.onclick = (function(w, d, t) { return function() {
workerOpenState[w] = !workerOpenState[w];
d.classList.toggle('open');
t.textContent = d.classList.contains('open') ? '\u25BC' : '\u25B6';
}; })(wid, div, toggleEl);
div.appendChild(hdr);
var body = document.createElement('div');
body.className = 'worker-group-body';
body.innerHTML = bodyHtml;
div.appendChild(body); div.appendChild(body);
container.appendChild(div); container.appendChild(div);
}); workerNodes[wid] = { div: div, summaryEl: summaryEl, bodyEl: body, toggleEl: toggleEl };
}
}
} }
function addLogEvents(events) { function addLogEvents(events) {
@ -445,68 +370,11 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
} }
} }
function escapeHtml(s) { createSSE({
if (!s) return ''; stats: function(data) { updateStats(data); },
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); activity: function(data) { addLogEvents(data); }
}
function setStatus(s) {
if (s === 'done') {
dot.className = 'status-dot done';
if (isReplay) {
replayPct.textContent = '100%';
progressFill.style.width = '100%';
}
} else if (s === 'disconnected') {
dot.className = 'status-dot disconnected';
} else {
dot.className = isReplay ? 'status-dot replaying' : 'status-dot';
}
}
var es = new EventSource('/events');
es.addEventListener('stats', function(e) {
try { updateStats(JSON.parse(e.data)); } catch(err) { console.error('stats parse error', err); }
}); });
es.addEventListener('activity', function(e) {
try { addLogEvents(JSON.parse(e.data)); } catch(err) { console.error('activity parse error', err); }
});
es.addEventListener('replay_meta', function(e) {
try {
var meta = JSON.parse(e.data);
replaySpeed.textContent = meta.speed + 'x';
replaySpeed.style.display = 'inline';
replayPct.style.display = 'inline';
replayPct.textContent = '0%';
} catch(err) { console.error('replay_meta parse error', err); }
});
es.addEventListener('replay_progress', function(e) {
try {
var data = JSON.parse(e.data);
var pct = Math.round(data.progress * 100);
replayPct.textContent = pct + '%';
progressFill.style.width = pct + '%';
} catch(err) { console.error('replay_progress parse error', err); }
});
es.addEventListener('done', function() {
setStatus('done');
es.close();
});
es.onerror = function() {
setStatus('disconnected');
};
es.onopen = function() {
setStatus('connected');
};
})(); })();
</script> </script>
</body> </body>
</html> </html>
"##;

View file

@ -0,0 +1,159 @@
/* Swactor Dashboard — Shared JavaScript */
// ── Mode Detection ─────────────────────────────────────────────
var DASHBOARD_MODE = (document.querySelector('meta[name="dashboard-mode"]') || {}).content || 'live';
var isReplay = (DASHBOARD_MODE === 'replay');
// ── Shared DOM References ──────────────────────────────────────
var statusDot = document.getElementById('statusDot');
var uptimeLabel = document.getElementById('uptimeLabel');
var replayBadge = document.getElementById('replayBadge');
var replaySpeed = document.getElementById('replaySpeed');
var replayPct = document.getElementById('replayPct');
var progressBarWrap = document.getElementById('progressBarWrap');
var progressFill = document.getElementById('progressFill');
// ── Replay UI Setup ────────────────────────────────────────────
if (isReplay) {
if (replayBadge) replayBadge.className = 'replay-badge visible';
if (progressBarWrap) progressBarWrap.className = 'progress-bar-wrap visible';
if (statusDot) statusDot.className = 'status-dot replaying';
if (uptimeLabel) uptimeLabel.style.display = 'none';
}
// ── Uptime Tracking ────────────────────────────────────────────
var lastUptimeMs = null;
var lastStatsTime = null;
function updateUptime() {
if (isReplay || !uptimeLabel) return;
var up = lastUptimeMs;
if (up !== null && lastStatsTime !== null) {
up += (Date.now() - lastStatsTime);
}
if (up === null) { uptimeLabel.textContent = ''; return; }
var s = Math.floor(up / 1000);
var d = Math.floor(s / 86400);
var h = Math.floor((s % 86400) / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
var parts = [];
if (d > 0) parts.push(d + 'd');
if (h > 0 || d > 0) parts.push(h + 'h');
parts.push(m + 'm');
parts.push(sec + 's');
uptimeLabel.textContent = parts.join(' ');
}
if (uptimeLabel) setInterval(updateUptime, 1000);
function trackUptime(data) {
if (typeof data.uptime_ms === 'number') {
lastUptimeMs = data.uptime_ms;
lastStatsTime = Date.now();
}
}
// ── Status Dot Management ──────────────────────────────────────
function setStatus(s) {
if (!statusDot) return;
if (s === 'done') {
statusDot.className = 'status-dot done';
if (isReplay) {
if (replayPct) replayPct.textContent = '100%';
if (progressFill) progressFill.style.width = '100%';
}
} else if (s === 'disconnected') {
statusDot.className = 'status-dot disconnected';
} else {
statusDot.className = isReplay ? 'status-dot replaying' : 'status-dot';
}
}
// ── Utility Functions ──────────────────────────────────────────
function escapeHtml(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function addrToHex(addr) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
var hex = '';
for (var j = 0; j < Math.min(8, bytes.length); j++) {
hex += ('0' + bytes[j].toString(16)).slice(-2);
}
return hex + '\u2026';
}
function addrToFullHex(addr) {
var bytes = Array.isArray(addr) ? addr : Object.values(addr);
var hex = '';
for (var j = 0; j < bytes.length; j++) {
hex += ('0' + bytes[j].toString(16)).slice(-2);
}
return hex;
}
function shortTypeName(full) {
if (!full) return '';
var parts = full.split('::');
return parts[parts.length - 1];
}
// ── Color Palette ──────────────────────────────────────────────
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
function colorWithAlpha(hex, alpha) {
var r = parseInt(hex.slice(1, 3), 16);
var g = parseInt(hex.slice(3, 5), 16);
var b = parseInt(hex.slice(5, 7), 16);
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
}
// ── SSE Connection Factory ─────────────────────────────────────
//
// Usage:
// var es = createSSE({
// stats: function(data) { ... },
// activity: function(data) { ... },
// distribution: function(data) { ... }
// });
//
function createSSE(handlers) {
var es = new EventSource('/events');
// Wire page-specific handlers
Object.keys(handlers).forEach(function(event) {
es.addEventListener(event, function(e) {
try { handlers[event](JSON.parse(e.data)); }
catch(err) { console.error(event + ' parse error', err); }
});
});
// Common replay handlers
es.addEventListener('replay_meta', function(e) {
try {
var meta = JSON.parse(e.data);
if (replaySpeed) { replaySpeed.textContent = meta.speed + 'x'; replaySpeed.style.display = 'inline'; }
if (replayPct) { replayPct.style.display = 'inline'; replayPct.textContent = '0%'; }
} catch(err) { console.error('replay_meta parse error', err); }
});
es.addEventListener('replay_progress', function(e) {
try {
var data = JSON.parse(e.data);
var pct = Math.round(data.progress * 100);
if (replayPct) replayPct.textContent = pct + '%';
if (progressFill) progressFill.style.width = pct + '%';
} catch(err) { console.error('replay_progress parse error', err); }
});
es.addEventListener('done', function() {
setStatus('done');
es.close();
});
es.onerror = function() { setStatus('disconnected'); };
es.onopen = function() { setStatus('connected'); };
return es;
}

View file

@ -3,7 +3,7 @@ use std::net::SocketAddr;
use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult}; use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
use distribution::swim::node::NodeAction; use distribution::swim::node::NodeAction;
use distribution::swim::probe::SwimConfig; use distribution::swim::probe::SwimConfig;
use distribution::types::NodeId; use distribution::types::{NodeAddr, NodeId};
use swactor::actor::ActorAddress; use swactor::actor::ActorAddress;
use crate::trace::{Event, SimulationTrace}; use crate::trace::{Event, SimulationTrace};
@ -63,13 +63,14 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
// Create nodes with sequential addresses. // Create nodes with sequential addresses.
let mut nodes: Vec<Option<DistributedNode>> = Vec::with_capacity(n); let mut nodes: Vec<Option<DistributedNode>> = Vec::with_capacity(n);
let mut addrs: Vec<SocketAddr> = Vec::with_capacity(n); let mut addrs: Vec<NodeAddr> = Vec::with_capacity(n);
let mut node_ids: Vec<NodeId> = Vec::with_capacity(n); let mut node_ids: Vec<NodeId> = Vec::with_capacity(n);
for i in 0..n { for i in 0..n {
let addr: SocketAddr = format!("127.0.0.1:{}", 10001 + i).parse().unwrap(); let sock_addr: SocketAddr = format!("127.0.0.1:{}", 10001 + i).parse().unwrap();
let addr = NodeAddr::Tcp(sock_addr);
let node_config = DistributedNodeConfig { let node_config = DistributedNodeConfig {
listen_addr: addr, listen_addr: addr.clone(),
swim: config.swim.clone(), swim: config.swim.clone(),
cache_capacity: config.cache_capacity, cache_capacity: config.cache_capacity,
republish_interval: 50, republish_interval: 50,
@ -81,9 +82,9 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
} }
// Form cluster: nodes[1..] join via seed (node 0). // Form cluster: nodes[1..] join via seed (node 0).
let seed_addr = addrs[0]; let seed_addr = addrs[0].clone();
for i in 1..n { for i in 1..n {
let join_actions = nodes[i].as_ref().unwrap().join(&[seed_addr]); let join_actions = nodes[i].as_ref().unwrap().join(&[seed_addr.clone()]);
events.push(Event { events.push(Event {
tick: 0, tick: 0,
node_name: node_names[i].clone(), node_name: node_names[i].clone(),
@ -96,7 +97,7 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
let tagged_responses = deliver_actions_tagged( let tagged_responses = deliver_actions_tagged(
&join_actions, &join_actions,
node_ids[i], node_ids[i],
addrs[i], addrs[i].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -105,7 +106,7 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
deliver_actions_tagged( deliver_actions_tagged(
&response_actions, &response_actions,
node_ids[responder_idx], node_ids[responder_idx],
addrs[responder_idx], addrs[responder_idx].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -180,21 +181,21 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
for &(revive_round, revive_idx) in &config.revive_schedule { for &(revive_round, revive_idx) in &config.revive_schedule {
if revive_round == round && revive_idx < n { if revive_round == round && revive_idx < n {
let node_config = DistributedNodeConfig { let node_config = DistributedNodeConfig {
listen_addr: addrs[revive_idx], listen_addr: addrs[revive_idx].clone(),
swim: config.swim.clone(), swim: config.swim.clone(),
cache_capacity: config.cache_capacity, cache_capacity: config.cache_capacity,
republish_interval: 50, republish_interval: 50,
}; };
let revived = DistributedNode::new(node_config); let revived = DistributedNode::new(node_config);
// Rejoin the cluster. // Rejoin the cluster.
let join_actions = revived.join(&[seed_addr]); let join_actions = revived.join(&[seed_addr.clone()]);
nodes[revive_idx] = Some(revived); nodes[revive_idx] = Some(revived);
node_ids[revive_idx] = nodes[revive_idx].as_ref().unwrap().node_id(); node_ids[revive_idx] = nodes[revive_idx].as_ref().unwrap().node_id();
let tagged_responses = deliver_actions_tagged( let tagged_responses = deliver_actions_tagged(
&join_actions, &join_actions,
node_ids[revive_idx], node_ids[revive_idx],
addrs[revive_idx], addrs[revive_idx].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -203,7 +204,7 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
deliver_actions_tagged( deliver_actions_tagged(
&response_actions, &response_actions,
node_ids[responder_idx], node_ids[responder_idx],
addrs[responder_idx], addrs[responder_idx].clone(),
&mut nodes, &mut nodes,
&node_ids, &node_ids,
&addrs, &addrs,
@ -317,7 +318,7 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
fn tick_all_and_deliver( fn tick_all_and_deliver(
nodes: &mut [Option<DistributedNode>], nodes: &mut [Option<DistributedNode>],
node_ids: &[NodeId], node_ids: &[NodeId],
addrs: &[SocketAddr], addrs: &[NodeAddr],
events: &mut Vec<Event<DistributionEventKind>>, events: &mut Vec<Event<DistributionEventKind>>,
node_names: &[String], node_names: &[String],
tick: u64, tick: u64,
@ -355,7 +356,7 @@ fn tick_all_and_deliver(
let tagged_responses = deliver_actions_tagged( let tagged_responses = deliver_actions_tagged(
&actions, &actions,
node_ids[sender_idx], node_ids[sender_idx],
addrs[sender_idx], addrs[sender_idx].clone(),
nodes, nodes,
node_ids, node_ids,
addrs, addrs,
@ -365,7 +366,7 @@ fn tick_all_and_deliver(
deliver_actions_tagged( deliver_actions_tagged(
&response_actions, &response_actions,
node_ids[responder_idx], node_ids[responder_idx],
addrs[responder_idx], addrs[responder_idx].clone(),
nodes, nodes,
node_ids, node_ids,
addrs, addrs,
@ -380,10 +381,10 @@ fn tick_all_and_deliver(
fn deliver_actions_tagged( fn deliver_actions_tagged(
actions: &[NodeAction], actions: &[NodeAction],
sender_id: NodeId, sender_id: NodeId,
sender_addr: SocketAddr, sender_addr: NodeAddr,
nodes: &mut [Option<DistributedNode>], nodes: &mut [Option<DistributedNode>],
node_ids: &[NodeId], node_ids: &[NodeId],
node_addrs: &[SocketAddr], node_addrs: &[NodeAddr],
) -> Vec<(usize, Vec<NodeAction>)> { ) -> Vec<(usize, Vec<NodeAction>)> {
let mut tagged_responses: Vec<(usize, Vec<NodeAction>)> = Vec::new(); let mut tagged_responses: Vec<(usize, Vec<NodeAction>)> = Vec::new();
@ -398,7 +399,7 @@ fn deliver_actions_tagged(
if let Some(idx) = node_ids.iter().position(|id| id == to) { if let Some(idx) = node_ids.iter().position(|id| id == to) {
if let Some(ref mut node) = nodes[idx] { if let Some(ref mut node) = nodes[idx] {
let resp = let resp =
node.handle_ping(sender_id, sender_addr, *sequence, piggyback); node.handle_ping(sender_id, sender_addr.clone(), *sequence, piggyback);
if !resp.is_empty() { if !resp.is_empty() {
tagged_responses.push((idx, resp)); tagged_responses.push((idx, resp));
} }
@ -423,7 +424,7 @@ fn deliver_actions_tagged(
NodeAction::SendJoinRequest { to_addr } => { NodeAction::SendJoinRequest { to_addr } => {
if let Some(idx) = node_addrs.iter().position(|a| a == to_addr) { if let Some(idx) = node_addrs.iter().position(|a| a == to_addr) {
if let Some(ref mut node) = nodes[idx] { if let Some(ref mut node) = nodes[idx] {
let resp = node.handle_join_request(sender_id, sender_addr); let resp = node.handle_join_request(sender_id, sender_addr.clone());
if !resp.is_empty() { if !resp.is_empty() {
tagged_responses.push((idx, resp)); tagged_responses.push((idx, resp));
} }
@ -453,7 +454,7 @@ fn deliver_actions_tagged(
let resp = node.handle_ping_req( let resp = node.handle_ping_req(
sender_id, sender_id,
*target, *target,
*target_addr, target_addr.clone(),
*sequence, *sequence,
piggyback, piggyback,
); );

View file

@ -1,11 +1,18 @@
[package] [package]
name = "wasm" name = "swactor-wasm"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
swactor = { path = "../..", default-features = false, features = ["no_random"] } swactor = { path = "../..", default-features = false, features = ["no_random", "transport"] }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"WebSocket", "BinaryType", "MessageEvent", "ErrorEvent",
"CloseEvent", "Blob", "console", "Window",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

131
crates/wasm/src/js_actor.rs Normal file
View file

@ -0,0 +1,131 @@
//! JavaScript actor wrapper.
//!
//! Allows JS functions to act as swactor actors. Each JS actor receives
//! messages as JSON strings and uses a `JsActorCtx` bridge to send
//! messages and spawn new actors.
use wasm_bindgen::prelude::*;
use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::Ctx;
// ─── JsMessage ────────────────────────────────────────────────────────────────
/// Unified message type for all JS actors — a JSON string payload.
#[derive(Clone, Debug)]
pub struct JsMessage {
/// The type_tag from the wire protocol (or "local" for local sends).
pub type_tag: String,
/// JSON-encoded payload.
pub payload: String,
}
// Message requires: 'static + Sized + Clone + Send + Sync
// Safety: wasm32 is single-threaded; Send+Sync are vacuously satisfied.
unsafe impl Send for JsMessage {}
unsafe impl Sync for JsMessage {}
// ─── JsActorCtx ──────────────────────────────────────────────────────────────
/// Bridge object passed to JS actor handlers, allowing them to send messages
/// and spawn new actors.
#[wasm_bindgen]
pub struct JsActorCtx {
// These fields are populated by the JsActor::handle method before
// calling the JS handler, and consumed by the JS handler via methods.
ctx_ptr: *const Ctx<'static>,
self_addr: ActorAddress,
}
// Safety: single-threaded wasm32.
unsafe impl Send for JsActorCtx {}
unsafe impl Sync for JsActorCtx {}
#[wasm_bindgen]
impl JsActorCtx {
/// Get this actor's own address as a hex string.
pub fn self_addr(&self) -> String {
hex_encode(&self.self_addr.0)
}
/// Send a JSON message to another actor.
///
/// `dest_hex` is the 64-character hex address of the target actor.
/// `msg_json` is the JSON payload.
pub fn send(&self, dest_hex: &str, type_tag: &str, msg_json: &str) -> bool {
let Some(dest) = hex_decode_address(dest_hex) else {
return false;
};
let msg = JsMessage {
type_tag: type_tag.to_string(),
payload: msg_json.to_string(),
};
// Safety: ctx_ptr is valid for the duration of the handle() call.
let ctx = unsafe { &*self.ctx_ptr };
ctx.send(dest, msg).is_ok()
}
}
// ─── JsActor ─────────────────────────────────────────────────────────────────
/// Wraps a JS function as a swactor actor.
///
/// The JS handler signature: `(ctx: JsActorCtx, type_tag: string, msg_json: string) => void`
pub struct JsActor {
handler: js_sys::Function,
}
// Safety: single-threaded wasm32.
unsafe impl Send for JsActor {}
impl JsActor {
pub fn new(handler: js_sys::Function) -> Self {
Self { handler }
}
}
impl ActorInterface for JsActor {
type Incoming = JsMessage;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: JsMessage) {
// Create a JsActorCtx bridge for this handler invocation.
// Safety: we extend the lifetime to 'static, but the ptr is only valid
// during this synchronous call. The JsActorCtx does not escape.
let ctx_ptr = ctx as *const Ctx<'_> as *const Ctx<'static>;
let js_ctx = JsActorCtx {
ctx_ptr,
self_addr: ctx.self_addr(),
};
let this = JsValue::null();
let js_ctx_val = JsValue::from(js_ctx);
let js_type_tag = JsValue::from_str(&msg.type_tag);
let js_payload = JsValue::from_str(&msg.payload);
let _ = self.handler.call3(&this, &js_ctx_val, &js_type_tag, &js_payload);
}
}
// ─── Hex helpers ─────────────────────────────────────────────────────────────
/// Encode 32 bytes as a 64-character hex string.
pub fn hex_encode(bytes: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
/// Decode a 64-character hex string into an `ActorAddress`.
pub fn hex_decode_address(hex: &str) -> Option<ActorAddress> {
if hex.len() != 64 {
return None;
}
let mut bytes = [0u8; 32];
for i in 0..32 {
bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
}
Some(ActorAddress(bytes))
}

View file

@ -1,11 +1,49 @@
pub mod js_actor;
pub mod protocol;
pub mod ws_transport;
use std::collections::HashMap;
use std::sync::Arc;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use swactor::actor::{ActorAddress, ActorInterface}; use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig}; use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
use swactor::transport::{
Codec, CodecRegistry, NetworkMessage, TransportRouter,
};
use swactor::Error;
// --------------------------------------------------------------------------- use crate::js_actor::{hex_decode_address, hex_encode, JsActor, JsMessage};
// Actors (private — only exposed through the wasm API) use crate::ws_transport::WsTransport;
// ---------------------------------------------------------------------------
// ─── JsMessage codec (JSON) ─────────────────────────────────────────────────
/// JSON-based codec for JsMessage — used for all JS actor communication.
struct JsMessageCodec;
impl Codec<JsMessage> for JsMessageCodec {
fn encode(&self, msg: &JsMessage) -> Result<Vec<u8>, Error> {
Ok(msg.payload.as_bytes().to_vec())
}
fn decode(&self, bytes: &[u8]) -> Result<JsMessage, Error> {
let payload = String::from_utf8(bytes.to_vec())
.map_err(|e| Error::from(format!("JsMessage decode: {e}")))?;
Ok(JsMessage {
type_tag: "js".to_string(),
payload,
})
}
}
impl NetworkMessage for JsMessage {
fn type_tag() -> &'static str {
"swactor::JsMessage"
}
}
// ─── Built-in actors ─────────────────────────────────────────────────────────
struct Counter { struct Counter {
total: u32, total: u32,
@ -35,14 +73,134 @@ impl ActorInterface for Relay {
} }
} }
// --------------------------------------------------------------------------- // ─── BrowserRuntime ──────────────────────────────────────────────────────────
// JS-facing runtime wrapper
// ---------------------------------------------------------------------------
/// JS-facing runtime that supports local actors (Rust and JS-defined),
/// remote messaging via WebSocket, and external inboxes.
#[wasm_bindgen]
pub struct BrowserRuntime {
rt: Runtime,
transport: Option<WsTransport>,
codec_registry: Arc<CodecRegistry>,
/// Hex address → inbox receiver for JS-polled inboxes.
inboxes: HashMap<String, swactor::runtime::Inbox<JsMessage>>,
}
#[wasm_bindgen]
impl BrowserRuntime {
/// Create a new browser runtime.
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
let mut codec_registry = CodecRegistry::new();
codec_registry.register::<JsMessage, _>(JsMessageCodec);
let codec_registry = Arc::new(codec_registry);
let mut rt = Runtime::new(RuntimeConfig {
num_threads: 1,
..RuntimeConfig::default()
});
rt.set_codec_registry(codec_registry.clone());
rt.set_transport_router(Arc::new(TransportRouter::new()));
Self {
rt,
transport: None,
codec_registry,
inboxes: HashMap::new(),
}
}
/// Connect to a gateway node via WebSocket.
pub fn connect(&mut self, gateway_url: &str) -> Result<(), JsValue> {
let transport = WsTransport::connect(gateway_url)
.map_err(|e| JsValue::from_str(&format!("{e}")))?;
self.transport = Some(transport);
Ok(())
}
/// Whether the WebSocket connection is currently open.
pub fn is_connected(&self) -> bool {
self.transport.as_ref().is_some_and(|t| t.is_connected())
}
/// Spawn a JS-defined actor. Returns the 64-character hex address.
///
/// The handler signature: `(ctx: JsActorCtx, type_tag: string, msg_json: string) => void`
pub fn spawn_js_actor(&mut self, handler: js_sys::Function) -> String {
let actor = JsActor::new(handler);
let addr = self.rt.spawn(actor).expect("spawn js actor");
// Register with gateway if connected
if let Some(ref transport) = self.transport {
let _ = transport.register_actor(&addr);
}
hex_encode(&addr.0)
}
/// Send a JSON message to any actor (local or remote).
///
/// `dest_hex` is the 64-character hex address of the target.
/// `type_tag` is the message type tag.
/// `msg_json` is the JSON payload.
pub fn send_json(&self, dest_hex: &str, type_tag: &str, msg_json: &str) -> bool {
let Some(dest) = hex_decode_address(dest_hex) else {
return false;
};
let msg = JsMessage {
type_tag: type_tag.to_string(),
payload: msg_json.to_string(),
};
self.rt.send_to(dest, msg).is_ok()
}
/// Drive one tick of the runtime.
///
/// Also drains any inbound WebSocket messages and delivers them locally.
pub fn tick(&self) {
// Drain inbound WS envelopes
if let Some(ref transport) = self.transport {
for envelope in transport.drain_inbound() {
if let Ok((addr, msg)) = self.codec_registry.receive(envelope) {
let _ = self.rt.deliver_raw(addr, msg);
}
}
}
self.rt.tick();
}
/// Create an inbox for receiving JsMessage in JS. Returns the hex address.
pub fn create_inbox(&mut self) -> String {
let inbox = self.rt.new_inbox::<JsMessage>().expect("create inbox");
let hex = hex_encode(&inbox.addr().0);
self.inboxes.insert(hex.clone(), inbox);
hex
}
/// Poll an inbox by hex address. Returns the JSON payload or undefined.
pub fn try_recv(&self, inbox_hex: &str) -> JsValue {
if let Some(inbox) = self.inboxes.get(inbox_hex) {
if let Some(msg) = inbox.try_recv() {
return JsValue::from_str(&msg.payload);
}
}
JsValue::UNDEFINED
}
/// Number of local actors.
pub fn actor_count(&self) -> usize {
self.rt.stats().actors.len()
}
}
// ─── Legacy SwactorRuntime (preserved for backwards compatibility) ────────────
/// Simple runtime wrapper for the Counter/Relay demo actors.
#[wasm_bindgen] #[wasm_bindgen]
pub struct SwactorRuntime { pub struct SwactorRuntime {
rt: Runtime, rt: Runtime,
inbox: Inbox<u32>, inbox: swactor::runtime::Inbox<u32>,
actors: Vec<ActorAddress>, actors: Vec<ActorAddress>,
} }
@ -62,7 +220,6 @@ impl SwactorRuntime {
} }
} }
/// Spawn a counter actor. Returns its index (used with `send`).
pub fn spawn_counter(&mut self) -> usize { pub fn spawn_counter(&mut self) -> usize {
let addr = self let addr = self
.rt .rt
@ -76,19 +233,14 @@ impl SwactorRuntime {
idx idx
} }
/// Spawn a relay that forwards every message to `target_idx`.
pub fn spawn_relay(&mut self, target_idx: usize) -> usize { pub fn spawn_relay(&mut self, target_idx: usize) -> usize {
let target = self.actors[target_idx]; let target = self.actors[target_idx];
let addr = self let addr = self.rt.spawn(Relay { target }).expect("spawn relay");
.rt
.spawn(Relay { target })
.expect("spawn relay");
let idx = self.actors.len(); let idx = self.actors.len();
self.actors.push(addr); self.actors.push(addr);
idx idx
} }
/// Send a u32 to the actor at `actor_idx`.
pub fn send(&self, actor_idx: usize, value: u32) -> bool { pub fn send(&self, actor_idx: usize, value: u32) -> bool {
if actor_idx >= self.actors.len() { if actor_idx >= self.actors.len() {
return false; return false;
@ -96,17 +248,14 @@ impl SwactorRuntime {
self.rt.send_to(self.actors[actor_idx], value).is_ok() self.rt.send_to(self.actors[actor_idx], value).is_ok()
} }
/// Drive one tick of the single-threaded runtime.
pub fn tick(&self) { pub fn tick(&self) {
self.rt.tick(); self.rt.tick();
} }
/// Try to read the next result from the inbox. Returns `undefined` when empty.
pub fn try_recv(&self) -> Option<u32> { pub fn try_recv(&self) -> Option<u32> {
self.inbox.try_recv() self.inbox.try_recv()
} }
/// Number of actors the runtime knows about.
pub fn actor_count(&self) -> usize { pub fn actor_count(&self) -> usize {
self.rt.stats().actors.len() self.rt.stats().actors.len()
} }

View file

@ -0,0 +1,29 @@
//! Control protocol for browser ↔ gateway communication.
//!
//! Sent as `WireEnvelope` with `type_tag = "swactor::GatewayControl"`
//! and `dest = ActorAddress([0u8; 32])` (reserved null address).
use serde::{Deserialize, Serialize};
/// Control messages exchanged between browser and gateway.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GatewayControl {
/// Browser → Gateway: register a local actor address.
RegisterActor { addr: [u8; 32] },
/// Browser → Gateway: unregister a local actor address.
UnregisterActor { addr: [u8; 32] },
/// Browser → Gateway: resolve an actor by name.
ResolveActor { name: String },
/// Gateway → Browser: actor resolution result.
ActorResolved { name: String, addr: [u8; 32] },
/// Keepalive ping.
Ping,
/// Keepalive pong.
Pong,
}
/// The type_tag used for control messages.
pub const CONTROL_TYPE_TAG: &str = "swactor::GatewayControl";
/// The null address used as dest for control messages.
pub const NULL_ADDRESS: [u8; 32] = [0u8; 32];

View file

@ -0,0 +1,179 @@
//! WebSocket transport for the browser runtime.
//!
//! Wraps `web_sys::WebSocket` and implements swactor's `Transport` trait.
//! Messages are sent as binary frames using the same wire format as TCP.
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use web_sys::{MessageEvent, WebSocket};
use swactor::actor::ActorAddress;
use swactor::transport::{encode_wire_envelope, decode_wire_envelope, Transport, WireEnvelope};
use swactor::Error;
use crate::protocol::{GatewayControl, CONTROL_TYPE_TAG, NULL_ADDRESS};
// ─── Connection state ─────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConnState {
Connecting,
Open,
Closed,
}
// ─── Shared inner state ──────────────────────────────────────────────────────
struct WsInner {
ws: WebSocket,
state: ConnState,
/// Outbound buffer for messages queued before the connection opens.
pending: Vec<Vec<u8>>,
/// Inbound envelope buffer, filled by the onmessage callback.
inbound: Vec<WireEnvelope>,
}
// ─── WsTransport ──────────────────────────────────────────────────────────────
/// WebSocket-based transport for browser → gateway communication.
///
/// Uses the same length-prefixed binary wire format as the TCP transport.
/// Messages are buffered until the WebSocket connection is open.
pub struct WsTransport {
inner: Rc<RefCell<WsInner>>,
}
// Safety: wasm32 is single-threaded. The Rc<RefCell<..>> is never shared
// across threads. This matches the pattern used by Runtime's `unsafe impl Sync`.
unsafe impl Send for WsTransport {}
unsafe impl Sync for WsTransport {}
impl WsTransport {
/// Connect to a gateway WebSocket endpoint.
///
/// Returns immediately; the connection completes asynchronously.
/// Messages sent before the connection is open are buffered and flushed on open.
pub fn connect(url: &str) -> Result<Self, Error> {
let ws = WebSocket::new(url)
.map_err(|e| Error::from(format!("WebSocket connect: {e:?}")))?;
ws.set_binary_type(web_sys::BinaryType::Arraybuffer);
let inner = Rc::new(RefCell::new(WsInner {
ws: ws.clone(),
state: ConnState::Connecting,
pending: Vec::new(),
inbound: Vec::new(),
}));
// onopen — flush pending buffer
{
let inner_clone = inner.clone();
let onopen = Closure::<dyn FnMut()>::new(move || {
let mut inner = inner_clone.borrow_mut();
inner.state = ConnState::Open;
let pending: Vec<Vec<u8>> = inner.pending.drain(..).collect();
for buf in pending {
let _ = inner.ws.send_with_u8_array(&buf);
}
});
ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
onopen.forget();
}
// onmessage — decode incoming binary frames
{
let inner_clone = inner.clone();
let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Ok(abuf) = e.data().dyn_into::<js_sys::ArrayBuffer>() {
let array = js_sys::Uint8Array::new(&abuf);
let bytes = array.to_vec();
// The wire format includes a 4-byte length prefix; skip it.
if bytes.len() >= 4 {
let frame = &bytes[4..];
if let Ok(envelope) = decode_wire_envelope(frame) {
inner_clone.borrow_mut().inbound.push(envelope);
}
}
}
});
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onmessage.forget();
}
// onclose
{
let inner_clone = inner.clone();
let onclose = Closure::<dyn FnMut()>::new(move || {
inner_clone.borrow_mut().state = ConnState::Closed;
});
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
onclose.forget();
}
// onerror
{
let inner_clone = inner.clone();
let onerror = Closure::<dyn FnMut()>::new(move || {
inner_clone.borrow_mut().state = ConnState::Closed;
});
ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
onerror.forget();
}
Ok(Self { inner })
}
/// Whether the WebSocket connection is currently open.
pub fn is_connected(&self) -> bool {
self.inner.borrow().state == ConnState::Open
}
/// Drain all inbound envelopes received since the last call.
pub fn drain_inbound(&self) -> Vec<WireEnvelope> {
let mut inner = self.inner.borrow_mut();
std::mem::take(&mut inner.inbound)
}
/// Send a control message to the gateway.
pub fn send_control(&self, ctrl: &GatewayControl) -> Result<(), Error> {
let payload = serde_json::to_vec(ctrl)
.map_err(|e| Error::from(format!("serialize control: {e}")))?;
let envelope = WireEnvelope {
dest: ActorAddress(NULL_ADDRESS),
type_tag: CONTROL_TYPE_TAG.to_string(),
payload,
};
self.send(envelope)
}
/// Register a local actor address with the gateway.
pub fn register_actor(&self, addr: &ActorAddress) -> Result<(), Error> {
self.send_control(&GatewayControl::RegisterActor { addr: addr.0 })
}
fn send_raw(&self, buf: Vec<u8>) -> Result<(), Error> {
let mut inner = self.inner.borrow_mut();
match inner.state {
ConnState::Connecting => {
inner.pending.push(buf);
Ok(())
}
ConnState::Open => {
inner.ws.send_with_u8_array(&buf)
.map_err(|e| Error::from(format!("WebSocket send: {e:?}")))
}
ConnState::Closed => {
Err(Error::from("WebSocket is closed"))
}
}
}
}
impl Transport for WsTransport {
fn send(&self, envelope: WireEnvelope) -> Result<(), Error> {
let buf = encode_wire_envelope(&envelope);
self.send_raw(buf)
}
}

153
crates/wasm/www/index.html Normal file
View file

@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Swactor Browser Runtime</title>
<style>
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; padding: 2rem; }
h1 { color: #00d4ff; }
.panel { background: #16213e; border: 1px solid #0f3460; border-radius: 4px; padding: 1rem; margin: 1rem 0; }
button { background: #0f3460; color: #e0e0e0; border: 1px solid #00d4ff; padding: 0.5rem 1rem; cursor: pointer; border-radius: 3px; margin: 0.25rem; }
button:hover { background: #00d4ff; color: #1a1a2e; }
input { background: #16213e; color: #e0e0e0; border: 1px solid #0f3460; padding: 0.5rem; border-radius: 3px; width: 300px; }
#log { max-height: 400px; overflow-y: auto; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.4; }
.info { color: #00d4ff; }
.sent { color: #e94560; }
.recv { color: #0cca4a; }
.error { color: #ff6b6b; }
.status { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
.status.connected { background: #0cca4a; }
.status.disconnected { background: #ff6b6b; }
</style>
</head>
<body>
<h1>Swactor Browser Runtime</h1>
<div class="panel">
<h3>Connection</h3>
<span id="status-dot" class="status disconnected"></span>
<span id="status-text">Disconnected</span>
<br><br>
<input id="gateway-url" value="ws://localhost:9000" placeholder="Gateway URL">
<button id="btn-connect">Connect</button>
</div>
<div class="panel">
<h3>Actors</h3>
<button id="btn-spawn">Spawn JS Actor</button>
<span id="actor-count">Actors: 0</span>
<div id="actors-list" style="margin-top: 0.5rem; font-size: 0.85rem;"></div>
</div>
<div class="panel">
<h3>Send Message</h3>
<input id="dest-addr" placeholder="Destination hex address (64 chars)">
<input id="msg-payload" placeholder="JSON payload" value='{"hello": "world"}'>
<button id="btn-send">Send</button>
</div>
<div class="panel">
<h3>Log</h3>
<div id="log"></div>
</div>
<script type="module">
// The wasm-pack output will be at ./pkg/swactor_wasm.js
// Build with: cd crates/wasm && wasm-pack build --target web
import init, { BrowserRuntime } from './pkg/swactor_wasm.js';
let rt = null;
const logEl = document.getElementById('log');
function log(msg, cls = 'info') {
const line = document.createElement('div');
line.className = cls;
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
async function main() {
try {
await init();
log('WASM module loaded');
rt = new BrowserRuntime();
log('BrowserRuntime created');
// Tick loop
setInterval(() => {
if (rt) {
rt.tick();
document.getElementById('actor-count').textContent = `Actors: ${rt.actor_count()}`;
const connected = rt.is_connected();
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
dot.className = `status ${connected ? 'connected' : 'disconnected'}`;
text.textContent = connected ? 'Connected' : 'Disconnected';
}
}, 16); // ~60fps
} catch (e) {
log(`Init error: ${e}`, 'error');
}
}
// Connect button
document.getElementById('btn-connect').addEventListener('click', () => {
if (!rt) { log('Runtime not ready', 'error'); return; }
const url = document.getElementById('gateway-url').value;
try {
rt.connect(url);
log(`Connecting to ${url}...`);
} catch (e) {
log(`Connect error: ${e}`, 'error');
}
});
// Spawn JS actor
document.getElementById('btn-spawn').addEventListener('click', () => {
if (!rt) { log('Runtime not ready', 'error'); return; }
const addr = rt.spawn_js_actor((ctx, typeTag, msgJson) => {
log(`Actor ${ctx.self_addr().substring(0, 16)}... received [${typeTag}]: ${msgJson}`, 'recv');
});
log(`Spawned JS actor: ${addr.substring(0, 16)}...`, 'info');
const list = document.getElementById('actors-list');
const item = document.createElement('div');
item.textContent = addr;
item.style.cursor = 'pointer';
item.title = 'Click to copy';
item.addEventListener('click', () => {
navigator.clipboard.writeText(addr);
log(`Copied address to clipboard`);
});
list.appendChild(item);
});
// Send message
document.getElementById('btn-send').addEventListener('click', () => {
if (!rt) { log('Runtime not ready', 'error'); return; }
const dest = document.getElementById('dest-addr').value;
const payload = document.getElementById('msg-payload').value;
if (dest.length !== 64) {
log('Destination must be a 64-character hex address', 'error');
return;
}
const ok = rt.send_json(dest, 'js', payload);
if (ok) {
log(`Sent to ${dest.substring(0, 16)}...: ${payload}`, 'sent');
} else {
log('Send failed', 'error');
}
});
main();
</script>
</body>
</html>

7
docs/DOCUMENTING.md Normal file
View file

@ -0,0 +1,7 @@
When requested to document your changes:
- read the `docs/` folder
- summarize your changes and add them to the `docs/development_history`
- use the extant files as a guide for how you should write the document
- add a section `verifying` that shows how a user can verify the changes work as expected
- check the relevant md and svg files in `docs` and add any changes
- add new docs to the folder if necessary, but do not clutter

View file

@ -0,0 +1,560 @@
# 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
```

View file

@ -89,6 +89,23 @@ propagates effects through all subsystems:
This cascade ensures that a single SWIM death detection triggers routing This cascade ensures that a single SWIM death detection triggers routing
table cleanup, cache invalidation, and directory repair in one tick. table cleanup, cache invalidation, and directory repair in one tick.
## NodeAddr
All network addresses in the distribution crate use `NodeAddr` — an extensible
enum that currently wraps `SocketAddr`:
```rust
pub enum NodeAddr {
Tcp(SocketAddr),
// Future: Ws(String), WebRtc(String)
}
```
This abstraction allows the distribution layer to eventually support WebSocket
and WebRTC peers natively. The `TcpTransport` extracts the inner `SocketAddr`
via pattern matching. Adding a new transport variant (e.g., `Ws`) is a
compiler-guided refactor — every match arm will produce an error until handled.
## Where Things Live ## Where Things Live
| Type | File | Role | | Type | File | Role |

View file

@ -64,6 +64,19 @@ Single-threaded mode keeps the `Worker` inline and requires the caller to
call `rt.tick()` to advance the simulation. This is useful for deterministic call `rt.tick()` to advance the simulation. This is useful for deterministic
testing, WASM, or game loops where you want frame-level control. testing, WASM, or game loops where you want frame-level control.
### WASM Compatibility
The runtime compiles for `wasm32-unknown-unknown`. Thread-dependent code
(`RuntimeHandle`, `run()`, `Worker::run()`) is cfg-gated with
`#[cfg(not(target_arch = "wasm32"))]`. On wasm32, only `tick()` mode is
available. `std::time::Instant` is replaced by `web_time::Instant` (backed
by `performance.now()`) via a compile-time switch in `src/lib.rs`.
```bash
cargo build --target wasm32-unknown-unknown -p swactor \
--no-default-features --features "no_random,transport"
```
Multi-threaded mode consumes the `Runtime` via `run()`, wraps it in an Multi-threaded mode consumes the `Runtime` via `run()`, wraps it in an
`Arc`, and spawns one OS thread per worker. Returns a `RuntimeHandle`. `Arc`, and spawns one OS thread per worker. Returns a `RuntimeHandle`.

View file

@ -96,6 +96,36 @@ which remote addresses exist via `router.add_route()`. Since addresses are
32 random bytes, runtimes must exchange them out-of-band (e.g., over the TCP 32 random bytes, runtimes must exchange them out-of-band (e.g., over the TCP
connection itself — see `examples/tcp_ping_pong.rs`). connection itself — see `examples/tcp_ping_pong.rs`).
## Wire Format
The core transport module provides canonical encode/decode functions for the
binary wire format. These are used by both the TCP transport (distribution
crate) and the WebSocket transport (wasm crate):
```
encode_wire_envelope(envelope) → bytes:
┌──────────────┬───────────────┬──────────────┬──────────┬─────────┐
│ frame_len │ dest address │ tag_len │ type_tag │ payload │
│ 4 bytes BE │ 32 bytes │ 4 bytes BE │ N bytes │ rest │
└──────────────┴───────────────┴──────────────┴──────────┴─────────┘
```
`decode_wire_envelope(frame)` takes the bytes *after* the 4-byte length prefix
and returns a `WireEnvelope`. The caller is responsible for reading the length
prefix and providing exactly `frame_len` bytes.
## WebSocket Transport (Browser)
The `swactor-wasm` crate provides `WsTransport` — a `Transport` impl over
`web_sys::WebSocket`. It uses the same wire format as TCP (binary frames with
length prefix). Messages are buffered until the WebSocket connection opens.
A `GatewayControl` protocol (JSON over reserved null address) handles actor
registration and keepalive between browser and gateway.
See `crates/wasm/src/ws_transport.rs` and `crates/gateway/` for the browser
and native sides respectively.
## Limitations ## Limitations
- **No automatic discovery** — manual address exchange required - **No automatic discovery** — manual address exchange required
@ -114,5 +144,11 @@ connection itself — see `examples/tcp_ping_pong.rs`).
| Transport fields on `TickContext` | `src/delivery.rs` | | Transport fields on `TickContext` | `src/delivery.rs` |
| `Runtime::deliver_raw`, setters | `src/runtime.rs` | | `Runtime::deliver_raw`, setters | `src/runtime.rs` |
| Transport fallback in worker | `src/worker.rs` | | Transport fallback in worker | `src/worker.rs` |
| Wire encode/decode | `src/transport.rs` |
| Integration tests | `tests/transport_api.rs` | | Integration tests | `tests/transport_api.rs` |
| TCP example | `examples/tcp_ping_pong.rs` | | TCP example | `examples/tcp_ping_pong.rs` |
| WebSocket transport (browser) | `crates/wasm/src/ws_transport.rs` |
| Gateway control protocol | `crates/wasm/src/protocol.rs` |
| WebSocket gateway (native) | `crates/gateway/src/lib.rs` |
| WS acceptor | `crates/gateway/src/ws_acceptor.rs` |
| Gateway example | `examples/ws_gateway.rs` |

174
examples/ws_gateway.rs Normal file
View file

@ -0,0 +1,174 @@
//! WebSocket gateway example.
//!
//! Starts a native node with some demo actors and a WebSocket gateway.
//! Browser clients connect via WebSocket to send/receive messages.
//!
//! ```bash
//! cargo run --example ws_gateway --features transport
//! ```
//!
//! Then open `crates/wasm/www/index.html` in a browser.
use std::sync::Arc;
use std::time::Duration;
use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
use swactor::transport::{
Codec, CodecRegistry, NetworkMessage, TransportRouter,
};
use swactor::Error;
use swactor_gateway::WsGateway;
// ─── Messages ────────────────────────────────────────────────────────────────
/// A simple echo request: the payload + who to reply to.
#[derive(Clone, Debug)]
struct EchoRequest {
payload: String,
reply_to: ActorAddress,
}
impl NetworkMessage for EchoRequest {
fn type_tag() -> &'static str {
"example::EchoRequest"
}
}
/// Echo response — just the echoed payload.
#[derive(Clone, Debug)]
struct EchoResponse {
payload: String,
}
impl NetworkMessage for EchoResponse {
fn type_tag() -> &'static str {
"example::EchoResponse"
}
}
// ─── JSON codec for the demo messages ────────────────────────────────────────
struct JsonCodec;
impl Codec<EchoRequest> for JsonCodec {
fn encode(&self, msg: &EchoRequest) -> Result<Vec<u8>, Error> {
let mut buf = Vec::new();
buf.extend_from_slice(&msg.reply_to.0);
buf.extend_from_slice(msg.payload.as_bytes());
Ok(buf)
}
fn decode(&self, bytes: &[u8]) -> Result<EchoRequest, Error> {
if bytes.len() < 32 {
return Err(Error::from("EchoRequest too short"));
}
let mut addr = [0u8; 32];
addr.copy_from_slice(&bytes[..32]);
let payload = String::from_utf8_lossy(&bytes[32..]).to_string();
Ok(EchoRequest {
payload,
reply_to: ActorAddress(addr),
})
}
}
impl Codec<EchoResponse> for JsonCodec {
fn encode(&self, msg: &EchoResponse) -> Result<Vec<u8>, Error> {
Ok(msg.payload.as_bytes().to_vec())
}
fn decode(&self, bytes: &[u8]) -> Result<EchoResponse, Error> {
Ok(EchoResponse {
payload: String::from_utf8_lossy(bytes).to_string(),
})
}
}
// Also register the JsMessage type that the browser uses
#[derive(Clone, Debug)]
struct JsMessage {
type_tag: String,
payload: String,
}
impl NetworkMessage for JsMessage {
fn type_tag() -> &'static str {
"swactor::JsMessage"
}
}
struct JsMessageCodec;
impl Codec<JsMessage> for JsMessageCodec {
fn encode(&self, msg: &JsMessage) -> Result<Vec<u8>, Error> {
Ok(msg.payload.as_bytes().to_vec())
}
fn decode(&self, bytes: &[u8]) -> Result<JsMessage, Error> {
Ok(JsMessage {
type_tag: "js".to_string(),
payload: String::from_utf8_lossy(bytes).to_string(),
})
}
}
// ─── Echo actor ──────────────────────────────────────────────────────────────
struct EchoActor;
impl ActorInterface for EchoActor {
type Incoming = JsMessage;
type Response = ();
fn handle(&mut self, ctx: &Ctx, msg: JsMessage) {
println!("[echo] Received: type_tag={}, payload={}", msg.type_tag, msg.payload);
// Echo back to sender — in a real app, the payload would contain
// the reply_to address. For the demo, we just log it.
let _ = msg;
let _ = ctx;
}
}
// ─── Main ────────────────────────────────────────────────────────────────────
const WS_ADDR: &str = "127.0.0.1:9000";
fn main() {
println!("[gateway] Starting WebSocket gateway on ws://{WS_ADDR}");
println!("[gateway] Open crates/wasm/www/index.html in a browser to connect.\n");
// Build codec registry
let mut codecs = CodecRegistry::new();
codecs.register::<EchoRequest, _>(JsonCodec);
codecs.register::<EchoResponse, _>(JsonCodec);
codecs.register::<JsMessage, _>(JsMessageCodec);
let codecs = Arc::new(codecs);
// Build runtime with an echo actor
let transport_router = Arc::new(TransportRouter::new());
let mut rt = Runtime::new(RuntimeConfig {
num_threads: 1,
..RuntimeConfig::default()
});
rt.set_codec_registry(codecs.clone());
rt.set_transport_router(transport_router.clone());
let echo_addr = rt.spawn(EchoActor).unwrap();
rt.tick(); // drain spawn queue
println!("[gateway] EchoActor spawned at {echo_addr}");
// Start WebSocket gateway
let mut gateway = WsGateway::bind(WS_ADDR.parse().unwrap())
.expect("failed to bind WebSocket gateway");
println!("[gateway] Listening on ws://{}", gateway.local_addr());
println!();
// Event loop
loop {
let n = gateway.tick(&rt, &codecs, &transport_router);
if n > 0 {
println!("[gateway] Processed {n} envelope(s)");
}
rt.tick();
std::thread::sleep(Duration::from_millis(16)); // ~60 ticks/sec
}
}

View file

@ -1,11 +1,13 @@
pub mod actor; pub mod actor;
#[cfg(not(target_arch = "wasm32"))]
pub mod worker; pub mod worker;
#[cfg(target_arch = "wasm32")]
pub(crate) mod worker;
pub(crate) mod channel; pub(crate) mod channel;
pub(crate) mod error; pub(crate) mod error;
pub use error::Error; pub use error::Error;
pub mod config; pub mod config;
pub(crate) mod delivery; pub(crate) mod delivery;
pub mod stats; pub mod stats;
@ -15,6 +17,14 @@ pub mod runtime;
#[cfg(feature = "transport")] #[cfg(feature = "transport")]
pub mod transport; pub mod transport;
/// Time abstraction: uses `std::time::Instant` natively, `web_time::Instant` on wasm.
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;
}
#[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()

View file

@ -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; use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use std::time::Instant;
use crate::time::Instant;
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Message}; use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Message};
use crate::channel::{Receiver, Sender}; use crate::channel::{Receiver, Sender};
@ -33,11 +35,13 @@ impl<M: Message> Inbox<M> {
} }
/// 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 {
@ -243,6 +247,7 @@ 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.
#[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);

View file

@ -201,6 +201,61 @@ impl Transport for InMemoryTransport {
} }
} }
// ─── Wire format encoding/decoding ─────────────────────────────────────────
/// Encode a WireEnvelope to bytes in the length-prefixed wire format.
///
/// Wire format per envelope:
/// [4 bytes: total frame len (BE u32)]
/// [32 bytes: dest address]
/// [4 bytes: type_tag len (BE u32)]
/// [N bytes: type_tag UTF-8]
/// [remaining: payload bytes]
pub fn encode_wire_envelope(envelope: &WireEnvelope) -> Vec<u8> {
let tag_bytes = envelope.type_tag.as_bytes();
let frame_len: u32 = (32 + 4 + tag_bytes.len() + envelope.payload.len()) as u32;
let mut buf = Vec::with_capacity(4 + frame_len as usize);
buf.extend_from_slice(&frame_len.to_be_bytes());
buf.extend_from_slice(&envelope.dest.0);
buf.extend_from_slice(&(tag_bytes.len() as u32).to_be_bytes());
buf.extend_from_slice(tag_bytes);
buf.extend_from_slice(&envelope.payload);
buf
}
/// Decode a WireEnvelope from a byte buffer (without the length prefix).
///
/// The caller is responsible for reading the 4-byte length prefix and
/// providing exactly `frame_len` bytes.
pub fn decode_wire_envelope(frame: &[u8]) -> Result<WireEnvelope, Error> {
if frame.len() < 36 {
return Err(Error::from("wire frame too short"));
}
let mut dest = [0u8; 32];
dest.copy_from_slice(&frame[0..32]);
let tag_len = u32::from_be_bytes(
frame[32..36]
.try_into()
.map_err(|_| Error::from("bad tag_len bytes"))?,
) as usize;
if frame.len() < 36 + tag_len {
return Err(Error::from("wire frame truncated in type_tag"));
}
let type_tag = String::from_utf8_lossy(&frame[36..36 + tag_len]).to_string();
let payload = frame[36 + tag_len..].to_vec();
Ok(WireEnvelope {
dest: ActorAddress(dest),
type_tag,
payload,
})
}
// ─── send_via_transport (crate-internal helper) ───────────────────────────── // ─── send_via_transport (crate-internal helper) ─────────────────────────────
/// Attempt to serialize and send a message via the transport router. /// Attempt to serialize and send a message via the transport router.

View file

@ -3,8 +3,10 @@ use std::cell::RefCell;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::thread; use std::thread;
use std::time::Instant;
use crate::time::Instant;
use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx}; use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx};
use crate::channel::Receiver; use crate::channel::Receiver;
@ -161,6 +163,7 @@ impl Worker {
did_work did_work
} }
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool) { pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool) {
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
let _span = tracing::info_span!("worker.run", worker_id = self.id.0).entered(); let _span = tracing::info_span!("worker.run", worker_id = self.id.0).entered();