diff --git a/Cargo.lock b/Cargo.lock index 60f4e86..7fc9e50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index ee80c46..e081532 100644 --- a/Cargo.toml +++ b/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"] diff --git a/crates/distribution/src/kademlia/lookup.rs b/crates/distribution/src/kademlia/lookup.rs index fe0061c..6fe9f75 100644 --- a/crates/distribution/src/kademlia/lookup.rs +++ b/crates/distribution/src/kademlia/lookup.rs @@ -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, + known: HashMap, /// Nodes we've already queried. queried: HashSet, /// 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 { 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); diff --git a/crates/distribution/src/kademlia/routing_table.rs b/crates/distribution/src/kademlia/routing_table.rs index 59cc215..d405afc 100644 --- a/crates/distribution/src/kademlia/routing_table.rs +++ b/crates/distribution/src/kademlia/routing_table.rs @@ -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; } diff --git a/crates/distribution/src/messages.rs b/crates/distribution/src/messages.rs index 5844087..4182c68 100644 --- a/crates/distribution/src/messages.rs +++ b/crates/distribution/src/messages.rs @@ -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 { diff --git a/crates/distribution/src/node.rs b/crates/distribution/src/node.rs index def6822..6453309 100644 --- a/crates/distribution/src/node.rs +++ b/crates/distribution/src/node.rs @@ -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 { + pub fn join(&self, seeds: &[NodeAddr]) -> Vec { 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 { - 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 { + 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 { + pub fn handle_ping_req(&mut self, from: NodeId, target: NodeId, target_addr: NodeAddr, sequence: u64, piggyback: &[u8]) -> Vec { self.swim.handle_ping_req(from, target, target_addr, sequence, piggyback) } - pub fn handle_join_request(&mut self, from: NodeId, from_addr: SocketAddr) -> Vec { - let actions = self.swim.handle_join_request(from, from_addr); + pub fn handle_join_request(&mut self, from: NodeId, from_addr: NodeAddr) -> Vec { + 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) -> Vec { 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, } diff --git a/crates/distribution/src/snapshot.rs b/crates/distribution/src/snapshot.rs index 97f5013..49db591 100644 --- a/crates/distribution/src/snapshot.rs +++ b/crates/distribution/src/snapshot.rs @@ -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, + /// Active suspicion timers. + pub suspicion_timers: Vec, + /// Pending gossip dissemination entries. + pub dissemination_queue: Vec, + /// 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, + + // ─── Gossip protocol state ─────────────────────────────────────── + /// SWIM gossip protocol internals (None for backward compat with old traces). + #[serde(default)] + pub gossip: Option, } 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 = 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 = 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), } } } diff --git a/crates/distribution/src/swim/dissemination.rs b/crates/distribution/src/swim/dissemination.rs index 66eeeb9..e006951 100644 --- a/crates/distribution/src/swim/dissemination.rs +++ b/crates/distribution/src/swim/dissemination.rs @@ -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 { + 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 { diff --git a/crates/distribution/src/swim/member_list.rs b/crates/distribution/src/swim/member_list.rs index 56d7548..49abe12 100644 --- a/crates/distribution/src/swim/member_list.rs +++ b/crates/distribution/src/swim/member_list.rs @@ -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; diff --git a/crates/distribution/src/swim/node.rs b/crates/distribution/src/swim/node.rs index cf7249f..8879597 100644 --- a/crates/distribution/src/swim/node.rs +++ b/crates/distribution/src/swim/node.rs @@ -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 }, + SendPing { to: NodeId, to_addr: NodeAddr, sequence: u64, piggyback: Vec }, /// 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, }, /// Send a SWIM ack. - SendAck { to: NodeId, to_addr: SocketAddr, sequence: u64, piggyback: Vec }, + SendAck { to: NodeId, to_addr: NodeAddr, sequence: u64, piggyback: Vec }, /// 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 }, + SendJoinResponse { to: NodeId, to_addr: NodeAddr, members: Vec }, /// 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 { 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 { + pub fn handle_ping(&mut self, from: NodeId, from_addr: NodeAddr, sequence: u64, piggyback: &[u8]) -> Vec { 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 { @@ -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 { + pub fn handle_join_request(&mut self, from: NodeId, from_addr: NodeAddr) -> Vec { // 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 { + pub fn join(&self, seeds: &[NodeAddr]) -> Vec { 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, ), diff --git a/crates/distribution/src/swim/probe.rs b/crates/distribution/src/swim/probe.rs index 2af2462..a6b670d 100644 --- a/crates/distribution/src/swim/probe.rs +++ b/crates/distribution/src/swim/probe.rs @@ -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 { + 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 } diff --git a/crates/distribution/src/transport.rs b/crates/distribution/src/transport.rs index 807f9c2..d617f88 100644 --- a/crates/distribution/src/transport.rs +++ b/crates/distribution/src/transport.rs @@ -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,13 +31,22 @@ pub struct TcpTransport { default_dest: Option, } +/// Extract the `SocketAddr` from a `NodeAddr::Tcp`. Returns an error for +/// non-TCP variants. +fn require_tcp(addr: &NodeAddr) -> Result { + match addr { + NodeAddr::Tcp(sa) => Ok(*sa), + } +} + impl TcpTransport { - /// Create a transport that sends to a specific destination. - pub fn new(dest: SocketAddr) -> Self { - Self { + /// Create a transport that sends to a specific TCP destination. + pub fn new(dest: NodeAddr) -> Result { + let sa = require_tcp(&dest)?; + Ok(Self { pool: Mutex::new(HashMap::new()), - default_dest: Some(dest), - } + default_dest: Some(sa), + }) } /// Create a transport with no default destination. @@ -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 { - 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 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 { let mut len_buf = [0u8; 4]; stream.read_exact(&mut len_buf)?; @@ -198,24 +204,13 @@ fn read_wire_envelope(stream: &mut TcpStream) -> Result 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 { - // 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 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 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, diff --git a/crates/distribution/tests/kademlia_lookup.rs b/crates/distribution/tests/kademlia_lookup.rs index 169dbe9..c4067f4 100644 --- a/crates/distribution/tests/kademlia_lookup.rs +++ b/crates/distribution/tests/kademlia_lookup.rs @@ -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 ─────────────────────────────────────────────────────────── diff --git a/crates/distribution/tests/kademlia_routing.rs b/crates/distribution/tests/kademlia_routing.rs index 5218e58..7fabbe8 100644 --- a/crates/distribution/tests/kademlia_routing.rs +++ b/crates/distribution/tests/kademlia_routing.rs @@ -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 ─────────────────────────────────────────────────────── diff --git a/crates/distribution/tests/node_integration.rs b/crates/distribution/tests/node_integration.rs index 58a3468..2bdfd59 100644 --- a/crates/distribution/tests/node_integration.rs +++ b/crates/distribution/tests/node_integration.rs @@ -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 { 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); diff --git a/crates/distribution/tests/swim_dissemination.rs b/crates/distribution/tests/swim_dissemination.rs index e4641ea..ecfc3ff 100644 --- a/crates/distribution/tests/swim_dissemination.rs +++ b/crates/distribution/tests/swim_dissemination.rs @@ -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 ───────────────────────────────────────────────── diff --git a/crates/distribution/tests/swim_node.rs b/crates/distribution/tests/swim_node.rs index 27de0ae..92afcda 100644 --- a/crates/distribution/tests/swim_node.rs +++ b/crates/distribution/tests/swim_node.rs @@ -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 { diff --git a/crates/distribution/tests/swim_probe.rs b/crates/distribution/tests/swim_probe.rs index 1eed2ca..2d9e3fc 100644 --- a/crates/distribution/tests/swim_probe.rs +++ b/crates/distribution/tests/swim_probe.rs @@ -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 { diff --git a/crates/distribution/tests/transport_and_codec.rs b/crates/distribution/tests/transport_and_codec.rs index 54ad831..ee8248c 100644 --- a/crates/distribution/tests/transport_and_codec.rs +++ b/crates/distribution/tests/transport_and_codec.rs @@ -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::(); @@ -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)); diff --git a/crates/distribution/tests/types_and_crypto.rs b/crates/distribution/tests/types_and_crypto.rs index ff368e9..9d1c9f5 100644 --- a/crates/distribution/tests/types_and_crypto.rs +++ b/crates/distribution/tests/types_and_crypto.rs @@ -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, }; diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml new file mode 100644 index 0000000..7a0b49c --- /dev/null +++ b/crates/gateway/Cargo.toml @@ -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" diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs new file mode 100644 index 0000000..24689a0 --- /dev/null +++ b/crates/gateway/src/lib.rs @@ -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, +} + +/// 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>>, +} + +// The Mutex> requires Send+Sync on WsSession. +// WsSession contains a tungstenite WebSocket, 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>>, + /// Maps session ID → browser session metadata. + browser_sessions: HashMap, + /// Reverse map: actor address → session ID (for cleanup on disconnect). + browser_actors: HashMap, +} + +impl WsGateway { + /// Create a new gateway bound to the given address. + pub fn bind(addr: SocketAddr) -> Result { + 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 = sessions.iter().map(|s| s.id).collect(); + let removed: Vec = 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 + } + } + } +} diff --git a/crates/gateway/src/ws_acceptor.rs b/crates/gateway/src/ws_acceptor.rs new file mode 100644 index 0000000..0a3fa7e --- /dev/null +++ b/crates/gateway/src/ws_acceptor.rs @@ -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, + 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 { + 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) -> 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 + } +} diff --git a/crates/runtime-dashboard/examples/dashboard_demo.rs b/crates/runtime-dashboard/examples/dashboard_demo.rs index cabf3bc..c6fed61 100644 --- a/crates/runtime-dashboard/examples/dashboard_demo.rs +++ b/crates/runtime-dashboard/examples/dashboard_demo.rs @@ -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], 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], node_ids: &[NodeId], - node_addrs: &[SocketAddr], + node_addrs: &[NodeAddr], ) -> Vec<(usize, Vec)> { let mut tagged_responses: Vec<(usize, Vec)> = 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> = Vec::with_capacity(num_nodes); let mut node_ids: Vec = Vec::with_capacity(num_nodes); - let mut addrs: Vec = Vec::with_capacity(num_nodes); + let mut addrs: Vec = 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, diff --git a/crates/runtime-dashboard/src/lib.rs b/crates/runtime-dashboard/src/lib.rs index 79da02a..0211d06 100644 --- a/crates/runtime-dashboard/src/lib.rs +++ b/crates/runtime-dashboard/src/lib.rs @@ -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; diff --git a/crates/runtime-dashboard/src/server.rs b/crates/runtime-dashboard/src/server.rs index 34f1c5e..75a0425 100644 --- a/crates/runtime-dashboard/src/server.rs +++ b/crates/runtime-dashboard/src/server.rs @@ -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 { @@ -97,14 +93,76 @@ 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( - "Content-Type: text/html; charset=utf-8" - .parse::() - .unwrap(), - ); - let _ = request.respond(response); +// ── 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 or + 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::() + .unwrap(), + ); + let _ = request.respond(response); + } + Err(_) => respond_404(request), + } +} + +/// Inject `` into the HTML . +fn inject_mode_meta(html: &str, mode: &str) -> String { + let meta_tag = format!(r#""#); + // Insert after the charset meta tag for clean placement + if let Some(pos) = html.find("", &format!("\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::().unwrap(), + ); + let _ = request.respond(response); + } + Err(_) => respond_404(request), + } } fn respond_404(request: tiny_http::Request) { @@ -146,10 +204,12 @@ pub(crate) fn spawn_http_server( let url = request.url().to_string(); let 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, 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); } diff --git a/crates/runtime-dashboard/src/actors_html.rs b/crates/runtime-dashboard/static/actors.html similarity index 70% rename from crates/runtime-dashboard/src/actors_html.rs rename to crates/runtime-dashboard/static/actors.html index 7fb6100..70eeb91 100644 --- a/crates/runtime-dashboard/src/actors_html.rs +++ b/crates/runtime-dashboard/static/actors.html @@ -1,80 +1,19 @@ -pub const ACTORS_HTML: &str = r##" + Swactor Runtime – Actors + @@ -207,68 +146,30 @@ pub const ACTORS_HTML: &str = r##" - -
-
-

