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:
parent
d817ad952f
commit
548396ba4a
49 changed files with 3311 additions and 872 deletions
208
Cargo.lock
generated
208
Cargo.lock
generated
|
|
@ -77,6 +77,12 @@ version = "3.19.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
|
|
@ -380,6 +386,12 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
|
|
@ -417,7 +429,7 @@ name = "distribution"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swactor",
|
||||
|
|
@ -441,7 +453,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
|||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
|
|
@ -503,6 +515,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
|
|
@ -543,6 +567,22 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
|
|
@ -837,6 +877,15 @@ version = "1.13.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
|
|
@ -926,13 +975,48 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
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]]
|
||||
|
|
@ -1138,6 +1222,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -1195,7 +1290,7 @@ version = "2.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1203,7 +1298,7 @@ name = "simulation"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"distribution",
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -1285,16 +1380,41 @@ dependencies = [
|
|||
"criterion",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
"serde",
|
||||
"swactor-gateway",
|
||||
"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]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
version = "2.0.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -1307,6 +1427,26 @@ version = "0.12.16"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
|
|
@ -1436,6 +1576,23 @@ dependencies = [
|
|||
"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]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
|
|
@ -1483,6 +1640,12 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
|
@ -1512,11 +1675,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm"
|
||||
version = "0.1.0"
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
dependencies = [
|
||||
"swactor",
|
||||
"wasm-bindgen",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1574,6 +1738,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
|
@ -1702,6 +1876,12 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
|
|
@ -1730,6 +1910,6 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
|||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.20"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -1,5 +1,5 @@
|
|||
[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"]
|
||||
|
||||
[package]
|
||||
|
|
@ -30,8 +30,12 @@ tracing = { version = "0.1", optional = true }
|
|||
crossbeam-queue = "0.3.12"
|
||||
crossbeam-utils = "0.8.21"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
web-time = "1.1"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
swactor_gateway = { path = "crates/gateway", package = "swactor-gateway" }
|
||||
|
||||
[[bench]]
|
||||
name = "runtime_benchmarks"
|
||||
|
|
@ -44,3 +48,7 @@ harness = false
|
|||
[[example]]
|
||||
name = "tcp_ping_pong"
|
||||
required-features = ["transport"]
|
||||
|
||||
[[example]]
|
||||
name = "ws_gateway"
|
||||
required-features = ["transport"]
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@
|
|||
//! translates into real network requests.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::types::NodeId;
|
||||
use crate::types::{NodeAddr, NodeId};
|
||||
use super::routing_table::{RoutingTable, K};
|
||||
|
||||
/// Concurrency parameter — how many queries to issue in parallel per round.
|
||||
|
|
@ -25,9 +24,9 @@ const MAX_ROUNDS: usize = 20;
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum LookupAction {
|
||||
/// 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.
|
||||
Done { closest: Vec<(NodeId, SocketAddr)> },
|
||||
Done { closest: Vec<(NodeId, NodeAddr)> },
|
||||
}
|
||||
|
||||
/// State of a single iterative FIND_NODE lookup.
|
||||
|
|
@ -36,7 +35,7 @@ pub struct NodeLookup {
|
|||
k: usize,
|
||||
alpha: usize,
|
||||
/// 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.
|
||||
queried: HashSet<NodeId>,
|
||||
/// Nodes we've sent queries to but haven't received responses yet.
|
||||
|
|
@ -63,7 +62,7 @@ impl NodeLookup {
|
|||
let mut known = HashMap::new();
|
||||
for entry in &seeds {
|
||||
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 {
|
||||
|
|
@ -85,7 +84,7 @@ impl NodeLookup {
|
|||
pub fn handle_response(
|
||||
&mut self,
|
||||
from: NodeId,
|
||||
closer_nodes: Vec<(NodeId, SocketAddr)>,
|
||||
closer_nodes: Vec<(NodeId, NodeAddr)>,
|
||||
) -> Vec<LookupAction> {
|
||||
if self.done {
|
||||
return vec![self.done_action()];
|
||||
|
|
@ -140,7 +139,7 @@ impl NodeLookup {
|
|||
.known
|
||||
.iter()
|
||||
.filter(|(id, _)| !self.queried.contains(id))
|
||||
.map(|(id, (addr, dist))| (*id, *addr, *dist))
|
||||
.map(|(id, (addr, dist))| (*id, addr.clone(), *dist))
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(|a, b| a.2.cmp(&b.2));
|
||||
|
|
@ -174,11 +173,11 @@ impl NodeLookup {
|
|||
actions
|
||||
}
|
||||
|
||||
fn k_closest(&self) -> Vec<(NodeId, SocketAddr)> {
|
||||
fn k_closest(&self) -> Vec<(NodeId, NodeAddr)> {
|
||||
let mut sorted: Vec<_> = self
|
||||
.known
|
||||
.iter()
|
||||
.map(|(id, (addr, dist))| (*id, *addr, *dist))
|
||||
.map(|(id, (addr, dist))| (*id, addr.clone(), *dist))
|
||||
.collect();
|
||||
sorted.sort_by(|a, b| a.2.cmp(&b.2));
|
||||
sorted.truncate(self.k);
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
//! replacement cache and only promote when an existing node is evicted.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::types::NodeId;
|
||||
use crate::types::{NodeAddr, NodeId};
|
||||
|
||||
/// Default replication parameter.
|
||||
pub const K: usize = 20;
|
||||
|
|
@ -20,7 +19,7 @@ const NUM_BUCKETS: usize = 256;
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct NodeEntry {
|
||||
pub node_id: NodeId,
|
||||
pub addr: SocketAddr,
|
||||
pub addr: NodeAddr,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
//! Protocol messages for SWIM membership and Kademlia directory.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swactor::actor::ActorAddress;
|
||||
use swactor::transport::NetworkMessage;
|
||||
|
||||
use crate::types::{DirectoryEntry, MemberState, NodeId, NodeRecord};
|
||||
use crate::types::{DirectoryEntry, MemberState, NodeAddr, NodeId, NodeRecord};
|
||||
|
||||
// ─── SWIM Protocol Messages ────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -41,7 +39,7 @@ impl NetworkMessage for Ack {
|
|||
pub struct PingReq {
|
||||
pub from: NodeId,
|
||||
pub target: NodeId,
|
||||
pub target_addr: SocketAddr,
|
||||
pub target_addr: NodeAddr,
|
||||
pub sequence: u64,
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +53,7 @@ impl NetworkMessage for PingReq {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JoinRequest {
|
||||
pub from: NodeId,
|
||||
pub addr: SocketAddr,
|
||||
pub addr: NodeAddr,
|
||||
}
|
||||
|
||||
impl NetworkMessage for JoinRequest {
|
||||
|
|
@ -82,7 +80,7 @@ impl NetworkMessage for JoinResponse {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MembershipUpdate {
|
||||
pub node_id: NodeId,
|
||||
pub addr: SocketAddr,
|
||||
pub addr: NodeAddr,
|
||||
pub state: MemberState,
|
||||
pub incarnation: u64,
|
||||
}
|
||||
|
|
@ -105,7 +103,7 @@ impl NetworkMessage for FindNodeRequest {
|
|||
/// Kademlia FIND_NODE response — closest known nodes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FindNodeResponse {
|
||||
pub closest: Vec<(NodeId, SocketAddr)>,
|
||||
pub closest: Vec<(NodeId, NodeAddr)>,
|
||||
}
|
||||
|
||||
impl NetworkMessage for FindNodeResponse {
|
||||
|
|
@ -145,7 +143,7 @@ pub enum FindValueResponse {
|
|||
/// Found the actor — here's the directory entry.
|
||||
Found(DirectoryEntry),
|
||||
/// Don't have it — here are closer nodes to ask.
|
||||
Closer(Vec<(NodeId, SocketAddr)>),
|
||||
Closer(Vec<(NodeId, NodeAddr)>),
|
||||
}
|
||||
|
||||
impl NetworkMessage for FindValueResponse {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
//! Composes SWIM membership, Kademlia routing, directory, cache, and
|
||||
//! transport into a single public API.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use swactor::actor::ActorAddress;
|
||||
|
||||
use crate::cache::LocationCache;
|
||||
|
|
@ -14,11 +12,11 @@ use crate::kademlia::repair::{RepairQueue, RepublishTracker};
|
|||
use crate::kademlia::routing_table::RoutingTable;
|
||||
use crate::swim::node::{NodeAction, SwimNode};
|
||||
use crate::swim::probe::SwimConfig;
|
||||
use crate::types::{MemberState, NodeId, NodeRecord};
|
||||
use crate::types::{MemberState, NodeAddr, NodeId, NodeRecord};
|
||||
|
||||
/// Configuration for a distributed node.
|
||||
pub struct DistributedNodeConfig {
|
||||
pub listen_addr: SocketAddr,
|
||||
pub listen_addr: NodeAddr,
|
||||
pub swim: SwimConfig,
|
||||
pub cache_capacity: usize,
|
||||
pub republish_interval: u64,
|
||||
|
|
@ -27,7 +25,7 @@ pub struct DistributedNodeConfig {
|
|||
impl Default for DistributedNodeConfig {
|
||||
fn default() -> 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(),
|
||||
cache_capacity: 10_000,
|
||||
republish_interval: 1000,
|
||||
|
|
@ -78,7 +76,7 @@ impl DistributedNode {
|
|||
self.keypair.node_id()
|
||||
}
|
||||
|
||||
pub fn listen_addr(&self) -> SocketAddr {
|
||||
pub fn listen_addr(&self) -> NodeAddr {
|
||||
self.swim.self_addr()
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +87,7 @@ impl DistributedNode {
|
|||
// ─── Cluster operations ─────────────────────────────────────────────
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
|
|
@ -154,8 +152,8 @@ impl DistributedNode {
|
|||
|
||||
// ─── SWIM message handling (delegate to SwimNode) ───────────────────
|
||||
|
||||
pub fn handle_ping(&mut self, from: NodeId, from_addr: SocketAddr, sequence: u64, piggyback: &[u8]) -> Vec<NodeAction> {
|
||||
let actions = self.swim.handle_ping(from, from_addr, sequence, piggyback);
|
||||
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.clone(), sequence, piggyback);
|
||||
self.maybe_update_routing_table(from, from_addr);
|
||||
actions
|
||||
}
|
||||
|
|
@ -164,12 +162,12 @@ impl DistributedNode {
|
|||
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)
|
||||
}
|
||||
|
||||
pub fn handle_join_request(&mut self, from: NodeId, from_addr: SocketAddr) -> Vec<NodeAction> {
|
||||
let actions = self.swim.handle_join_request(from, from_addr);
|
||||
pub fn handle_join_request(&mut self, from: NodeId, from_addr: NodeAddr) -> Vec<NodeAction> {
|
||||
let actions = self.swim.handle_join_request(from, from_addr.clone());
|
||||
self.maybe_update_routing_table(from, from_addr);
|
||||
actions
|
||||
}
|
||||
|
|
@ -177,7 +175,7 @@ impl DistributedNode {
|
|||
pub fn handle_join_response(&mut self, members: Vec<NodeRecord>) -> Vec<NodeAction> {
|
||||
for m in &members {
|
||||
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)
|
||||
|
|
@ -260,9 +258,14 @@ impl DistributedNode {
|
|||
self.swim.recent_probe_targets().iter().copied().collect()
|
||||
}
|
||||
|
||||
/// Access the underlying SWIM node.
|
||||
pub fn swim_node(&self) -> &SwimNode {
|
||||
&self.swim
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
|
||||
|
|
@ -270,7 +273,7 @@ impl DistributedNode {
|
|||
match state {
|
||||
MemberState::Alive => {
|
||||
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 => {
|
||||
|
|
@ -291,7 +294,7 @@ pub enum ResolveResult {
|
|||
/// Found in cache or local directory.
|
||||
Cached(NodeId),
|
||||
/// 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.
|
||||
NotFound,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@
|
|||
//! Used by the runtime-dashboard to display distribution monitoring data
|
||||
//! for a single node without reaching out to other nodes.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -33,6 +31,55 @@ pub struct CacheEntryInfo {
|
|||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DistributionNodeSnapshot {
|
||||
|
|
@ -74,13 +121,18 @@ pub struct DistributionNodeSnapshot {
|
|||
// ─── Gossip pairs ────────────────────────────────────────────────
|
||||
/// Recent SWIM probe targets (most recent last).
|
||||
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 {
|
||||
id.0.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
|
||||
fn addr_str(addr: &SocketAddr) -> String {
|
||||
fn addr_str(addr: &NodeAddr) -> String {
|
||||
addr.to_string()
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +188,49 @@ impl DistributedNode {
|
|||
.map(|id| node_id_hex(id))
|
||||
.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 {
|
||||
node_id: node_id_hex(&self.node_id()),
|
||||
listen_addr: addr_str(&self.listen_addr()),
|
||||
|
|
@ -151,6 +246,7 @@ impl DistributedNode {
|
|||
directory_entry_count: self.directory().entry_count(),
|
||||
repair_queue_size: self.repair_queue_len(),
|
||||
recent_probe_targets: recent_targets,
|
||||
gossip: Some(gossip),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@
|
|||
//!
|
||||
//! Priority ordering: Dead > Suspect > Alive (most urgent first).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::messages::MembershipUpdate;
|
||||
use crate::types::{MemberState, NodeId};
|
||||
use crate::types::{MemberState, NodeAddr, NodeId};
|
||||
|
||||
/// A queued membership update with a remaining transmit budget.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -110,6 +108,11 @@ impl DisseminationQueue {
|
|||
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)))`.
|
||||
fn transmit_budget(&self, cluster_size: usize) -> usize {
|
||||
let n = cluster_size.max(2) as f64;
|
||||
|
|
@ -121,7 +124,7 @@ impl DisseminationQueue {
|
|||
/// Convenience: create a `MembershipUpdate` from components.
|
||||
pub fn membership_update(
|
||||
node_id: NodeId,
|
||||
addr: SocketAddr,
|
||||
addr: NodeAddr,
|
||||
state: MemberState,
|
||||
incarnation: u64,
|
||||
) -> MembershipUpdate {
|
||||
|
|
|
|||
|
|
@ -6,15 +6,14 @@
|
|||
//! 2. Same incarnation: higher-priority state wins (Dead > Suspect > Alive).
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemberEntry {
|
||||
pub node_id: NodeId,
|
||||
pub addr: SocketAddr,
|
||||
pub addr: NodeAddr,
|
||||
pub state: MemberState,
|
||||
pub incarnation: u64,
|
||||
}
|
||||
|
|
@ -23,7 +22,7 @@ impl MemberEntry {
|
|||
pub fn to_record(&self) -> NodeRecord {
|
||||
NodeRecord {
|
||||
node_id: self.node_id,
|
||||
addr: self.addr,
|
||||
addr: self.addr.clone(),
|
||||
state: self.state,
|
||||
incarnation: self.incarnation,
|
||||
}
|
||||
|
|
@ -100,7 +99,7 @@ impl MemberList {
|
|||
/// - Higher incarnation always wins.
|
||||
/// - Same incarnation: higher-priority state wins.
|
||||
/// - 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
|
||||
if node_id == self.self_id {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@
|
|||
//! 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.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
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::member_list::MemberList;
|
||||
|
|
@ -18,22 +16,22 @@ use super::probe::{SwimAction, SwimConfig, SwimEvent, SwimProbe};
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum NodeAction {
|
||||
/// 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.
|
||||
SendPingReq {
|
||||
relay: NodeId,
|
||||
relay_addr: SocketAddr,
|
||||
relay_addr: NodeAddr,
|
||||
target: NodeId,
|
||||
target_addr: SocketAddr,
|
||||
target_addr: NodeAddr,
|
||||
sequence: u64,
|
||||
piggyback: Vec<u8>,
|
||||
},
|
||||
/// 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.
|
||||
SendJoinRequest { to_addr: SocketAddr },
|
||||
SendJoinRequest { to_addr: NodeAddr },
|
||||
/// 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).
|
||||
MembershipChanged { node_id: NodeId, state: MemberState, incarnation: u64 },
|
||||
}
|
||||
|
|
@ -44,13 +42,13 @@ pub struct SwimNode {
|
|||
members: MemberList,
|
||||
probe: SwimProbe,
|
||||
dissemination: DisseminationQueue,
|
||||
self_addr: SocketAddr,
|
||||
self_addr: NodeAddr,
|
||||
/// Maximum piggybacked updates per message.
|
||||
max_piggyback: usize,
|
||||
}
|
||||
|
||||
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 {
|
||||
members: MemberList::new(self_id),
|
||||
probe: SwimProbe::new(config),
|
||||
|
|
@ -64,8 +62,8 @@ impl SwimNode {
|
|||
self.members.self_id()
|
||||
}
|
||||
|
||||
pub fn self_addr(&self) -> SocketAddr {
|
||||
self.self_addr
|
||||
pub fn self_addr(&self) -> NodeAddr {
|
||||
self.self_addr.clone()
|
||||
}
|
||||
|
||||
pub fn members(&self) -> &MemberList {
|
||||
|
|
@ -77,6 +75,16 @@ impl SwimNode {
|
|||
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.
|
||||
pub fn tick(&mut self) -> Vec<NodeAction> {
|
||||
let probe_actions = self.probe.step(SwimEvent::Tick, &mut self.members);
|
||||
|
|
@ -84,11 +92,11 @@ impl SwimNode {
|
|||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
// 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
|
||||
let pb = self.dissemination.pack_piggyback(self.max_piggyback);
|
||||
|
|
@ -115,7 +123,7 @@ impl SwimNode {
|
|||
&mut self,
|
||||
_from: NodeId,
|
||||
target: NodeId,
|
||||
target_addr: SocketAddr,
|
||||
target_addr: NodeAddr,
|
||||
sequence: u64,
|
||||
piggyback: &[u8],
|
||||
) -> Vec<NodeAction> {
|
||||
|
|
@ -132,15 +140,15 @@ impl SwimNode {
|
|||
}
|
||||
|
||||
/// 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
|
||||
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();
|
||||
|
||||
if changed {
|
||||
// Enqueue the join for dissemination
|
||||
self.dissemination.enqueue(
|
||||
membership_update(from, from_addr, MemberState::Alive, 0),
|
||||
membership_update(from, from_addr.clone(), MemberState::Alive, 0),
|
||||
self.cluster_size(),
|
||||
);
|
||||
actions.push(NodeAction::MembershipChanged {
|
||||
|
|
@ -154,7 +162,7 @@ impl SwimNode {
|
|||
let mut members = self.members.snapshot();
|
||||
members.push(NodeRecord {
|
||||
node_id: self.members.self_id(),
|
||||
addr: self.self_addr,
|
||||
addr: self.self_addr.clone(),
|
||||
state: MemberState::Alive,
|
||||
incarnation: self.members.self_incarnation(),
|
||||
});
|
||||
|
|
@ -189,10 +197,10 @@ impl SwimNode {
|
|||
}
|
||||
|
||||
/// 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
|
||||
.iter()
|
||||
.map(|addr| NodeAction::SendJoinRequest { to_addr: *addr })
|
||||
.map(|addr| NodeAction::SendJoinRequest { to_addr: addr.clone() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +209,7 @@ impl SwimNode {
|
|||
self.dissemination.enqueue(
|
||||
membership_update(
|
||||
self.members.self_id(),
|
||||
self.self_addr,
|
||||
self.self_addr.clone(),
|
||||
MemberState::Dead,
|
||||
self.members.self_incarnation(),
|
||||
),
|
||||
|
|
@ -230,7 +238,7 @@ impl SwimNode {
|
|||
self.dissemination.enqueue(
|
||||
membership_update(
|
||||
self.members.self_id(),
|
||||
self.self_addr,
|
||||
self.self_addr.clone(),
|
||||
MemberState::Alive,
|
||||
new_inc,
|
||||
),
|
||||
|
|
@ -242,7 +250,7 @@ impl SwimNode {
|
|||
|
||||
let changed = self.members.apply(
|
||||
update.node_id,
|
||||
update.addr,
|
||||
update.addr.clone(),
|
||||
update.state,
|
||||
update.incarnation,
|
||||
);
|
||||
|
|
@ -283,7 +291,7 @@ impl SwimNode {
|
|||
if self.members.suspect(node_id) {
|
||||
if let Some(entry) = self.members.get(&node_id) {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
|
@ -297,7 +305,7 @@ impl SwimNode {
|
|||
SwimAction::DeclareDead(node_id) => {
|
||||
if let Some(entry) = self.members.get(&node_id) {
|
||||
let inc = entry.incarnation;
|
||||
let addr = entry.addr;
|
||||
let addr = entry.addr.clone();
|
||||
if self.members.declare_dead(node_id) {
|
||||
self.dissemination.enqueue(
|
||||
membership_update(node_id, addr, MemberState::Dead, inc),
|
||||
|
|
@ -315,7 +323,7 @@ impl SwimNode {
|
|||
self.dissemination.enqueue(
|
||||
membership_update(
|
||||
self.members.self_id(),
|
||||
self.self_addr,
|
||||
self.self_addr.clone(),
|
||||
MemberState::Alive,
|
||||
new_incarnation,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
//! No I/O, no timers — the caller drives the clock.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::types::NodeId;
|
||||
use crate::types::{NodeAddr, NodeId};
|
||||
|
||||
use super::member_list::MemberList;
|
||||
|
||||
|
|
@ -58,13 +57,13 @@ pub enum SwimEvent {
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum SwimAction {
|
||||
/// 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.
|
||||
SendPingReq {
|
||||
relay: NodeId,
|
||||
relay_addr: SocketAddr,
|
||||
relay_addr: NodeAddr,
|
||||
target: NodeId,
|
||||
target_addr: SocketAddr,
|
||||
target_addr: NodeAddr,
|
||||
sequence: u64,
|
||||
},
|
||||
/// A node is now suspected.
|
||||
|
|
@ -84,7 +83,7 @@ enum ProbePhase {
|
|||
/// Direct ping sent, waiting for ack.
|
||||
WaitingDirectAck {
|
||||
target: NodeId,
|
||||
target_addr: SocketAddr,
|
||||
target_addr: NodeAddr,
|
||||
sequence: u64,
|
||||
sent_at: u64,
|
||||
},
|
||||
|
|
@ -98,9 +97,9 @@ enum ProbePhase {
|
|||
|
||||
/// Suspicion timer for a single node.
|
||||
#[derive(Debug)]
|
||||
struct SuspicionTimer {
|
||||
node_id: NodeId,
|
||||
started_at: u64,
|
||||
pub struct SuspicionTimer {
|
||||
pub node_id: NodeId,
|
||||
pub started_at: u64,
|
||||
}
|
||||
|
||||
/// The SWIM probe state machine.
|
||||
|
|
@ -162,13 +161,51 @@ impl SwimProbe {
|
|||
&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 {
|
||||
self.sequence += 1;
|
||||
self.sequence
|
||||
}
|
||||
|
||||
/// 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();
|
||||
if alive.is_empty() {
|
||||
return None;
|
||||
|
|
@ -189,11 +226,11 @@ impl SwimProbe {
|
|||
let target_id = self.probe_order[self.probe_index];
|
||||
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`).
|
||||
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
|
||||
.alive_members()
|
||||
.into_iter()
|
||||
|
|
@ -206,7 +243,7 @@ impl SwimProbe {
|
|||
let mut relays = Vec::with_capacity(k);
|
||||
for i in 0..k {
|
||||
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
|
||||
}
|
||||
|
|
@ -231,7 +268,7 @@ impl SwimProbe {
|
|||
let seq = self.next_sequence();
|
||||
actions.push(SwimAction::SendPing {
|
||||
to: target,
|
||||
to_addr: target_addr,
|
||||
to_addr: target_addr.clone(),
|
||||
sequence: seq,
|
||||
});
|
||||
self.phase = ProbePhase::WaitingDirectAck {
|
||||
|
|
@ -248,7 +285,7 @@ impl SwimProbe {
|
|||
ProbePhase::WaitingDirectAck { target, target_addr, sequence, sent_at } => {
|
||||
if self.tick - sent_at >= self.config.probe_timeout {
|
||||
let target = *target;
|
||||
let target_addr = *target_addr;
|
||||
let target_addr = target_addr.clone();
|
||||
let sequence = *sequence;
|
||||
|
||||
// Send indirect probes through relays
|
||||
|
|
@ -258,7 +295,7 @@ impl SwimProbe {
|
|||
relay,
|
||||
relay_addr,
|
||||
target,
|
||||
target_addr,
|
||||
target_addr: target_addr.clone(),
|
||||
sequence,
|
||||
});
|
||||
}
|
||||
|
|
@ -350,7 +387,7 @@ impl MemberList {
|
|||
fn clone_shallow(original: &MemberList) -> MemberList {
|
||||
let mut copy = MemberList::new(original.self_id());
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ use swactor::actor::ActorAddress;
|
|||
use swactor::transport::{Transport, WireEnvelope};
|
||||
use swactor::Error;
|
||||
|
||||
use crate::types::NodeAddr;
|
||||
|
||||
// ─── TcpTransport ──────────────────────────────────────────────────────────
|
||||
|
||||
/// TCP transport with connection pooling.
|
||||
|
|
@ -29,15 +31,24 @@ pub struct TcpTransport {
|
|||
default_dest: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
impl TcpTransport {
|
||||
/// Create a transport that sends to a specific destination.
|
||||
pub fn new(dest: SocketAddr) -> Self {
|
||||
Self {
|
||||
pool: Mutex::new(HashMap::new()),
|
||||
default_dest: Some(dest),
|
||||
/// Extract the `SocketAddr` from a `NodeAddr::Tcp`. Returns an error for
|
||||
/// non-TCP variants.
|
||||
fn require_tcp(addr: &NodeAddr) -> Result<SocketAddr, Error> {
|
||||
match addr {
|
||||
NodeAddr::Tcp(sa) => Ok(*sa),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
/// The destination must be determined by the caller (e.g. via TransportRouter).
|
||||
pub fn pool() -> Self {
|
||||
|
|
@ -67,8 +78,9 @@ impl TcpTransport {
|
|||
}
|
||||
|
||||
/// Send an envelope to a specific address.
|
||||
pub fn send_to(&self, addr: SocketAddr, envelope: WireEnvelope) -> Result<(), Error> {
|
||||
let mut stream = self.get_or_connect(addr)?;
|
||||
pub fn send_to(&self, addr: &NodeAddr, envelope: WireEnvelope) -> Result<(), Error> {
|
||||
let sa = require_tcp(addr)?;
|
||||
let mut stream = self.get_or_connect(sa)?;
|
||||
let buf = encode_wire_envelope(&envelope);
|
||||
stream
|
||||
.write_all(&buf)
|
||||
|
|
@ -81,7 +93,11 @@ impl Transport for TcpTransport {
|
|||
let dest = self
|
||||
.default_dest
|
||||
.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 ─────────────────────────────────────────
|
||||
|
||||
/// Encode a WireEnvelope to bytes in the length-prefixed wire format.
|
||||
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
|
||||
}
|
||||
// Re-export the canonical encode function from the core crate.
|
||||
pub use swactor::transport::encode_wire_envelope;
|
||||
pub use swactor::transport::decode_wire_envelope;
|
||||
|
||||
enum ReadError {
|
||||
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> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
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];
|
||||
stream.read_exact(&mut frame)?;
|
||||
|
||||
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();
|
||||
|
||||
Ok(WireEnvelope {
|
||||
dest: ActorAddress(dest),
|
||||
type_tag,
|
||||
payload,
|
||||
})
|
||||
decode_wire_envelope(&frame).map_err(|_| ReadError::Other(
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "bad wire frame"),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// Temporarily set blocking mode
|
||||
stream
|
||||
.set_nonblocking(false)
|
||||
.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
|
||||
.read_exact(&mut frame)
|
||||
.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);
|
||||
|
||||
Ok(WireEnvelope {
|
||||
dest: ActorAddress(dest),
|
||||
type_tag,
|
||||
payload,
|
||||
})
|
||||
decode_wire_envelope(&frame)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,38 @@ use std::net::SocketAddr;
|
|||
use serde::{Deserialize, Serialize};
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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)]
|
||||
pub struct NodeRecord {
|
||||
pub node_id: NodeId,
|
||||
pub addr: SocketAddr,
|
||||
pub addr: NodeAddr,
|
||||
pub state: MemberState,
|
||||
/// Incarnation number — bumped by the node itself to refute suspicion.
|
||||
pub incarnation: u64,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use distribution::kademlia::lookup::{LookupAction, NodeLookup};
|
||||
use distribution::kademlia::routing_table::RoutingTable;
|
||||
use distribution::types::NodeId;
|
||||
use distribution::types::{NodeAddr, NodeId};
|
||||
|
||||
fn node(byte: u8) -> NodeId {
|
||||
NodeId([byte; 32])
|
||||
}
|
||||
|
||||
fn addr(port: u16) -> SocketAddr {
|
||||
format!("127.0.0.1:{port}").parse().unwrap()
|
||||
fn addr(port: u16) -> NodeAddr {
|
||||
NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
|
||||
}
|
||||
|
||||
// ─── Basic lookup ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use distribution::kademlia::routing_table::RoutingTable;
|
||||
use distribution::types::NodeId;
|
||||
use distribution::types::{NodeAddr, NodeId};
|
||||
|
||||
fn node(byte: u8) -> NodeId {
|
||||
NodeId([byte; 32])
|
||||
}
|
||||
|
||||
fn addr(port: u16) -> std::net::SocketAddr {
|
||||
format!("127.0.0.1:{port}").parse().unwrap()
|
||||
fn addr(port: u16) -> NodeAddr {
|
||||
NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
|
||||
}
|
||||
|
||||
// ─── Basic operations ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -3,18 +3,16 @@
|
|||
//! These tests verify the full composed behavior from a consumer's perspective:
|
||||
//! cluster formation, actor registration/resolution, and fault tolerance.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use swactor::actor::ActorAddress;
|
||||
use distribution::crypto::Keypair;
|
||||
use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
|
||||
use distribution::swim::node::NodeAction;
|
||||
use distribution::swim::probe::SwimConfig;
|
||||
use distribution::types::NodeId;
|
||||
use distribution::types::{NodeAddr, NodeId};
|
||||
|
||||
fn test_config(addr: &str) -> DistributedNodeConfig {
|
||||
DistributedNodeConfig {
|
||||
listen_addr: addr.parse().unwrap(),
|
||||
listen_addr: NodeAddr::Tcp(addr.parse().unwrap()),
|
||||
swim: SwimConfig {
|
||||
probe_interval: 1,
|
||||
probe_timeout: 3,
|
||||
|
|
@ -31,15 +29,15 @@ fn test_config(addr: &str) -> DistributedNodeConfig {
|
|||
fn deliver_actions(
|
||||
actions: &[NodeAction],
|
||||
sender_id: NodeId,
|
||||
sender_addr: SocketAddr,
|
||||
nodes: &mut [(NodeId, SocketAddr, &mut DistributedNode)],
|
||||
sender_addr: NodeAddr,
|
||||
nodes: &mut [(NodeId, NodeAddr, &mut DistributedNode)],
|
||||
) -> Vec<NodeAction> {
|
||||
let mut responses = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
NodeAction::SendPing { to, sequence, piggyback, .. } => {
|
||||
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, .. } => {
|
||||
|
|
@ -49,7 +47,7 @@ fn deliver_actions(
|
|||
}
|
||||
NodeAction::SendJoinRequest { 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, .. } => {
|
||||
|
|
@ -59,7 +57,7 @@ fn deliver_actions(
|
|||
}
|
||||
NodeAction::SendPingReq { relay, target, target_addr, sequence, piggyback, .. } => {
|
||||
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 { .. } => {
|
||||
|
|
@ -84,16 +82,16 @@ fn two_node_cluster_forms_via_join() {
|
|||
let joiner_addr = joiner.listen_addr();
|
||||
|
||||
// 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
|
||||
let mut all_nodes: Vec<(NodeId, SocketAddr, &mut DistributedNode)> = vec![
|
||||
(seed_id, seed_addr, &mut seed),
|
||||
let mut all_nodes: Vec<(NodeId, NodeAddr, &mut DistributedNode)> = vec![
|
||||
(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
|
||||
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),
|
||||
];
|
||||
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();
|
||||
|
||||
// When: join completes
|
||||
let actions = joiner.join(&[seed_addr]);
|
||||
let mut nodes = vec![(seed_id, seed_addr, &mut seed)];
|
||||
let responses = deliver_actions(&actions, joiner_id, joiner_addr, &mut nodes);
|
||||
let actions = joiner.join(&[seed_addr.clone()]);
|
||||
let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
|
||||
let responses = deliver_actions(&actions, joiner_id, joiner_addr.clone(), &mut nodes);
|
||||
let mut nodes = vec![(joiner_id, joiner_addr, &mut joiner)];
|
||||
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_addr = joiner.listen_addr();
|
||||
|
||||
let actions = joiner.join(&[seed_addr]);
|
||||
let mut nodes = vec![(seed_id, seed_addr, &mut seed)];
|
||||
let responses = deliver_actions(&actions, joiner_id, joiner_addr, &mut nodes);
|
||||
let actions = joiner.join(&[seed_addr.clone()]);
|
||||
let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
|
||||
let responses = deliver_actions(&actions, joiner_id, joiner_addr.clone(), &mut nodes);
|
||||
let mut nodes = vec![(joiner_id, joiner_addr, &mut joiner)];
|
||||
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();
|
||||
|
||||
// Form cluster
|
||||
let actions = node.join(&[seed_addr]);
|
||||
let mut nodes = vec![(seed_id, seed_addr, &mut seed)];
|
||||
let responses = deliver_actions(&actions, node_id, node_addr, &mut nodes);
|
||||
let actions = node.join(&[seed_addr.clone()]);
|
||||
let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
|
||||
let responses = deliver_actions(&actions, node_id, node_addr.clone(), &mut nodes);
|
||||
let mut nodes = vec![(node_id, node_addr, &mut node)];
|
||||
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 mut node = DistributedNode::new(test_config("127.0.0.1:9071"));
|
||||
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
|
||||
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_addr = node.listen_addr();
|
||||
|
||||
let actions = node.join(&[seed_addr]);
|
||||
let mut nodes = vec![(seed_id, seed_addr, &mut seed)];
|
||||
let responses = deliver_actions(&actions, node_id, node_addr, &mut nodes);
|
||||
let actions = node.join(&[seed_addr.clone()]);
|
||||
let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
|
||||
let responses = deliver_actions(&actions, node_id, node_addr.clone(), &mut nodes);
|
||||
let mut nodes = vec![(node_id, node_addr, &mut node)];
|
||||
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_addr = node.listen_addr();
|
||||
|
||||
let actions = node.join(&[seed_addr]);
|
||||
let mut nodes = vec![(seed_id, seed_addr, &mut seed)];
|
||||
let responses = deliver_actions(&actions, node_id, node_addr, &mut nodes);
|
||||
let actions = node.join(&[seed_addr.clone()]);
|
||||
let mut nodes = vec![(seed_id, seed_addr.clone(), &mut seed)];
|
||||
let responses = deliver_actions(&actions, node_id, node_addr.clone(), &mut nodes);
|
||||
let mut nodes = vec![(node_id, node_addr, &mut node)];
|
||||
let _ = deliver_actions(&responses, seed_id, seed_addr, &mut nodes);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use distribution::swim::dissemination::{membership_update, DisseminationQueue};
|
||||
use distribution::types::{MemberState, NodeId};
|
||||
use distribution::types::{MemberState, NodeAddr, NodeId};
|
||||
|
||||
fn node(byte: u8) -> NodeId {
|
||||
NodeId([byte; 32])
|
||||
}
|
||||
|
||||
fn addr(port: u16) -> std::net::SocketAddr {
|
||||
format!("127.0.0.1:{port}").parse().unwrap()
|
||||
fn addr(port: u16) -> NodeAddr {
|
||||
NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
|
||||
}
|
||||
|
||||
// ─── Basic queue operations ─────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
use distribution::swim::node::{NodeAction, SwimNode};
|
||||
use distribution::swim::probe::SwimConfig;
|
||||
use distribution::types::{MemberState, NodeId, NodeRecord};
|
||||
use distribution::types::{MemberState, NodeAddr, NodeId, NodeRecord};
|
||||
|
||||
fn node(byte: u8) -> NodeId {
|
||||
NodeId([byte; 32])
|
||||
}
|
||||
|
||||
fn addr(port: u16) -> std::net::SocketAddr {
|
||||
format!("127.0.0.1:{port}").parse().unwrap()
|
||||
fn addr(port: u16) -> NodeAddr {
|
||||
NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
|
||||
}
|
||||
|
||||
fn fast_config() -> SwimConfig {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use distribution::swim::member_list::MemberList;
|
||||
use distribution::swim::probe::{SwimAction, SwimConfig, SwimEvent, SwimProbe};
|
||||
use distribution::types::{MemberState, NodeId};
|
||||
use distribution::types::{MemberState, NodeAddr, NodeId};
|
||||
|
||||
fn node(byte: u8) -> NodeId {
|
||||
NodeId([byte; 32])
|
||||
}
|
||||
|
||||
fn addr(port: u16) -> SocketAddr {
|
||||
format!("127.0.0.1:{port}").parse().unwrap()
|
||||
fn addr(port: u16) -> NodeAddr {
|
||||
NodeAddr::Tcp(format!("127.0.0.1:{port}").parse().unwrap())
|
||||
}
|
||||
|
||||
fn tick_n(probe: &mut SwimProbe, members: &mut MemberList, n: u64) -> Vec<SwimAction> {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use swactor::transport::WireEnvelope;
|
|||
use distribution::codec::distribution_codec_registry;
|
||||
use distribution::messages::*;
|
||||
use distribution::transport::{TcpAcceptor, TcpTransport};
|
||||
use distribution::types::NodeId;
|
||||
use distribution::types::{NodeAddr, NodeId};
|
||||
|
||||
// ─── Wire format round-trip ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -20,9 +20,10 @@ fn wire_envelope_roundtrips_through_tcp() {
|
|||
};
|
||||
|
||||
let original_clone = original.clone();
|
||||
let node_addr = NodeAddr::Tcp(addr);
|
||||
let sender = std::thread::spawn(move || {
|
||||
let transport = TcpTransport::new(addr);
|
||||
transport.send_to(addr, original_clone).unwrap();
|
||||
let transport = TcpTransport::new(node_addr.clone()).unwrap();
|
||||
transport.send_to(&node_addr, original_clone).unwrap();
|
||||
});
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
|
@ -56,9 +57,10 @@ fn wire_envelope_minimal_roundtrips() {
|
|||
};
|
||||
|
||||
let original_clone = original.clone();
|
||||
let node_addr = NodeAddr::Tcp(addr);
|
||||
let sender = std::thread::spawn(move || {
|
||||
let transport = TcpTransport::new(addr);
|
||||
transport.send_to(addr, original_clone).unwrap();
|
||||
let transport = TcpTransport::new(node_addr.clone()).unwrap();
|
||||
transport.send_to(&node_addr, original_clone).unwrap();
|
||||
});
|
||||
|
||||
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 resp = FindValueResponse::Closer(vec![
|
||||
(NodeId([0x11; 32]), "127.0.0.1:8080".parse().unwrap()),
|
||||
(NodeId([0x22; 32]), "127.0.0.1:8081".parse().unwrap()),
|
||||
(NodeId([0x11; 32]), NodeAddr::Tcp("127.0.0.1:8080".parse().unwrap())),
|
||||
(NodeId([0x22; 32]), NodeAddr::Tcp("127.0.0.1:8081".parse().unwrap())),
|
||||
]);
|
||||
|
||||
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 server_node_addr = NodeAddr::Tcp(server_addr);
|
||||
let sender = std::thread::spawn(move || {
|
||||
let transport = TcpTransport::new(server_addr);
|
||||
transport.send_to(server_addr, envelope_clone).unwrap();
|
||||
let transport = TcpTransport::new(server_node_addr.clone()).unwrap();
|
||||
transport.send_to(&server_node_addr, envelope_clone).unwrap();
|
||||
});
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use swactor::actor::ActorAddress;
|
||||
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 ────────────────────────────────────────
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ fn node_record_serde_roundtrip() {
|
|||
let kp = Keypair::generate();
|
||||
let record = NodeRecord {
|
||||
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,
|
||||
incarnation: 5,
|
||||
};
|
||||
|
|
|
|||
11
crates/gateway/Cargo.toml
Normal file
11
crates/gateway/Cargo.toml
Normal 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
231
crates/gateway/src/lib.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
crates/gateway/src/ws_acceptor.rs
Normal file
152
crates/gateway/src/ws_acceptor.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
|
|||
use distribution::snapshot::DistributionNodeSnapshot;
|
||||
use distribution::swim::node::NodeAction;
|
||||
use distribution::swim::probe::SwimConfig;
|
||||
use distribution::types::NodeId;
|
||||
use distribution::types::{NodeAddr, NodeId};
|
||||
|
||||
use runtime_dashboard::collector::StatsCollector;
|
||||
use runtime_dashboard::distribution_collector::DistributionStatsProvider;
|
||||
|
|
@ -86,7 +86,7 @@ impl DistributionStatsProvider for SnapshotProvider {
|
|||
fn tick_all_and_deliver(
|
||||
nodes: &mut [Option<DistributedNode>],
|
||||
node_ids: &[NodeId],
|
||||
addrs: &[SocketAddr],
|
||||
addrs: &[NodeAddr],
|
||||
) {
|
||||
let n = nodes.len();
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ fn tick_all_and_deliver(
|
|||
let tagged_responses = deliver_actions_tagged(
|
||||
&actions,
|
||||
node_ids[sender_idx],
|
||||
addrs[sender_idx],
|
||||
addrs[sender_idx].clone(),
|
||||
nodes,
|
||||
node_ids,
|
||||
addrs,
|
||||
|
|
@ -115,7 +115,7 @@ fn tick_all_and_deliver(
|
|||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
addrs[responder_idx],
|
||||
addrs[responder_idx].clone(),
|
||||
nodes,
|
||||
node_ids,
|
||||
addrs,
|
||||
|
|
@ -130,10 +130,10 @@ fn tick_all_and_deliver(
|
|||
fn deliver_actions_tagged(
|
||||
actions: &[NodeAction],
|
||||
sender_id: NodeId,
|
||||
sender_addr: SocketAddr,
|
||||
sender_addr: NodeAddr,
|
||||
nodes: &mut [Option<DistributedNode>],
|
||||
node_ids: &[NodeId],
|
||||
node_addrs: &[SocketAddr],
|
||||
node_addrs: &[NodeAddr],
|
||||
) -> Vec<(usize, Vec<NodeAction>)> {
|
||||
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(ref mut node) = nodes[idx] {
|
||||
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() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
|
|
@ -173,7 +173,7 @@ fn deliver_actions_tagged(
|
|||
NodeAction::SendJoinRequest { to_addr } => {
|
||||
if let Some(idx) = node_addrs.iter().position(|a| a == to_addr) {
|
||||
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() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
|
|
@ -203,7 +203,7 @@ fn deliver_actions_tagged(
|
|||
let resp = node.handle_ping_req(
|
||||
sender_id,
|
||||
*target,
|
||||
*target_addr,
|
||||
target_addr.clone(),
|
||||
*sequence,
|
||||
piggyback,
|
||||
);
|
||||
|
|
@ -282,12 +282,13 @@ fn main() {
|
|||
let num_nodes = 9; // 1 main + 8 peers
|
||||
let mut nodes: Vec<Option<DistributedNode>> = 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 {
|
||||
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 {
|
||||
listen_addr: addr,
|
||||
listen_addr: addr.clone(),
|
||||
swim: swim_config.clone(),
|
||||
cache_capacity: if i == 0 { 1000 } else { 100 },
|
||||
republish_interval: 500,
|
||||
|
|
@ -299,13 +300,13 @@ fn main() {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
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(
|
||||
&join_actions,
|
||||
node_ids[i],
|
||||
addrs[i],
|
||||
addrs[i].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -314,7 +315,7 @@ fn main() {
|
|||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
addrs[responder_idx],
|
||||
addrs[responder_idx].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -444,20 +445,20 @@ fn main() {
|
|||
// Revive peer 8 (new node + rejoin)
|
||||
if churn_pos == 150 {
|
||||
let config = DistributedNodeConfig {
|
||||
listen_addr: addrs[8],
|
||||
listen_addr: addrs[8].clone(),
|
||||
swim: swim_config.clone(),
|
||||
cache_capacity: 100,
|
||||
republish_interval: 500,
|
||||
};
|
||||
let revived = DistributedNode::new(config);
|
||||
let join_actions = revived.join(&[seed_addr]);
|
||||
let join_actions = revived.join(&[seed_addr.clone()]);
|
||||
nodes[8] = Some(revived);
|
||||
node_ids[8] = nodes[8].as_ref().unwrap().node_id();
|
||||
|
||||
let tagged_responses = deliver_actions_tagged(
|
||||
&join_actions,
|
||||
node_ids[8],
|
||||
addrs[8],
|
||||
addrs[8].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -466,7 +467,7 @@ fn main() {
|
|||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
addrs[responder_idx],
|
||||
addrs[responder_idx].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -485,7 +486,7 @@ fn main() {
|
|||
let tagged_responses = deliver_actions_tagged(
|
||||
&leave_actions,
|
||||
node_ids[7],
|
||||
addrs[7],
|
||||
addrs[7].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -494,7 +495,7 @@ fn main() {
|
|||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
addrs[responder_idx],
|
||||
addrs[responder_idx].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -508,20 +509,20 @@ fn main() {
|
|||
// Rejoin peer 7
|
||||
if churn_pos == 350 {
|
||||
let config = DistributedNodeConfig {
|
||||
listen_addr: addrs[7],
|
||||
listen_addr: addrs[7].clone(),
|
||||
swim: swim_config.clone(),
|
||||
cache_capacity: 100,
|
||||
republish_interval: 500,
|
||||
};
|
||||
let revived = DistributedNode::new(config);
|
||||
let join_actions = revived.join(&[seed_addr]);
|
||||
let join_actions = revived.join(&[seed_addr.clone()]);
|
||||
nodes[7] = Some(revived);
|
||||
node_ids[7] = nodes[7].as_ref().unwrap().node_id();
|
||||
|
||||
let tagged_responses = deliver_actions_tagged(
|
||||
&join_actions,
|
||||
node_ids[7],
|
||||
addrs[7],
|
||||
addrs[7].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -530,7 +531,7 @@ fn main() {
|
|||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
addrs[responder_idx],
|
||||
addrs[responder_idx].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
|
|||
|
|
@ -2,15 +2,11 @@ pub mod collector;
|
|||
pub mod investigate;
|
||||
pub mod layer;
|
||||
pub mod trace;
|
||||
mod actors_html;
|
||||
mod dashboard_html;
|
||||
mod server;
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
pub mod tui;
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
mod distribution_html;
|
||||
#[cfg(feature = "distribution")]
|
||||
pub mod distribution_collector;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,17 +8,13 @@ use std::collections::HashMap;
|
|||
|
||||
use swactor::runtime::Runtime;
|
||||
|
||||
use crate::actors_html::ACTORS_HTML;
|
||||
use crate::collector::StatsCollector;
|
||||
use crate::dashboard_html::DASHBOARD_HTML;
|
||||
use crate::investigate;
|
||||
use crate::layer::EventStore;
|
||||
use crate::trace::RuntimeTrace;
|
||||
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_collector::DistributionStatsProvider;
|
||||
#[cfg(feature = "distribution")]
|
||||
use crate::distribution_html::DISTRIBUTION_HTML;
|
||||
|
||||
/// Format a server-sent event.
|
||||
fn format_sse(event: &str, data: &str) -> Vec<u8> {
|
||||
|
|
@ -97,15 +93,77 @@ fn make_sse_response(
|
|||
)
|
||||
}
|
||||
|
||||
fn respond_html(request: tiny_http::Request, html_template: &str, mode: &str) {
|
||||
let html = html_template.replace("__DASHBOARD_MODE__", mode);
|
||||
let response = tiny_http::Response::from_string(html).with_header(
|
||||
// ── Static file serving ─────────────────────────────────────────────────
|
||||
|
||||
/// 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"
|
||||
.parse::<tiny_http::Header>()
|
||||
.unwrap(),
|
||||
);
|
||||
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) {
|
||||
let response = tiny_http::Response::from_string("Not Found").with_status_code(404);
|
||||
|
|
@ -146,10 +204,12 @@ pub(crate) fn spawn_http_server(
|
|||
let url = request.url().to_string();
|
||||
let path = url.split('?').next().unwrap_or(&url);
|
||||
match path {
|
||||
"/" => respond_html(request, DASHBOARD_HTML, "live"),
|
||||
"/actors" => respond_html(request, ACTORS_HTML, "live"),
|
||||
"/" => serve_html_page(request, "index.html", "live"),
|
||||
"/actors" => serve_html_page(request, "actors.html", "live"),
|
||||
#[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" => {
|
||||
handle_live_sse(
|
||||
request,
|
||||
|
|
@ -340,9 +400,12 @@ pub(crate) fn spawn_replay_server(trace: Arc<RuntimeTrace>, port: u16, speed: f6
|
|||
};
|
||||
|
||||
let url = request.url().to_string();
|
||||
match url.as_str() {
|
||||
"/" => respond_html(request, DASHBOARD_HTML, "replay"),
|
||||
"/actors" => respond_html(request, ACTORS_HTML, "replay"),
|
||||
let path = url.split('?').next().unwrap_or(&url);
|
||||
match path {
|
||||
"/" => 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" => {
|
||||
handle_replay_sse(request, Arc::clone(&trace), speed);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,19 @@
|
|||
pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Actors</title>
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
<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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
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; }
|
||||
|
||||
.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; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.msg-type { color: #4caf50; }
|
||||
.msg-type.none { color: #555; font-style: italic; }
|
||||
|
||||
tr.focused { background: #1c1f2e; }
|
||||
tr.clickable { cursor: pointer; }
|
||||
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-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.detail-close {
|
||||
|
|
@ -129,10 +72,6 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
}
|
||||
.healthy-badge { color: #4caf50; font-size: 12px; }
|
||||
canvas.sparkline { width: 100%; height: 60px; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -207,8 +146,11 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
</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">
|
||||
<h2>Actor Detail <span id="detailAddr" style="color:#aaa;font-weight:400;"></span></h2>
|
||||
<button class="detail-close" id="detailClose">Close</button>
|
||||
|
|
@ -224,51 +166,10 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
<h2>Mailbox Depth History</h2>
|
||||
<canvas id="sparkline" class="sparkline"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script>
|
||||
(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 ──────────────────────────────────────────────
|
||||
var currentActors = [];
|
||||
var sortCol = 'worker';
|
||||
|
|
@ -277,39 +178,10 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
var focusedAddrHex = null;
|
||||
var depthHistory = {};
|
||||
var HISTORY_MAX = 120;
|
||||
|
||||
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
|
||||
var lastStatsJSON = ''; // fingerprint for skipping redundant redraws
|
||||
var renderPending = false; // rAF throttle flag
|
||||
|
||||
// ── 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function depthColor(d) {
|
||||
if (d === 0) return '#4caf50';
|
||||
if (d <= 2) return '#8bc34a';
|
||||
|
|
@ -350,7 +222,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
{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) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
|
|
@ -388,7 +260,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
depthCtx.fillStyle = '#e0e0e0';
|
||||
depthCtx.font = '10px monospace';
|
||||
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;
|
||||
|
|
@ -402,6 +274,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
// ── Actors per worker chart ────────────────────────────
|
||||
var workerCanvas = document.getElementById('workerChart');
|
||||
var workerCtx = workerCanvas.getContext('2d');
|
||||
var workerBarHitRegions = []; // [{x, y, w, h, wid}]
|
||||
|
||||
function drawWorkerChart(actors) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
|
|
@ -411,6 +284,7 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
workerCtx.scale(dpr, dpr);
|
||||
var W = rect.width, H = rect.height;
|
||||
workerCtx.clearRect(0, 0, W, H);
|
||||
workerBarHitRegions = [];
|
||||
|
||||
var workerCounts = {};
|
||||
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.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.fillStyle = '#e0e0e0';
|
||||
workerCtx.font = '10px monospace';
|
||||
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.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 ────────────────────────────────────────
|
||||
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 tr = document.createElement('tr');
|
||||
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';
|
||||
tr.setAttribute('data-addr', fullHex);
|
||||
tr.innerHTML =
|
||||
|
|
@ -531,14 +431,14 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
// ── Focus / detail panel ───────────────────────────────
|
||||
function focusActor(fullHex) {
|
||||
focusedAddrHex = fullHex;
|
||||
document.getElementById('detailPanel').className = 'panel full-width detail-panel visible';
|
||||
document.getElementById('detailPanel').className = 'panel detail-panel visible';
|
||||
updateDetailPanel();
|
||||
renderActorTable();
|
||||
}
|
||||
|
||||
function clearFocus() {
|
||||
focusedAddrHex = null;
|
||||
document.getElementById('detailPanel').className = 'panel full-width detail-panel';
|
||||
document.getElementById('detailPanel').className = 'panel detail-panel';
|
||||
renderActorTable();
|
||||
}
|
||||
|
||||
|
|
@ -653,31 +553,26 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
searchTimer = setTimeout(renderActorTable, 150);
|
||||
});
|
||||
|
||||
// ── Status helpers ─────────────────────────────────────
|
||||
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';
|
||||
}
|
||||
// ── Throttled render via requestAnimationFrame ────────
|
||||
function scheduleRender() {
|
||||
if (renderPending) return;
|
||||
renderPending = true;
|
||||
requestAnimationFrame(function() {
|
||||
renderPending = false;
|
||||
renderActorTable();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 || [];
|
||||
updateStatCards(currentActors);
|
||||
drawDepthChart(currentActors);
|
||||
|
|
@ -697,44 +592,11 @@ pub const ACTORS_HTML: &str = r##"<!DOCTYPE html>
|
|||
if (!liveAddrs[key]) delete depthHistory[key];
|
||||
}
|
||||
|
||||
renderActorTable();
|
||||
scheduleRender();
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
89
crates/runtime-dashboard/static/css/shared.css
Normal file
89
crates/runtime-dashboard/static/css/shared.css
Normal 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; }
|
||||
|
|
@ -1,65 +1,34 @@
|
|||
pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime – Distribution</title>
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
<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; }
|
||||
|
||||
.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; }
|
||||
.stats-cards { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-panel {
|
||||
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; }
|
||||
|
||||
.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 {
|
||||
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-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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: absolute; bottom: 12px; left: 12px; font-size: 10px;
|
||||
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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0f1117; }
|
||||
::-webkit-scrollbar-thumb { background: #2a2d3e; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -175,9 +182,37 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom-section">
|
||||
<h2>Recent Probes</h2>
|
||||
<div class="scroll-wrap" id="probesWrap"></div>
|
||||
<div class="bottom-section" style="flex:1.5;">
|
||||
<div class="gossip-header">
|
||||
<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 class="bottom-section">
|
||||
<h2>Routing Buckets</h2>
|
||||
|
|
@ -186,9 +221,9 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var dot = document.getElementById('statusDot');
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
|
|
@ -196,6 +231,10 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
var selfNodeId = ''; // this node's hex id
|
||||
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
|
||||
var N = 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 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 edges = [];
|
||||
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) {
|
||||
highlighted = {};
|
||||
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) {
|
||||
for (var i = 1; i < N; i++) highlighted[i] = true;
|
||||
} 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++) {
|
||||
var alpha = 1;
|
||||
var dash = false;
|
||||
if (highlighted) {
|
||||
if (!highlighted[i]) { alpha = 0.08; }
|
||||
else {
|
||||
// Check if this node is a routing neighbor
|
||||
var nid = nodeIds[i];
|
||||
if (routingSet[nid]) { dash = true; alpha = 0.7; }
|
||||
else { alpha = 0.5; }
|
||||
|
|
@ -595,7 +631,6 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
canvas.parentElement.getBoundingClientRect().height);
|
||||
runLayout();
|
||||
} else {
|
||||
// Just update states, redraw
|
||||
drawGraph();
|
||||
}
|
||||
|
||||
|
|
@ -618,7 +653,6 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
document.getElementById('statRepair').textContent = d.repair_queue_size;
|
||||
document.getElementById('statProbes').textContent = d.recent_probe_targets.length;
|
||||
|
||||
// Color alive/suspect/dead
|
||||
document.getElementById('statAlive').style.color = '#4caf50';
|
||||
document.getElementById('statSuspect').style.color = d.suspect_count > 0 ? '#ff9800' : '#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);
|
||||
}
|
||||
|
||||
// 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');
|
||||
probesWrap.innerHTML = '';
|
||||
if (d.recent_probe_targets.length > 0) {
|
||||
for (var i = d.recent_probe_targets.length - 1; i >= 0; i--) {
|
||||
var div = document.createElement('div');
|
||||
div.style.cssText = 'padding:2px 0;color:#aaa;font-size:11px;border-bottom:1px solid #1c1f2e;';
|
||||
div.textContent = d.recent_probe_targets[i].substring(0, 16) + '\u2026';
|
||||
div.style.cssText = 'padding:1px 0;color:#aaa;border-bottom:1px solid #1c1f2e;';
|
||||
div.textContent = nodeLabel(d.recent_probe_targets[i]);
|
||||
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
|
||||
drawBucketChart(d.routing_buckets);
|
||||
|
|
@ -716,32 +873,13 @@ pub const DISTRIBUTION_HTML: &str = r##"<!DOCTYPE html>
|
|||
}
|
||||
|
||||
// ── SSE connection ─────────────────────────────────────────────
|
||||
var es = new EventSource('/events');
|
||||
|
||||
es.addEventListener('distribution', function(e) {
|
||||
try {
|
||||
var d = JSON.parse(e.data);
|
||||
updateFromSnapshot(d);
|
||||
} catch(err) { console.error('distribution parse error', err); }
|
||||
createSSE({
|
||||
distribution: function(d) { updateFromSnapshot(d); }
|
||||
});
|
||||
|
||||
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();
|
||||
drawGraph();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
|
|
@ -1,57 +1,11 @@
|
|||
pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Swactor Runtime Dashboard</title>
|
||||
<link rel="stylesheet" href="/css/shared.css">
|
||||
<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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
@ -59,23 +13,9 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
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; }
|
||||
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 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; }
|
||||
|
||||
.log-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.log-panel { grid-column: 1 / -1; }
|
||||
.log-wrap { max-height: 300px; overflow-y: auto; }
|
||||
.log-wrap table { width: 100%; border-collapse: collapse; }
|
||||
.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 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-scroll { max-height: 360px; overflow-y: auto; }
|
||||
.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;
|
||||
}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -195,54 +121,17 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var DASHBOARD_MODE = '__DASHBOARD_MODE__';
|
||||
var logRowCount = 0;
|
||||
var MAX_LOG_ROWS = 2000;
|
||||
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);
|
||||
var workerOpenState = {}; // persistent open/close state for worker groups
|
||||
var workerNodes = {}; // persistent wid → {div, summaryEl, bodyEl, toggleEl}
|
||||
var lastStatsJSON = ''; // fingerprint for skipping redundant redraws
|
||||
|
||||
var canvas = document.getElementById('workerChart');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var colors = ['#4caf50','#2196f3','#ff9800','#f44336','#9c27b0','#00bcd4','#ffeb3b','#e91e63'];
|
||||
|
||||
function drawWorkerChart(workers) {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
|
|
@ -285,10 +174,13 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
}
|
||||
|
||||
function updateStats(data) {
|
||||
if (typeof data.uptime_ms === 'number') {
|
||||
lastUptimeMs = data.uptime_ms;
|
||||
lastStatsTime = Date.now();
|
||||
}
|
||||
trackUptime(data);
|
||||
|
||||
// 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 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;
|
||||
|
|
@ -299,54 +191,59 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
document.getElementById('statMailbox').textContent = totalMailbox;
|
||||
|
||||
drawWorkerChart(data.workers);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
updateActorTable(data.actors);
|
||||
updateWorkerDetails(data);
|
||||
}
|
||||
|
||||
function formatAddr(addr) {
|
||||
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';
|
||||
}
|
||||
return hex;
|
||||
// ── Differential actor table update ──────────────────────
|
||||
var actorRows = []; // pool of <tr> elements
|
||||
|
||||
function updateActorTable(actors) {
|
||||
var tbody = document.getElementById('actorTableBody');
|
||||
if (!actors) actors = [];
|
||||
var shown = actors.slice(0, 200);
|
||||
var needOverflow = actors.length > 200;
|
||||
var totalNeeded = shown.length + (needOverflow ? 1 : 0);
|
||||
|
||||
// Remove excess rows
|
||||
while (actorRows.length > totalNeeded) {
|
||||
var old = actorRows.pop();
|
||||
tbody.removeChild(old);
|
||||
}
|
||||
|
||||
function shortTypeName(full) {
|
||||
if (!full) return '';
|
||||
var parts = full.split('::');
|
||||
return parts[parts.length - 1];
|
||||
// Update or create rows
|
||||
for (var i = 0; i < shown.length; i++) {
|
||||
var addr = shown[i][0];
|
||||
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) {
|
||||
var container = document.getElementById('workerDetailContainer');
|
||||
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; });
|
||||
|
||||
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 div = document.createElement('div');
|
||||
var isOpen = openState[wid] || false;
|
||||
div.className = 'worker-group' + (isOpen ? ' open' : '');
|
||||
div.dataset.wid = wid;
|
||||
var node = workerNodes[wid];
|
||||
|
||||
var hdr = document.createElement('div');
|
||||
hdr.className = 'worker-group-header';
|
||||
// Build the summary and body HTML
|
||||
var panicHtml = g.info.panics > 0 ? ', <span style="color:#f44336">' + g.info.panics + ' panics</span>' : '';
|
||||
hdr.innerHTML =
|
||||
'<span class="wid" style="color:' + colors[wid % colors.length] + '">W' + wid + '</span>' +
|
||||
'<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 summaryHtml = g.actors.length + ' actors, ' +
|
||||
g.info.messages_processed.toLocaleString() + ' msgs, mbox ' + g.info.mailbox_depth + panicHtml;
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'worker-group-body';
|
||||
var bodyHtml;
|
||||
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 {
|
||||
var rows = '';
|
||||
g.actors.forEach(function(a) {
|
||||
var hex = formatAddr(a.address);
|
||||
var hex = addrToHex(a.address);
|
||||
var hasMsg = !!a.last_msg_type;
|
||||
var msgShort = hasMsg ? shortTypeName(a.last_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 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);
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
workerNodes[wid] = { div: div, summaryEl: summaryEl, bodyEl: body, toggleEl: toggleEl };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addLogEvents(events) {
|
||||
|
|
@ -445,68 +370,11 @@ pub const DASHBOARD_HTML: &str = r##"<!DOCTYPE html>
|
|||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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); }
|
||||
createSSE({
|
||||
stats: function(data) { updateStats(data); },
|
||||
activity: function(data) { addLogEvents(data); }
|
||||
});
|
||||
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
159
crates/runtime-dashboard/static/js/shared.js
Normal file
159
crates/runtime-dashboard/static/js/shared.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ use std::net::SocketAddr;
|
|||
use distribution::node::{DistributedNode, DistributedNodeConfig, ResolveResult};
|
||||
use distribution::swim::node::NodeAction;
|
||||
use distribution::swim::probe::SwimConfig;
|
||||
use distribution::types::NodeId;
|
||||
use distribution::types::{NodeAddr, NodeId};
|
||||
use swactor::actor::ActorAddress;
|
||||
|
||||
use crate::trace::{Event, SimulationTrace};
|
||||
|
|
@ -63,13 +63,14 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
|
|||
|
||||
// Create nodes with sequential addresses.
|
||||
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);
|
||||
|
||||
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 {
|
||||
listen_addr: addr,
|
||||
listen_addr: addr.clone(),
|
||||
swim: config.swim.clone(),
|
||||
cache_capacity: config.cache_capacity,
|
||||
republish_interval: 50,
|
||||
|
|
@ -81,9 +82,9 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
tick: 0,
|
||||
node_name: node_names[i].clone(),
|
||||
|
|
@ -96,7 +97,7 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
|
|||
let tagged_responses = deliver_actions_tagged(
|
||||
&join_actions,
|
||||
node_ids[i],
|
||||
addrs[i],
|
||||
addrs[i].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -105,7 +106,7 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
|
|||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
addrs[responder_idx],
|
||||
addrs[responder_idx].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -180,21 +181,21 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
|
|||
for &(revive_round, revive_idx) in &config.revive_schedule {
|
||||
if revive_round == round && revive_idx < n {
|
||||
let node_config = DistributedNodeConfig {
|
||||
listen_addr: addrs[revive_idx],
|
||||
listen_addr: addrs[revive_idx].clone(),
|
||||
swim: config.swim.clone(),
|
||||
cache_capacity: config.cache_capacity,
|
||||
republish_interval: 50,
|
||||
};
|
||||
let revived = DistributedNode::new(node_config);
|
||||
// Rejoin the cluster.
|
||||
let join_actions = revived.join(&[seed_addr]);
|
||||
let join_actions = revived.join(&[seed_addr.clone()]);
|
||||
nodes[revive_idx] = Some(revived);
|
||||
node_ids[revive_idx] = nodes[revive_idx].as_ref().unwrap().node_id();
|
||||
|
||||
let tagged_responses = deliver_actions_tagged(
|
||||
&join_actions,
|
||||
node_ids[revive_idx],
|
||||
addrs[revive_idx],
|
||||
addrs[revive_idx].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -203,7 +204,7 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
|
|||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
addrs[responder_idx],
|
||||
addrs[responder_idx].clone(),
|
||||
&mut nodes,
|
||||
&node_ids,
|
||||
&addrs,
|
||||
|
|
@ -317,7 +318,7 @@ pub fn run_simulation(config: DistributionSimConfig) -> DistTrace {
|
|||
fn tick_all_and_deliver(
|
||||
nodes: &mut [Option<DistributedNode>],
|
||||
node_ids: &[NodeId],
|
||||
addrs: &[SocketAddr],
|
||||
addrs: &[NodeAddr],
|
||||
events: &mut Vec<Event<DistributionEventKind>>,
|
||||
node_names: &[String],
|
||||
tick: u64,
|
||||
|
|
@ -355,7 +356,7 @@ fn tick_all_and_deliver(
|
|||
let tagged_responses = deliver_actions_tagged(
|
||||
&actions,
|
||||
node_ids[sender_idx],
|
||||
addrs[sender_idx],
|
||||
addrs[sender_idx].clone(),
|
||||
nodes,
|
||||
node_ids,
|
||||
addrs,
|
||||
|
|
@ -365,7 +366,7 @@ fn tick_all_and_deliver(
|
|||
deliver_actions_tagged(
|
||||
&response_actions,
|
||||
node_ids[responder_idx],
|
||||
addrs[responder_idx],
|
||||
addrs[responder_idx].clone(),
|
||||
nodes,
|
||||
node_ids,
|
||||
addrs,
|
||||
|
|
@ -380,10 +381,10 @@ fn tick_all_and_deliver(
|
|||
fn deliver_actions_tagged(
|
||||
actions: &[NodeAction],
|
||||
sender_id: NodeId,
|
||||
sender_addr: SocketAddr,
|
||||
sender_addr: NodeAddr,
|
||||
nodes: &mut [Option<DistributedNode>],
|
||||
node_ids: &[NodeId],
|
||||
node_addrs: &[SocketAddr],
|
||||
node_addrs: &[NodeAddr],
|
||||
) -> Vec<(usize, Vec<NodeAction>)> {
|
||||
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(ref mut node) = nodes[idx] {
|
||||
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() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
|
|
@ -423,7 +424,7 @@ fn deliver_actions_tagged(
|
|||
NodeAction::SendJoinRequest { to_addr } => {
|
||||
if let Some(idx) = node_addrs.iter().position(|a| a == to_addr) {
|
||||
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() {
|
||||
tagged_responses.push((idx, resp));
|
||||
}
|
||||
|
|
@ -453,7 +454,7 @@ fn deliver_actions_tagged(
|
|||
let resp = node.handle_ping_req(
|
||||
sender_id,
|
||||
*target,
|
||||
*target_addr,
|
||||
target_addr.clone(),
|
||||
*sequence,
|
||||
piggyback,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
[package]
|
||||
name = "wasm"
|
||||
name = "swactor-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
swactor = { path = "../..", default-features = false, features = ["no_random"] }
|
||||
swactor = { path = "../..", default-features = false, features = ["no_random", "transport"] }
|
||||
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
131
crates/wasm/src/js_actor.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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 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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actors (private — only exposed through the wasm API)
|
||||
// ---------------------------------------------------------------------------
|
||||
use crate::js_actor::{hex_decode_address, hex_encode, JsActor, JsMessage};
|
||||
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 {
|
||||
total: u32,
|
||||
|
|
@ -35,14 +73,134 @@ impl ActorInterface for Relay {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JS-facing runtime wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
// ─── BrowserRuntime ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 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]
|
||||
pub struct SwactorRuntime {
|
||||
rt: Runtime,
|
||||
inbox: Inbox<u32>,
|
||||
inbox: swactor::runtime::Inbox<u32>,
|
||||
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 {
|
||||
let addr = self
|
||||
.rt
|
||||
|
|
@ -76,19 +233,14 @@ impl SwactorRuntime {
|
|||
idx
|
||||
}
|
||||
|
||||
/// Spawn a relay that forwards every message to `target_idx`.
|
||||
pub fn spawn_relay(&mut self, target_idx: usize) -> usize {
|
||||
let target = self.actors[target_idx];
|
||||
let addr = self
|
||||
.rt
|
||||
.spawn(Relay { target })
|
||||
.expect("spawn relay");
|
||||
let addr = self.rt.spawn(Relay { target }).expect("spawn relay");
|
||||
let idx = self.actors.len();
|
||||
self.actors.push(addr);
|
||||
idx
|
||||
}
|
||||
|
||||
/// Send a u32 to the actor at `actor_idx`.
|
||||
pub fn send(&self, actor_idx: usize, value: u32) -> bool {
|
||||
if actor_idx >= self.actors.len() {
|
||||
return false;
|
||||
|
|
@ -96,17 +248,14 @@ impl SwactorRuntime {
|
|||
self.rt.send_to(self.actors[actor_idx], value).is_ok()
|
||||
}
|
||||
|
||||
/// Drive one tick of the single-threaded runtime.
|
||||
pub fn tick(&self) {
|
||||
self.rt.tick();
|
||||
}
|
||||
|
||||
/// Try to read the next result from the inbox. Returns `undefined` when empty.
|
||||
pub fn try_recv(&self) -> Option<u32> {
|
||||
self.inbox.try_recv()
|
||||
}
|
||||
|
||||
/// Number of actors the runtime knows about.
|
||||
pub fn actor_count(&self) -> usize {
|
||||
self.rt.stats().actors.len()
|
||||
}
|
||||
|
|
|
|||
29
crates/wasm/src/protocol.rs
Normal file
29
crates/wasm/src/protocol.rs
Normal 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];
|
||||
179
crates/wasm/src/ws_transport.rs
Normal file
179
crates/wasm/src/ws_transport.rs
Normal 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
153
crates/wasm/www/index.html
Normal 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
7
docs/DOCUMENTING.md
Normal 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
|
||||
560
docs/development_history/IN_BROWSER_RUNTIME.md
Normal file
560
docs/development_history/IN_BROWSER_RUNTIME.md
Normal 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
|
||||
```
|
||||
|
|
@ -89,6 +89,23 @@ propagates effects through all subsystems:
|
|||
This cascade ensures that a single SWIM death detection triggers routing
|
||||
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
|
||||
|
||||
| Type | File | Role |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
`Arc`, and spawns one OS thread per worker. Returns a `RuntimeHandle`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
- **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` |
|
||||
| `Runtime::deliver_raw`, setters | `src/runtime.rs` |
|
||||
| Transport fallback in worker | `src/worker.rs` |
|
||||
| Wire encode/decode | `src/transport.rs` |
|
||||
| Integration tests | `tests/transport_api.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
174
examples/ws_gateway.rs
Normal 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
|
||||
}
|
||||
}
|
||||
12
src/lib.rs
12
src/lib.rs
|
|
@ -1,11 +1,13 @@
|
|||
pub mod actor;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod worker;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) mod worker;
|
||||
|
||||
pub(crate) mod channel;
|
||||
pub(crate) mod error;
|
||||
pub use error::Error;
|
||||
|
||||
|
||||
pub mod config;
|
||||
pub(crate) mod delivery;
|
||||
pub mod stats;
|
||||
|
|
@ -15,6 +17,14 @@ pub mod runtime;
|
|||
#[cfg(feature = "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")]
|
||||
pub(crate) fn get_random(buf: &mut [u8]) {
|
||||
getrandom::getrandom(buf).unwrap()
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ use std::any::Any;
|
|||
use std::cell::RefCell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::time::Instant;
|
||||
|
||||
use crate::actor::{Actor, ActorAddress, ActorInterface, AnyActor, Message};
|
||||
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.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct RuntimeHandle {
|
||||
pub runtime: Arc<Runtime>,
|
||||
threads: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl RuntimeHandle {
|
||||
pub fn join(self) {
|
||||
for handle in self.threads {
|
||||
|
|
@ -243,6 +247,7 @@ impl Runtime {
|
|||
///
|
||||
/// Works in both single-threaded and multi-threaded configurations.
|
||||
/// In single-threaded mode, one background thread is spawned.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn run(self) -> Result<RuntimeHandle, Error> {
|
||||
self.is_running.store(true, Ordering::Release);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ─────────────────────────────
|
||||
|
||||
/// Attempt to serialize and send a message via the transport router.
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ use std::cell::RefCell;
|
|||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::time::Instant;
|
||||
|
||||
use crate::actor::{ActorAddress, AnyActor, ContextInner, Ctx};
|
||||
use crate::channel::Receiver;
|
||||
|
|
@ -161,6 +163,7 @@ impl Worker {
|
|||
did_work
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn run(&mut self, tc: &TickContext, is_running: &AtomicBool) {
|
||||
#[cfg(feature = "tracing")]
|
||||
let _span = tracing::info_span!("worker.run", worker_id = self.id.0).entered();
|
||||
|
|
|
|||
Loading…
Reference in a new issue