Actor Detail

- -
-
-
Full Address
-
Worker
-
Mailbox Depth
-
Messages Processed
-
Last Message Type
-
Status
-
-

Mailbox Depth History

- -
+ + +
+
+

Actor Detail

+ +
+
+
Full Address
+
Worker
+
Mailbox Depth
+
Messages Processed
+
Last Message Type
+
Status
+
+

Mailbox Depth History

+ +
+ + -"##; diff --git a/crates/runtime-dashboard/static/css/shared.css b/crates/runtime-dashboard/static/css/shared.css new file mode 100644 index 0000000..93a03c1 --- /dev/null +++ b/crates/runtime-dashboard/static/css/shared.css @@ -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; } diff --git a/crates/runtime-dashboard/src/distribution_html.rs b/crates/runtime-dashboard/static/distribution.html similarity index 71% rename from crates/runtime-dashboard/src/distribution_html.rs rename to crates/runtime-dashboard/static/distribution.html index 650bf06..f4f4a33 100644 --- a/crates/runtime-dashboard/src/distribution_html.rs +++ b/crates/runtime-dashboard/static/distribution.html @@ -1,65 +1,34 @@ -pub const DISTRIBUTION_HTML: &str = r##" + Swactor Runtime – Distribution + @@ -175,9 +182,37 @@ pub const DISTRIBUTION_HTML: &str = r##" -
-

Recent Probes

-
+
+
+

Gossip Protocol

+ IDLE + +
+
+
No peers — gossip inactive
+ +
+

Routing Buckets

@@ -186,9 +221,9 @@ pub const DISTRIBUTION_HTML: &str = r##"
+ -"##; diff --git a/crates/runtime-dashboard/src/dashboard_html.rs b/crates/runtime-dashboard/static/index.html similarity index 52% rename from crates/runtime-dashboard/src/dashboard_html.rs rename to crates/runtime-dashboard/static/index.html index ec1cc28..522fe7a 100644 --- a/crates/runtime-dashboard/src/dashboard_html.rs +++ b/crates/runtime-dashboard/static/index.html @@ -1,57 +1,11 @@ -pub const DASHBOARD_HTML: &str = r##" + Swactor Runtime Dashboard + @@ -195,54 +121,17 @@ pub const DASHBOARD_HTML: &str = r##" + -"##; diff --git a/crates/runtime-dashboard/static/js/shared.js b/crates/runtime-dashboard/static/js/shared.js new file mode 100644 index 0000000..d5c6a86 --- /dev/null +++ b/crates/runtime-dashboard/static/js/shared.js @@ -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,'>'); +} + +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; +} diff --git a/crates/simulation/src/distribution/sim.rs b/crates/simulation/src/distribution/sim.rs index 270c143..988dc27 100644 --- a/crates/simulation/src/distribution/sim.rs +++ b/crates/simulation/src/distribution/sim.rs @@ -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> = Vec::with_capacity(n); - let mut addrs: Vec = Vec::with_capacity(n); + let mut addrs: Vec = Vec::with_capacity(n); let mut node_ids: Vec = 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], node_ids: &[NodeId], - addrs: &[SocketAddr], + addrs: &[NodeAddr], events: &mut Vec>, 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], node_ids: &[NodeId], - node_addrs: &[SocketAddr], + node_addrs: &[NodeAddr], ) -> Vec<(usize, Vec)> { let mut tagged_responses: Vec<(usize, Vec)> = 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, ); diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index f4fda2e..6863994 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -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" diff --git a/crates/wasm/src/js_actor.rs b/crates/wasm/src/js_actor.rs new file mode 100644 index 0000000..91bdfa8 --- /dev/null +++ b/crates/wasm/src/js_actor.rs @@ -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 { + 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)) +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 221a38d..6fdea42 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -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 for JsMessageCodec { + fn encode(&self, msg: &JsMessage) -> Result, Error> { + Ok(msg.payload.as_bytes().to_vec()) + } + + fn decode(&self, bytes: &[u8]) -> Result { + 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, + codec_registry: Arc, + /// Hex address → inbox receiver for JS-polled inboxes. + inboxes: HashMap>, +} + +#[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::(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::().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, + inbox: swactor::runtime::Inbox, actors: Vec, } @@ -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 { self.inbox.try_recv() } - /// Number of actors the runtime knows about. pub fn actor_count(&self) -> usize { self.rt.stats().actors.len() } diff --git a/crates/wasm/src/protocol.rs b/crates/wasm/src/protocol.rs new file mode 100644 index 0000000..c012f57 --- /dev/null +++ b/crates/wasm/src/protocol.rs @@ -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]; diff --git a/crates/wasm/src/ws_transport.rs b/crates/wasm/src/ws_transport.rs new file mode 100644 index 0000000..2017b0a --- /dev/null +++ b/crates/wasm/src/ws_transport.rs @@ -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>, + /// Inbound envelope buffer, filled by the onmessage callback. + inbound: Vec, +} + +// ─── 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>, +} + +// Safety: wasm32 is single-threaded. The Rc> 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 { + 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::::new(move || { + let mut inner = inner_clone.borrow_mut(); + inner.state = ConnState::Open; + let pending: Vec> = 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::::new(move |e: MessageEvent| { + if let Ok(abuf) = e.data().dyn_into::() { + 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::::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::::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 { + 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) -> 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) + } +} diff --git a/crates/wasm/www/index.html b/crates/wasm/www/index.html new file mode 100644 index 0000000..0b422b7 --- /dev/null +++ b/crates/wasm/www/index.html @@ -0,0 +1,153 @@ + + + + + Swactor Browser Runtime + + + +

Swactor Browser Runtime

+ +
+

Connection

+ + Disconnected +

+ + +
+ +
+

Actors

+ + Actors: 0 +
+
+ +
+

Send Message

+ + + +
+ +
+

Log

+
+
+ + + + diff --git a/docs/DOCUMENTING.md b/docs/DOCUMENTING.md new file mode 100644 index 0000000..e2c3ea2 --- /dev/null +++ b/docs/DOCUMENTING.md @@ -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 \ No newline at end of file diff --git a/docs/development_history/IN_BROWSER_RUNTIME.md b/docs/development_history/IN_BROWSER_RUNTIME.md new file mode 100644 index 0000000..ca579d0 --- /dev/null +++ b/docs/development_history/IN_BROWSER_RUNTIME.md @@ -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 { + 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 { ... } +``` + +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>` internally — safe because wasm32 +is single-threaded. `unsafe impl Send + Sync` matches the pattern used by +`Runtime`'s existing `unsafe impl Sync` for `RefCell>`. + +### 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::() → 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` in WsTransport | wasm32 is single-threaded. `Arc` 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` 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 +``` diff --git a/docs/distribution.md b/docs/distribution.md index 31fe3cc..bc5d826 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -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 | diff --git a/docs/runtime.md b/docs/runtime.md index 9ca1c13..4c5aa67 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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`. diff --git a/docs/transport.md b/docs/transport.md index f75452f..b44f570 100644 --- a/docs/transport.md +++ b/docs/transport.md @@ -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` | diff --git a/examples/ws_gateway.rs b/examples/ws_gateway.rs new file mode 100644 index 0000000..9ebe0d1 --- /dev/null +++ b/examples/ws_gateway.rs @@ -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 for JsonCodec { + fn encode(&self, msg: &EchoRequest) -> Result, 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 { + 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 for JsonCodec { + fn encode(&self, msg: &EchoResponse) -> Result, Error> { + Ok(msg.payload.as_bytes().to_vec()) + } + fn decode(&self, bytes: &[u8]) -> Result { + 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 for JsMessageCodec { + fn encode(&self, msg: &JsMessage) -> Result, Error> { + Ok(msg.payload.as_bytes().to_vec()) + } + fn decode(&self, bytes: &[u8]) -> Result { + 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::(JsonCodec); + codecs.register::(JsonCodec); + codecs.register::(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 + } +} diff --git a/src/lib.rs b/src/lib.rs index 06642fc..8864a3a 100644 --- a/src/lib.rs +++ b/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() diff --git a/src/runtime.rs b/src/runtime.rs index 314fcf6..26d095e 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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 Inbox { } /// 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, threads: Vec>, } +#[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 { self.is_running.store(true, Ordering::Release); diff --git a/src/transport.rs b/src/transport.rs index 439c5a6..61beef2 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -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 { + 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 { + 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. diff --git a/src/worker.rs b/src/worker.rs index a257a30..47b497f 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -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();