code review results
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
@@ -10,9 +10,10 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
networking::{
|
||||
VectorClock,
|
||||
blob_support::{
|
||||
get_component_data,
|
||||
BlobStore,
|
||||
get_component_data,
|
||||
},
|
||||
delta_generation::NodeVectorClock,
|
||||
entity_map::NetworkEntityMap,
|
||||
@@ -23,19 +24,20 @@ use crate::{
|
||||
SyncMessage,
|
||||
},
|
||||
operations::ComponentOp,
|
||||
VectorClock,
|
||||
},
|
||||
persistence::reflection::deserialize_component_typed,
|
||||
};
|
||||
|
||||
/// Resource to track the last vector clock and originating node for each component on each entity
|
||||
/// Resource to track the last vector clock and originating node for each
|
||||
/// component on each entity
|
||||
///
|
||||
/// This enables Last-Write-Wins conflict resolution by comparing incoming
|
||||
/// operations' vector clocks with the current component's vector clock.
|
||||
/// The node_id is used as a deterministic tiebreaker for concurrent operations.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ComponentVectorClocks {
|
||||
/// Maps (entity_network_id, component_type) -> (vector_clock, originating_node_id)
|
||||
/// Maps (entity_network_id, component_type) -> (vector_clock,
|
||||
/// originating_node_id)
|
||||
clocks: HashMap<(Uuid, String), (VectorClock, Uuid)>,
|
||||
}
|
||||
|
||||
@@ -52,8 +54,15 @@ impl ComponentVectorClocks {
|
||||
}
|
||||
|
||||
/// Update the vector clock and node_id for a component
|
||||
pub fn set(&mut self, entity_id: Uuid, component_type: String, clock: VectorClock, node_id: Uuid) {
|
||||
self.clocks.insert((entity_id, component_type), (clock, node_id));
|
||||
pub fn set(
|
||||
&mut self,
|
||||
entity_id: Uuid,
|
||||
component_type: String,
|
||||
clock: VectorClock,
|
||||
node_id: Uuid,
|
||||
) {
|
||||
self.clocks
|
||||
.insert((entity_id, component_type), (clock, node_id));
|
||||
}
|
||||
|
||||
/// Remove all clocks for an entity (when entity is deleted)
|
||||
@@ -74,10 +83,7 @@ impl ComponentVectorClocks {
|
||||
///
|
||||
/// - `delta`: The EntityDelta to apply
|
||||
/// - `world`: The Bevy world to apply changes to
|
||||
pub fn apply_entity_delta(
|
||||
delta: &EntityDelta,
|
||||
world: &mut World,
|
||||
) {
|
||||
pub fn apply_entity_delta(delta: &EntityDelta, world: &mut World) {
|
||||
// Validate and merge the remote vector clock
|
||||
{
|
||||
let mut node_clock = world.resource_mut::<NodeVectorClock>();
|
||||
@@ -99,12 +105,10 @@ pub fn apply_entity_delta(
|
||||
for op in &delta.operations {
|
||||
if let crate::networking::ComponentOp::Delete { vector_clock } = op {
|
||||
// Record tombstone
|
||||
if let Some(mut registry) = world.get_resource_mut::<crate::networking::TombstoneRegistry>() {
|
||||
registry.record_deletion(
|
||||
delta.entity_id,
|
||||
delta.node_id,
|
||||
vector_clock.clone(),
|
||||
);
|
||||
if let Some(mut registry) =
|
||||
world.get_resource_mut::<crate::networking::TombstoneRegistry>()
|
||||
{
|
||||
registry.record_deletion(delta.entity_id, delta.node_id, vector_clock.clone());
|
||||
|
||||
// Despawn the entity if it exists locally
|
||||
let entity_to_despawn = {
|
||||
@@ -115,7 +119,10 @@ pub fn apply_entity_delta(
|
||||
world.despawn(entity);
|
||||
let mut entity_map = world.resource_mut::<NetworkEntityMap>();
|
||||
entity_map.remove_by_network_id(delta.entity_id);
|
||||
info!("Despawned entity {:?} due to Delete operation", delta.entity_id);
|
||||
info!(
|
||||
"Despawned entity {:?} due to Delete operation",
|
||||
delta.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
// Don't process other operations - entity is deleted
|
||||
@@ -127,10 +134,7 @@ pub fn apply_entity_delta(
|
||||
// Check if we should ignore this delta due to deletion
|
||||
if let Some(registry) = world.get_resource::<crate::networking::TombstoneRegistry>() {
|
||||
if registry.should_ignore_operation(delta.entity_id, &delta.vector_clock) {
|
||||
debug!(
|
||||
"Ignoring delta for deleted entity {:?}",
|
||||
delta.entity_id
|
||||
);
|
||||
debug!("Ignoring delta for deleted entity {:?}", delta.entity_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -158,7 +162,10 @@ pub fn apply_entity_delta(
|
||||
if let Some(mut persisted) = entity_mut.get_mut::<crate::persistence::Persisted>() {
|
||||
// Accessing &mut triggers Bevy's change detection
|
||||
let _ = &mut *persisted;
|
||||
debug!("Triggered persistence for synced entity {:?}", delta.entity_id);
|
||||
debug!(
|
||||
"Triggered persistence for synced entity {:?}",
|
||||
delta.entity_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,44 +174,52 @@ pub fn apply_entity_delta(
|
||||
///
|
||||
/// This dispatches to the appropriate CRDT merge logic based on the operation
|
||||
/// type.
|
||||
fn apply_component_op(
|
||||
entity: Entity,
|
||||
op: &ComponentOp,
|
||||
incoming_node_id: Uuid,
|
||||
world: &mut World,
|
||||
) {
|
||||
fn apply_component_op(entity: Entity, op: &ComponentOp, incoming_node_id: Uuid, world: &mut World) {
|
||||
match op {
|
||||
| ComponentOp::Set {
|
||||
component_type,
|
||||
data,
|
||||
vector_clock,
|
||||
} => {
|
||||
apply_set_operation_with_lww(entity, component_type, data, vector_clock, incoming_node_id, world);
|
||||
}
|
||||
apply_set_operation_with_lww(
|
||||
entity,
|
||||
component_type,
|
||||
data,
|
||||
vector_clock,
|
||||
incoming_node_id,
|
||||
world,
|
||||
);
|
||||
},
|
||||
| ComponentOp::SetAdd { component_type, .. } => {
|
||||
// OR-Set add - Phase 10 provides OrSet<T> type
|
||||
// Application code should use OrSet in components and handle SetAdd/SetRemove
|
||||
// Full integration will be in Phase 12 plugin
|
||||
debug!("SetAdd operation for {} (use OrSet<T> in components)", component_type);
|
||||
}
|
||||
debug!(
|
||||
"SetAdd operation for {} (use OrSet<T> in components)",
|
||||
component_type
|
||||
);
|
||||
},
|
||||
| ComponentOp::SetRemove { component_type, .. } => {
|
||||
// OR-Set remove - Phase 10 provides OrSet<T> type
|
||||
// Application code should use OrSet in components and handle SetAdd/SetRemove
|
||||
// Full integration will be in Phase 12 plugin
|
||||
debug!("SetRemove operation for {} (use OrSet<T> in components)", component_type);
|
||||
}
|
||||
debug!(
|
||||
"SetRemove operation for {} (use OrSet<T> in components)",
|
||||
component_type
|
||||
);
|
||||
},
|
||||
| ComponentOp::SequenceInsert { .. } => {
|
||||
// RGA insert - will be implemented in Phase 11
|
||||
debug!("SequenceInsert operation not yet implemented");
|
||||
}
|
||||
},
|
||||
| ComponentOp::SequenceDelete { .. } => {
|
||||
// RGA delete - will be implemented in Phase 11
|
||||
debug!("SequenceDelete operation not yet implemented");
|
||||
}
|
||||
},
|
||||
| ComponentOp::Delete { .. } => {
|
||||
// Entity deletion - will be implemented in Phase 9
|
||||
debug!("Delete operation not yet implemented");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +254,9 @@ fn apply_set_operation_with_lww(
|
||||
// Check if we should apply this operation based on LWW
|
||||
let should_apply = {
|
||||
if let Some(component_clocks) = world.get_resource::<ComponentVectorClocks>() {
|
||||
if let Some((current_clock, current_node_id)) = component_clocks.get(entity_network_id, component_type) {
|
||||
if let Some((current_clock, current_node_id)) =
|
||||
component_clocks.get(entity_network_id, component_type)
|
||||
{
|
||||
// We have a current clock - do LWW comparison with real node IDs
|
||||
let decision = compare_operations_lww(
|
||||
current_clock,
|
||||
@@ -249,23 +266,24 @@ fn apply_set_operation_with_lww(
|
||||
);
|
||||
|
||||
match decision {
|
||||
crate::networking::merge::MergeDecision::ApplyRemote => {
|
||||
| crate::networking::merge::MergeDecision::ApplyRemote => {
|
||||
debug!(
|
||||
"Applying remote Set for {} (remote is newer)",
|
||||
component_type
|
||||
);
|
||||
true
|
||||
}
|
||||
crate::networking::merge::MergeDecision::KeepLocal => {
|
||||
},
|
||||
| crate::networking::merge::MergeDecision::KeepLocal => {
|
||||
debug!(
|
||||
"Ignoring remote Set for {} (local is newer)",
|
||||
component_type
|
||||
);
|
||||
false
|
||||
}
|
||||
crate::networking::merge::MergeDecision::Concurrent => {
|
||||
// For concurrent operations, use node_id comparison as deterministic tiebreaker
|
||||
// This ensures all nodes make the same decision for concurrent updates
|
||||
},
|
||||
| crate::networking::merge::MergeDecision::Concurrent => {
|
||||
// For concurrent operations, use node_id comparison as deterministic
|
||||
// tiebreaker This ensures all nodes make the same
|
||||
// decision for concurrent updates
|
||||
if incoming_node_id > *current_node_id {
|
||||
debug!(
|
||||
"Applying remote Set for {} (concurrent, remote node_id {:?} > local {:?})",
|
||||
@@ -279,11 +297,11 @@ fn apply_set_operation_with_lww(
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
crate::networking::merge::MergeDecision::Equal => {
|
||||
},
|
||||
| crate::networking::merge::MergeDecision::Equal => {
|
||||
debug!("Ignoring remote Set for {} (clocks equal)", component_type);
|
||||
false
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// No current clock - this is the first time we're setting this component
|
||||
@@ -343,14 +361,14 @@ fn apply_set_operation(
|
||||
| ComponentData::BlobRef { hash: _, size: _ } => {
|
||||
if let Some(store) = blob_store {
|
||||
match get_component_data(data, store) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
| Ok(bytes) => bytes,
|
||||
| Err(e) => {
|
||||
error!(
|
||||
"Failed to retrieve blob for component {}: {}",
|
||||
component_type, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
error!(
|
||||
@@ -359,31 +377,34 @@ fn apply_set_operation(
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let reflected = match deserialize_component_typed(&data_bytes, component_type, &type_registry) {
|
||||
Ok(reflected) => reflected,
|
||||
Err(e) => {
|
||||
| Ok(reflected) => reflected,
|
||||
| Err(e) => {
|
||||
error!("Failed to deserialize component {}: {}", component_type, e);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let registration = match type_registry.get_with_type_path(component_type) {
|
||||
Some(reg) => reg,
|
||||
None => {
|
||||
| Some(reg) => reg,
|
||||
| None => {
|
||||
error!("Component type {} not registered", component_type);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let reflect_component = match registration.data::<ReflectComponent>() {
|
||||
Some(rc) => rc.clone(),
|
||||
None => {
|
||||
error!("Component type {} does not have ReflectComponent data", component_type);
|
||||
| Some(rc) => rc.clone(),
|
||||
| None => {
|
||||
error!(
|
||||
"Component type {} does not have ReflectComponent data",
|
||||
component_type
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
drop(type_registry);
|
||||
@@ -399,14 +420,20 @@ fn apply_set_operation(
|
||||
// This ensures remote entities can have their Transform changes detected
|
||||
if component_type == "bevy_transform::components::transform::Transform" {
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
||||
if entity_mut.get::<crate::networking::NetworkedTransform>().is_none() {
|
||||
if entity_mut
|
||||
.get::<crate::networking::NetworkedTransform>()
|
||||
.is_none()
|
||||
{
|
||||
entity_mut.insert(crate::networking::NetworkedTransform::default());
|
||||
debug!("Added NetworkedTransform to entity with Transform");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Entity {:?} not found when applying component {}", entity, component_type);
|
||||
error!(
|
||||
"Entity {:?} not found when applying component {}",
|
||||
entity, component_type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,12 +448,14 @@ fn apply_set_operation(
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::receive_and_apply_deltas_system;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_systems(Update, receive_and_apply_deltas_system);
|
||||
/// App::new().add_systems(Update, receive_and_apply_deltas_system);
|
||||
/// ```
|
||||
pub fn receive_and_apply_deltas_system(world: &mut World) {
|
||||
// Check if bridge exists
|
||||
if world.get_resource::<crate::networking::GossipBridge>().is_none() {
|
||||
if world
|
||||
.get_resource::<crate::networking::GossipBridge>()
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -456,23 +485,23 @@ pub fn receive_and_apply_deltas_system(world: &mut World) {
|
||||
);
|
||||
|
||||
apply_entity_delta(&delta, world);
|
||||
}
|
||||
},
|
||||
| SyncMessage::JoinRequest { .. } => {
|
||||
// Handled by handle_join_requests_system
|
||||
debug!("JoinRequest handled by dedicated system");
|
||||
}
|
||||
},
|
||||
| SyncMessage::FullState { .. } => {
|
||||
// Handled by handle_full_state_system
|
||||
debug!("FullState handled by dedicated system");
|
||||
}
|
||||
},
|
||||
| SyncMessage::SyncRequest { .. } => {
|
||||
// Handled by handle_sync_requests_system
|
||||
debug!("SyncRequest handled by dedicated system");
|
||||
}
|
||||
},
|
||||
| SyncMessage::MissingDeltas { .. } => {
|
||||
// Handled by handle_missing_deltas_system
|
||||
debug!("MissingDeltas handled by dedicated system");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
121
crates/lib/src/networking/auth.rs
Normal file
121
crates/lib/src/networking/auth.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Authentication and authorization for the networking layer
|
||||
|
||||
use sha2::{
|
||||
Digest,
|
||||
Sha256,
|
||||
};
|
||||
|
||||
use crate::networking::error::{
|
||||
NetworkingError,
|
||||
Result,
|
||||
};
|
||||
|
||||
/// Validate session secret using constant-time comparison
|
||||
///
|
||||
/// This function uses SHA-256 hash comparison to perform constant-time
|
||||
/// comparison and prevent timing attacks. The session secret is a pre-shared
|
||||
/// key that controls access to the gossip network.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provided` - The session secret provided by the joining peer
|
||||
/// * `expected` - The expected session secret configured for this node
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Session secret is valid
|
||||
/// * `Err(NetworkingError::SecurityError)` - Session secret is invalid
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use lib::networking::auth::validate_session_secret;
|
||||
///
|
||||
/// let secret = b"my_secret_key";
|
||||
/// assert!(validate_session_secret(secret, secret).is_ok());
|
||||
///
|
||||
/// let wrong_secret = b"wrong_key";
|
||||
/// assert!(validate_session_secret(wrong_secret, secret).is_err());
|
||||
/// ```
|
||||
pub fn validate_session_secret(provided: &[u8], expected: &[u8]) -> Result<()> {
|
||||
// Different lengths = definitely not equal, fail fast
|
||||
if provided.len() != expected.len() {
|
||||
return Err(NetworkingError::SecurityError(
|
||||
"Invalid session secret".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Hash both secrets for constant-time comparison
|
||||
let provided_hash = hash_secret(provided);
|
||||
let expected_hash = hash_secret(expected);
|
||||
|
||||
// Compare hashes using constant-time comparison
|
||||
// This prevents timing attacks that could leak information about the secret
|
||||
if provided_hash != expected_hash {
|
||||
return Err(NetworkingError::SecurityError(
|
||||
"Invalid session secret".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash a secret using SHA-256
|
||||
///
|
||||
/// This is used internally for constant-time comparison of session secrets.
|
||||
fn hash_secret(secret: &[u8]) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(secret);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_secret() {
|
||||
let secret = b"my_secret_key";
|
||||
assert!(validate_session_secret(secret, secret).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_secret() {
|
||||
let secret1 = b"my_secret_key";
|
||||
let secret2 = b"wrong_secret_key";
|
||||
let result = validate_session_secret(secret1, secret2);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
| Err(NetworkingError::SecurityError(_)) => {}, // Expected
|
||||
| _ => panic!("Expected SecurityError"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_lengths() {
|
||||
let secret1 = b"short";
|
||||
let secret2 = b"much_longer_secret";
|
||||
let result = validate_session_secret(secret1, secret2);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_secrets() {
|
||||
let empty = b"";
|
||||
assert!(validate_session_secret(empty, empty).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_is_deterministic() {
|
||||
let secret = b"test_secret";
|
||||
let hash1 = hash_secret(secret);
|
||||
let hash2 = hash_secret(secret);
|
||||
assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_secrets_have_different_hashes() {
|
||||
let secret1 = b"secret1";
|
||||
let secret2 = b"secret2";
|
||||
let hash1 = hash_secret(secret1);
|
||||
let hash2 = hash_secret(secret2);
|
||||
assert_ne!(hash1, hash2);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! by its hash in the ComponentOp.
|
||||
//!
|
||||
//! **NOTE:** This is a simplified implementation for Phase 6. Full iroh-blobs
|
||||
//! integration will be completed when we integrate with actual gossip networking.
|
||||
//! integration will be completed when we integrate with actual gossip
|
||||
//! networking.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -92,7 +93,8 @@ impl BlobStore {
|
||||
///
|
||||
/// Returns an error if the cache lock is poisoned.
|
||||
pub fn has_blob(&self, hash: &BlobHash) -> Result<bool> {
|
||||
Ok(self.cache
|
||||
Ok(self
|
||||
.cache
|
||||
.lock()
|
||||
.map_err(|e| NetworkingError::Blob(format!("Failed to lock cache: {}", e)))?
|
||||
.contains_key(hash))
|
||||
@@ -103,7 +105,8 @@ impl BlobStore {
|
||||
/// This is safer than calling `has_blob()` followed by `get_blob()` because
|
||||
/// it's atomic - the blob can't be removed between the check and get.
|
||||
pub fn get_blob_if_exists(&self, hash: &BlobHash) -> Result<Option<Vec<u8>>> {
|
||||
Ok(self.cache
|
||||
Ok(self
|
||||
.cache
|
||||
.lock()
|
||||
.map_err(|e| NetworkingError::Blob(format!("Failed to lock cache: {}", e)))?
|
||||
.get(hash)
|
||||
@@ -114,7 +117,8 @@ impl BlobStore {
|
||||
///
|
||||
/// Returns an error if the cache lock is poisoned.
|
||||
pub fn cache_size(&self) -> Result<usize> {
|
||||
Ok(self.cache
|
||||
Ok(self
|
||||
.cache
|
||||
.lock()
|
||||
.map_err(|e| NetworkingError::Blob(format!("Failed to lock cache: {}", e)))?
|
||||
.len())
|
||||
@@ -173,7 +177,10 @@ pub fn should_use_blob(data: &[u8]) -> bool {
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::{create_component_data, BlobStore};
|
||||
/// use lib::networking::{
|
||||
/// BlobStore,
|
||||
/// create_component_data,
|
||||
/// };
|
||||
///
|
||||
/// let store = BlobStore::new();
|
||||
///
|
||||
@@ -202,7 +209,11 @@ pub fn create_component_data(data: Vec<u8>, blob_store: &BlobStore) -> Result<Co
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::{get_component_data, BlobStore, ComponentData};
|
||||
/// use lib::networking::{
|
||||
/// BlobStore,
|
||||
/// ComponentData,
|
||||
/// get_component_data,
|
||||
/// };
|
||||
///
|
||||
/// let store = BlobStore::new();
|
||||
///
|
||||
@@ -334,7 +345,7 @@ mod tests {
|
||||
| ComponentData::BlobRef { hash, size } => {
|
||||
assert_eq!(size, 100_000);
|
||||
assert!(store.has_blob(&hash).unwrap());
|
||||
}
|
||||
},
|
||||
| ComponentData::Inline(_) => panic!("Expected blob reference"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ use crate::networking::{
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::auto_detect_transform_changes_system;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_systems(Update, auto_detect_transform_changes_system);
|
||||
/// App::new().add_systems(Update, auto_detect_transform_changes_system);
|
||||
/// ```
|
||||
pub fn auto_detect_transform_changes_system(
|
||||
mut query: Query<
|
||||
@@ -38,7 +37,10 @@ pub fn auto_detect_transform_changes_system(
|
||||
// Count how many changed entities we found
|
||||
let count = query.iter().count();
|
||||
if count > 0 {
|
||||
debug!("auto_detect_transform_changes_system: Found {} entities with changed Transform", count);
|
||||
debug!(
|
||||
"auto_detect_transform_changes_system: Found {} entities with changed Transform",
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
// Simply accessing &mut NetworkedEntity triggers Bevy's change detection
|
||||
@@ -66,8 +68,8 @@ impl LastSyncVersions {
|
||||
/// Check if we should sync this entity based on version
|
||||
pub fn should_sync(&self, network_id: uuid::Uuid, version: u64) -> bool {
|
||||
match self.versions.get(&network_id) {
|
||||
Some(&last_version) => version > last_version,
|
||||
None => true, // Never synced before
|
||||
| Some(&last_version) => version > last_version,
|
||||
| None => true, // Never synced before
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,10 +42,7 @@ use crate::networking::vector_clock::NodeId;
|
||||
/// fn spawn_networked_entity(mut commands: Commands) {
|
||||
/// let node_id = Uuid::new_v4();
|
||||
///
|
||||
/// commands.spawn((
|
||||
/// NetworkedEntity::new(node_id),
|
||||
/// Transform::default(),
|
||||
/// ));
|
||||
/// commands.spawn((NetworkedEntity::new(node_id), Transform::default()));
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Component, Reflect, Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -139,7 +136,10 @@ impl Default for NetworkedEntity {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{NetworkedEntity, NetworkedTransform};
|
||||
/// use lib::networking::{
|
||||
/// NetworkedEntity,
|
||||
/// NetworkedTransform,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// fn spawn_synced_transform(mut commands: Commands) {
|
||||
@@ -171,7 +171,10 @@ pub struct NetworkedTransform;
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{NetworkedEntity, NetworkedSelection};
|
||||
/// use lib::networking::{
|
||||
/// NetworkedEntity,
|
||||
/// NetworkedSelection,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// fn create_selection(mut commands: Commands) {
|
||||
@@ -182,10 +185,7 @@ pub struct NetworkedTransform;
|
||||
/// selection.selected_ids.insert(Uuid::new_v4());
|
||||
/// selection.selected_ids.insert(Uuid::new_v4());
|
||||
///
|
||||
/// commands.spawn((
|
||||
/// NetworkedEntity::new(node_id),
|
||||
/// selection,
|
||||
/// ));
|
||||
/// commands.spawn((NetworkedEntity::new(node_id), selection));
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Component, Reflect, Debug, Clone, Default)]
|
||||
@@ -253,7 +253,10 @@ impl NetworkedSelection {
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{NetworkedEntity, NetworkedDrawingPath};
|
||||
/// use lib::networking::{
|
||||
/// NetworkedDrawingPath,
|
||||
/// NetworkedEntity,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// fn create_path(mut commands: Commands) {
|
||||
@@ -265,10 +268,7 @@ impl NetworkedSelection {
|
||||
/// path.points.push(Vec2::new(10.0, 10.0));
|
||||
/// path.points.push(Vec2::new(20.0, 5.0));
|
||||
///
|
||||
/// commands.spawn((
|
||||
/// NetworkedEntity::new(node_id),
|
||||
/// path,
|
||||
/// ));
|
||||
/// commands.spawn((NetworkedEntity::new(node_id), path));
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Component, Reflect, Debug, Clone, Default)]
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::networking::{
|
||||
NetworkedEntity,
|
||||
change_detection::LastSyncVersions,
|
||||
gossip_bridge::GossipBridge,
|
||||
messages::{
|
||||
@@ -18,7 +19,6 @@ use crate::networking::{
|
||||
NodeId,
|
||||
VectorClock,
|
||||
},
|
||||
NetworkedEntity,
|
||||
};
|
||||
|
||||
/// Resource wrapping our node's vector clock
|
||||
@@ -63,8 +63,7 @@ impl NodeVectorClock {
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::generate_delta_system;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_systems(Update, generate_delta_system);
|
||||
/// App::new().add_systems(Update, generate_delta_system);
|
||||
/// ```
|
||||
pub fn generate_delta_system(world: &mut World) {
|
||||
// Check if bridge exists
|
||||
@@ -73,8 +72,10 @@ pub fn generate_delta_system(world: &mut World) {
|
||||
}
|
||||
|
||||
let changed_entities: Vec<(Entity, uuid::Uuid, uuid::Uuid)> = {
|
||||
let mut query = world.query_filtered::<(Entity, &NetworkedEntity), Changed<NetworkedEntity>>();
|
||||
query.iter(world)
|
||||
let mut query =
|
||||
world.query_filtered::<(Entity, &NetworkedEntity), Changed<NetworkedEntity>>();
|
||||
query
|
||||
.iter(world)
|
||||
.map(|(entity, networked)| (entity, networked.network_id, networked.owner_node_id))
|
||||
.collect()
|
||||
};
|
||||
@@ -142,12 +143,7 @@ pub fn generate_delta_system(world: &mut World) {
|
||||
let (bridge, _, _, mut last_versions, mut operation_log) = system_state.get_mut(world);
|
||||
|
||||
// Create EntityDelta
|
||||
let delta = EntityDelta::new(
|
||||
network_id,
|
||||
node_id,
|
||||
vector_clock.clone(),
|
||||
operations,
|
||||
);
|
||||
let delta = EntityDelta::new(network_id, node_id, vector_clock.clone(), operations);
|
||||
|
||||
// Record in operation log for anti-entropy
|
||||
if let Some(ref mut log) = operation_log {
|
||||
@@ -179,10 +175,22 @@ pub fn generate_delta_system(world: &mut World) {
|
||||
|
||||
// Phase 4: Update component vector clocks for local modifications
|
||||
{
|
||||
if let Some(mut component_clocks) = world.get_resource_mut::<crate::networking::ComponentVectorClocks>() {
|
||||
if let Some(mut component_clocks) =
|
||||
world.get_resource_mut::<crate::networking::ComponentVectorClocks>()
|
||||
{
|
||||
for op in &delta.operations {
|
||||
if let crate::networking::ComponentOp::Set { component_type, vector_clock: op_clock, .. } = op {
|
||||
component_clocks.set(network_id, component_type.clone(), op_clock.clone(), node_id);
|
||||
if let crate::networking::ComponentOp::Set {
|
||||
component_type,
|
||||
vector_clock: op_clock,
|
||||
..
|
||||
} = op
|
||||
{
|
||||
component_clocks.set(
|
||||
network_id,
|
||||
component_type.clone(),
|
||||
op_clock.clone(),
|
||||
node_id,
|
||||
);
|
||||
debug!(
|
||||
"Updated local vector clock for {} on entity {:?} (node_id: {:?})",
|
||||
component_type, network_id, node_id
|
||||
|
||||
@@ -22,13 +22,13 @@ use bevy::prelude::*;
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{NetworkEntityMap, NetworkedEntity};
|
||||
/// use lib::networking::{
|
||||
/// NetworkEntityMap,
|
||||
/// NetworkedEntity,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// fn example_system(
|
||||
/// mut map: ResMut<NetworkEntityMap>,
|
||||
/// query: Query<(Entity, &NetworkedEntity)>,
|
||||
/// ) {
|
||||
/// fn example_system(mut map: ResMut<NetworkEntityMap>, query: Query<(Entity, &NetworkedEntity)>) {
|
||||
/// // Register networked entities
|
||||
/// for (entity, networked) in query.iter() {
|
||||
/// map.insert(networked.network_id, entity);
|
||||
@@ -256,12 +256,14 @@ impl NetworkEntityMap {
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::register_networked_entities_system;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_systems(PostUpdate, register_networked_entities_system);
|
||||
/// App::new().add_systems(PostUpdate, register_networked_entities_system);
|
||||
/// ```
|
||||
pub fn register_networked_entities_system(
|
||||
mut map: ResMut<NetworkEntityMap>,
|
||||
query: Query<(Entity, &crate::networking::NetworkedEntity), Added<crate::networking::NetworkedEntity>>,
|
||||
query: Query<
|
||||
(Entity, &crate::networking::NetworkedEntity),
|
||||
Added<crate::networking::NetworkedEntity>,
|
||||
>,
|
||||
) {
|
||||
for (entity, networked) in query.iter() {
|
||||
map.insert(networked.network_id, entity);
|
||||
@@ -278,8 +280,7 @@ pub fn register_networked_entities_system(
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::cleanup_despawned_entities_system;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_systems(PostUpdate, cleanup_despawned_entities_system);
|
||||
/// App::new().add_systems(PostUpdate, cleanup_despawned_entities_system);
|
||||
/// ```
|
||||
pub fn cleanup_despawned_entities_system(
|
||||
mut map: ResMut<NetworkEntityMap>,
|
||||
|
||||
@@ -69,8 +69,9 @@ impl GossipBridge {
|
||||
|
||||
/// Drain all pending messages from the incoming queue atomically
|
||||
///
|
||||
/// This acquires the lock once and drains all messages, preventing race conditions
|
||||
/// where messages could arrive between individual try_recv() calls.
|
||||
/// This acquires the lock once and drains all messages, preventing race
|
||||
/// conditions where messages could arrive between individual try_recv()
|
||||
/// calls.
|
||||
pub fn drain_incoming(&self) -> Vec<VersionedMessage> {
|
||||
self.incoming
|
||||
.lock()
|
||||
|
||||
@@ -17,6 +17,8 @@ use bevy::{
|
||||
};
|
||||
|
||||
use crate::networking::{
|
||||
GossipBridge,
|
||||
NetworkedEntity,
|
||||
blob_support::BlobStore,
|
||||
delta_generation::NodeVectorClock,
|
||||
entity_map::NetworkEntityMap,
|
||||
@@ -25,17 +27,14 @@ use crate::networking::{
|
||||
SyncMessage,
|
||||
VersionedMessage,
|
||||
},
|
||||
GossipBridge,
|
||||
NetworkedEntity,
|
||||
};
|
||||
|
||||
/// Session secret for join authentication
|
||||
///
|
||||
/// In Phase 7, this is optional. Phase 13 will add full authentication.
|
||||
pub type SessionSecret = Vec<u8>;
|
||||
|
||||
/// Build a JoinRequest message
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `node_id` - The UUID of the node requesting to join
|
||||
/// * `session_secret` - Optional pre-shared secret for authentication
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
@@ -45,7 +44,10 @@ pub type SessionSecret = Vec<u8>;
|
||||
/// let node_id = Uuid::new_v4();
|
||||
/// let request = build_join_request(node_id, None);
|
||||
/// ```
|
||||
pub fn build_join_request(node_id: uuid::Uuid, session_secret: Option<SessionSecret>) -> VersionedMessage {
|
||||
pub fn build_join_request(
|
||||
node_id: uuid::Uuid,
|
||||
session_secret: Option<Vec<u8>>,
|
||||
) -> VersionedMessage {
|
||||
VersionedMessage::new(SyncMessage::JoinRequest {
|
||||
node_id,
|
||||
session_secret,
|
||||
@@ -99,10 +101,10 @@ pub fn build_full_state(
|
||||
let type_path = registration.type_info().type_path();
|
||||
|
||||
// Skip networked wrapper components
|
||||
if type_path.ends_with("::NetworkedEntity")
|
||||
|| type_path.ends_with("::NetworkedTransform")
|
||||
|| type_path.ends_with("::NetworkedSelection")
|
||||
|| type_path.ends_with("::NetworkedDrawingPath")
|
||||
if type_path.ends_with("::NetworkedEntity") ||
|
||||
type_path.ends_with("::NetworkedTransform") ||
|
||||
type_path.ends_with("::NetworkedSelection") ||
|
||||
type_path.ends_with("::NetworkedDrawingPath")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -114,8 +116,8 @@ pub fn build_full_state(
|
||||
// Create component data (inline or blob)
|
||||
let data = if let Some(store) = blob_store {
|
||||
match create_component_data(serialized, store) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
| Ok(d) => d,
|
||||
| Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
crate::networking::ComponentData::Inline(serialized)
|
||||
@@ -203,10 +205,7 @@ pub fn apply_full_state(
|
||||
// This ensures entities received via FullState are persisted locally
|
||||
let entity = commands
|
||||
.spawn((
|
||||
NetworkedEntity::with_id(
|
||||
entity_state.entity_id,
|
||||
entity_state.owner_node_id,
|
||||
),
|
||||
NetworkedEntity::with_id(entity_state.entity_id, entity_state.owner_node_id),
|
||||
crate::persistence::Persisted::with_id(entity_state.entity_id),
|
||||
))
|
||||
.id();
|
||||
@@ -224,14 +223,14 @@ pub fn apply_full_state(
|
||||
| blob_ref @ crate::networking::ComponentData::BlobRef { .. } => {
|
||||
if let Some(store) = blob_store {
|
||||
match get_component_data(blob_ref, store) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
| Ok(bytes) => bytes,
|
||||
| Err(e) => {
|
||||
error!(
|
||||
"Failed to retrieve blob for {}: {}",
|
||||
component_state.component_type, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
error!(
|
||||
@@ -240,44 +239,44 @@ pub fn apply_full_state(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Deserialize the component
|
||||
let reflected = match deserialize_component(&data_bytes, type_registry) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
| Ok(r) => r,
|
||||
| Err(e) => {
|
||||
error!(
|
||||
"Failed to deserialize {}: {}",
|
||||
component_state.component_type, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Get the type registration
|
||||
let registration = match type_registry.get_with_type_path(&component_state.component_type)
|
||||
{
|
||||
Some(reg) => reg,
|
||||
None => {
|
||||
error!(
|
||||
"Component type {} not registered",
|
||||
component_state.component_type
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let registration =
|
||||
match type_registry.get_with_type_path(&component_state.component_type) {
|
||||
| Some(reg) => reg,
|
||||
| None => {
|
||||
error!(
|
||||
"Component type {} not registered",
|
||||
component_state.component_type
|
||||
);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
// Get ReflectComponent data
|
||||
let reflect_component = match registration.data::<ReflectComponent>() {
|
||||
Some(rc) => rc.clone(),
|
||||
None => {
|
||||
| Some(rc) => rc.clone(),
|
||||
| None => {
|
||||
error!(
|
||||
"Component type {} does not have ReflectComponent data",
|
||||
component_state.component_type
|
||||
);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Insert the component
|
||||
@@ -302,8 +301,7 @@ pub fn apply_full_state(
|
||||
|
||||
debug!(
|
||||
"Spawned entity {:?} from FullState with {} components",
|
||||
entity_state.entity_id,
|
||||
num_components
|
||||
entity_state.entity_id, num_components
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,8 +318,7 @@ pub fn apply_full_state(
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::handle_join_requests_system;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_systems(Update, handle_join_requests_system);
|
||||
/// App::new().add_systems(Update, handle_join_requests_system);
|
||||
/// ```
|
||||
pub fn handle_join_requests_system(
|
||||
world: &World,
|
||||
@@ -347,9 +344,32 @@ pub fn handle_join_requests_system(
|
||||
} => {
|
||||
info!("Received JoinRequest from node {}", node_id);
|
||||
|
||||
// TODO: Validate session_secret in Phase 13
|
||||
if let Some(_secret) = session_secret {
|
||||
debug!("Session secret validation not yet implemented");
|
||||
// Validate session secret if configured
|
||||
if let Some(expected) =
|
||||
world.get_resource::<crate::networking::plugin::SessionSecret>()
|
||||
{
|
||||
match &session_secret {
|
||||
| Some(provided_secret) => {
|
||||
if let Err(e) = crate::networking::validate_session_secret(
|
||||
provided_secret,
|
||||
expected.as_bytes(),
|
||||
) {
|
||||
error!("JoinRequest from {} rejected: {}", node_id, e);
|
||||
continue; // Skip this request, don't send FullState
|
||||
}
|
||||
info!("Session secret validated for node {}", node_id);
|
||||
},
|
||||
| None => {
|
||||
warn!(
|
||||
"JoinRequest from {} missing required session secret, rejecting",
|
||||
node_id
|
||||
);
|
||||
continue; // Reject requests without secret when one is configured
|
||||
},
|
||||
}
|
||||
} else if session_secret.is_some() {
|
||||
// No session secret configured but peer provided one
|
||||
debug!("Session secret provided but none configured, accepting");
|
||||
}
|
||||
|
||||
// Build full state
|
||||
@@ -367,17 +387,19 @@ pub fn handle_join_requests_system(
|
||||
} else {
|
||||
info!("Sent FullState to node {}", node_id);
|
||||
}
|
||||
}
|
||||
},
|
||||
| _ => {
|
||||
// Not a JoinRequest, ignore (other systems handle other messages)
|
||||
}
|
||||
// Not a JoinRequest, ignore (other systems handle other
|
||||
// messages)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System to handle FullState messages
|
||||
///
|
||||
/// When we receive a FullState (after sending JoinRequest), apply it to our world.
|
||||
/// When we receive a FullState (after sending JoinRequest), apply it to our
|
||||
/// world.
|
||||
///
|
||||
/// This system should run BEFORE receive_and_apply_deltas_system to ensure
|
||||
/// we're fully initialized before processing deltas.
|
||||
@@ -416,10 +438,10 @@ pub fn handle_full_state_system(
|
||||
blob_store_ref,
|
||||
tombstone_registry.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
},
|
||||
| _ => {
|
||||
// Not a FullState, ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -441,7 +463,7 @@ mod tests {
|
||||
} => {
|
||||
assert_eq!(req_node_id, node_id);
|
||||
assert!(session_secret.is_none());
|
||||
}
|
||||
},
|
||||
| _ => panic!("Expected JoinRequest"),
|
||||
}
|
||||
}
|
||||
@@ -458,7 +480,7 @@ mod tests {
|
||||
session_secret,
|
||||
} => {
|
||||
assert_eq!(session_secret, Some(secret));
|
||||
}
|
||||
},
|
||||
| _ => panic!("Expected JoinRequest"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ pub enum MergeDecision {
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::{VectorClock, compare_operations_lww};
|
||||
/// use lib::networking::{
|
||||
/// VectorClock,
|
||||
/// compare_operations_lww,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// let node1 = Uuid::new_v4();
|
||||
@@ -189,9 +192,7 @@ mod tests {
|
||||
let decision = compare_operations_lww(&clock1, node1, &clock2, node2);
|
||||
|
||||
// Should use node ID as tiebreaker
|
||||
assert!(
|
||||
decision == MergeDecision::ApplyRemote || decision == MergeDecision::KeepLocal
|
||||
);
|
||||
assert!(decision == MergeDecision::ApplyRemote || decision == MergeDecision::KeepLocal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,6 +10,9 @@ use bevy::{
|
||||
};
|
||||
|
||||
use crate::networking::{
|
||||
GossipBridge,
|
||||
NetworkedEntity,
|
||||
TombstoneRegistry,
|
||||
apply_entity_delta,
|
||||
apply_full_state,
|
||||
blob_support::BlobStore,
|
||||
@@ -18,9 +21,8 @@ use crate::networking::{
|
||||
entity_map::NetworkEntityMap,
|
||||
messages::SyncMessage,
|
||||
operation_log::OperationLog,
|
||||
GossipBridge,
|
||||
NetworkedEntity,
|
||||
TombstoneRegistry,
|
||||
plugin::SessionSecret,
|
||||
validate_session_secret,
|
||||
};
|
||||
|
||||
/// Central message dispatcher system
|
||||
@@ -46,8 +48,7 @@ use crate::networking::{
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::message_dispatcher_system;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_systems(Update, message_dispatcher_system);
|
||||
/// App::new().add_systems(Update, message_dispatcher_system);
|
||||
/// ```
|
||||
pub fn message_dispatcher_system(world: &mut World) {
|
||||
// This is an exclusive system to avoid parameter conflicts with world access
|
||||
@@ -57,7 +58,8 @@ pub fn message_dispatcher_system(world: &mut World) {
|
||||
}
|
||||
|
||||
// Atomically drain all pending messages from the incoming queue
|
||||
// This prevents race conditions where messages could arrive between individual try_recv() calls
|
||||
// This prevents race conditions where messages could arrive between individual
|
||||
// try_recv() calls
|
||||
let messages: Vec<crate::networking::VersionedMessage> = {
|
||||
let bridge = world.resource::<GossipBridge>();
|
||||
bridge.drain_incoming()
|
||||
@@ -74,10 +76,7 @@ pub fn message_dispatcher_system(world: &mut World) {
|
||||
|
||||
/// Helper function to dispatch a single message
|
||||
/// This is separate to allow proper borrowing of world resources
|
||||
fn dispatch_message(
|
||||
world: &mut World,
|
||||
message: crate::networking::VersionedMessage,
|
||||
) {
|
||||
fn dispatch_message(world: &mut World, message: crate::networking::VersionedMessage) {
|
||||
match message.message {
|
||||
// EntityDelta - apply remote operations
|
||||
| SyncMessage::EntityDelta {
|
||||
@@ -100,7 +99,7 @@ fn dispatch_message(
|
||||
);
|
||||
|
||||
apply_entity_delta(&delta, world);
|
||||
}
|
||||
},
|
||||
|
||||
// JoinRequest - new peer joining
|
||||
| SyncMessage::JoinRequest {
|
||||
@@ -109,9 +108,29 @@ fn dispatch_message(
|
||||
} => {
|
||||
info!("Received JoinRequest from node {}", node_id);
|
||||
|
||||
// TODO: Validate session_secret in Phase 13
|
||||
if let Some(_secret) = session_secret {
|
||||
debug!("Session secret validation not yet implemented");
|
||||
// Validate session secret if configured
|
||||
if let Some(expected) = world.get_resource::<SessionSecret>() {
|
||||
match &session_secret {
|
||||
| Some(provided_secret) => {
|
||||
if let Err(e) =
|
||||
validate_session_secret(provided_secret, expected.as_bytes())
|
||||
{
|
||||
error!("JoinRequest from {} rejected: {}", node_id, e);
|
||||
return; // Stop processing this message
|
||||
}
|
||||
info!("Session secret validated for node {}", node_id);
|
||||
},
|
||||
| None => {
|
||||
warn!(
|
||||
"JoinRequest from {} missing required session secret, rejecting",
|
||||
node_id
|
||||
);
|
||||
return; // Reject requests without secret when one is configured
|
||||
},
|
||||
}
|
||||
} else if session_secret.is_some() {
|
||||
// No session secret configured but peer provided one
|
||||
debug!("Session secret provided but none configured, accepting");
|
||||
}
|
||||
|
||||
// Build and send full state
|
||||
@@ -143,7 +162,7 @@ fn dispatch_message(
|
||||
info!("Sent FullState to node {}", node_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// FullState - receiving world state after join
|
||||
| SyncMessage::FullState {
|
||||
@@ -163,7 +182,14 @@ fn dispatch_message(
|
||||
)> = SystemState::new(world);
|
||||
|
||||
{
|
||||
let (mut commands, mut entity_map, type_registry, mut node_clock, blob_store, mut tombstone_registry) = system_state.get_mut(world);
|
||||
let (
|
||||
mut commands,
|
||||
mut entity_map,
|
||||
type_registry,
|
||||
mut node_clock,
|
||||
blob_store,
|
||||
mut tombstone_registry,
|
||||
) = system_state.get_mut(world);
|
||||
let registry = type_registry.read();
|
||||
|
||||
apply_full_state(
|
||||
@@ -180,7 +206,7 @@ fn dispatch_message(
|
||||
}
|
||||
|
||||
system_state.apply(world);
|
||||
}
|
||||
},
|
||||
|
||||
// SyncRequest - peer requesting missing operations
|
||||
| SyncMessage::SyncRequest {
|
||||
@@ -213,7 +239,7 @@ fn dispatch_message(
|
||||
} else {
|
||||
warn!("Received SyncRequest but OperationLog resource not available");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// MissingDeltas - receiving operations we requested
|
||||
| SyncMessage::MissingDeltas { deltas } => {
|
||||
@@ -221,14 +247,11 @@ fn dispatch_message(
|
||||
|
||||
// Apply each delta
|
||||
for delta in deltas {
|
||||
debug!(
|
||||
"Applying missing delta for entity {:?}",
|
||||
delta.entity_id
|
||||
);
|
||||
debug!("Applying missing delta for entity {:?}", delta.entity_id);
|
||||
|
||||
apply_entity_delta(&delta, world);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,10 +303,10 @@ fn build_full_state_from_data(
|
||||
let type_path = registration.type_info().type_path();
|
||||
|
||||
// Skip networked wrapper components
|
||||
if type_path.ends_with("::NetworkedEntity")
|
||||
|| type_path.ends_with("::NetworkedTransform")
|
||||
|| type_path.ends_with("::NetworkedSelection")
|
||||
|| type_path.ends_with("::NetworkedDrawingPath")
|
||||
if type_path.ends_with("::NetworkedEntity") ||
|
||||
type_path.ends_with("::NetworkedTransform") ||
|
||||
type_path.ends_with("::NetworkedSelection") ||
|
||||
type_path.ends_with("::NetworkedDrawingPath")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -295,8 +318,8 @@ fn build_full_state_from_data(
|
||||
// Create component data (inline or blob)
|
||||
let data = if let Some(store) = blob_store {
|
||||
match create_component_data(serialized, store) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
| Ok(d) => d,
|
||||
| Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
crate::networking::ComponentData::Inline(serialized)
|
||||
|
||||
@@ -144,7 +144,8 @@ pub struct EntityState {
|
||||
/// Contains the component type and its serialized data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComponentState {
|
||||
/// Type path of the component (e.g., "bevy_transform::components::Transform")
|
||||
/// Type path of the component (e.g.,
|
||||
/// "bevy_transform::components::Transform")
|
||||
pub component_type: String,
|
||||
|
||||
/// Serialized component data (bincode)
|
||||
|
||||
@@ -25,10 +25,14 @@
|
||||
//!
|
||||
//! // Build a component operation
|
||||
//! let builder = ComponentOpBuilder::new(node_id, clock.clone());
|
||||
//! let op = builder.set("Transform".to_string(), ComponentData::Inline(vec![1, 2, 3]));
|
||||
//! let op = builder.set(
|
||||
//! "Transform".to_string(),
|
||||
//! ComponentData::Inline(vec![1, 2, 3]),
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
mod apply_ops;
|
||||
mod auth;
|
||||
mod blob_support;
|
||||
mod change_detection;
|
||||
mod components;
|
||||
@@ -51,6 +55,7 @@ mod tombstones;
|
||||
mod vector_clock;
|
||||
|
||||
pub use apply_ops::*;
|
||||
pub use auth::*;
|
||||
pub use blob_support::*;
|
||||
pub use change_detection::*;
|
||||
pub use components::*;
|
||||
@@ -108,10 +113,12 @@ pub fn spawn_networked_entity(
|
||||
use bevy::prelude::*;
|
||||
|
||||
// Spawn with both NetworkedEntity and Persisted components
|
||||
let entity = world.spawn((
|
||||
NetworkedEntity::with_id(entity_id, node_id),
|
||||
crate::persistence::Persisted::with_id(entity_id),
|
||||
)).id();
|
||||
let entity = world
|
||||
.spawn((
|
||||
NetworkedEntity::with_id(entity_id, node_id),
|
||||
crate::persistence::Persisted::with_id(entity_id),
|
||||
))
|
||||
.id();
|
||||
|
||||
// Register in entity map
|
||||
let mut entity_map = world.resource_mut::<NetworkEntityMap>();
|
||||
|
||||
@@ -11,8 +11,8 @@ use bevy::{
|
||||
use crate::{
|
||||
networking::{
|
||||
blob_support::{
|
||||
create_component_data,
|
||||
BlobStore,
|
||||
create_component_data,
|
||||
},
|
||||
error::Result,
|
||||
messages::ComponentData,
|
||||
@@ -72,7 +72,8 @@ pub fn build_set_operation(
|
||||
/// Build Set operations for all components on an entity
|
||||
///
|
||||
/// This iterates over all components with reflection data and creates Set
|
||||
/// operations for each one. Automatically uses blob storage for large components.
|
||||
/// operations for each one. Automatically uses blob storage for large
|
||||
/// components.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
@@ -97,7 +98,10 @@ pub fn build_entity_operations(
|
||||
let mut operations = Vec::new();
|
||||
let entity_ref = world.entity(entity);
|
||||
|
||||
debug!("build_entity_operations: Building operations for entity {:?}", entity);
|
||||
debug!(
|
||||
"build_entity_operations: Building operations for entity {:?}",
|
||||
entity
|
||||
);
|
||||
|
||||
// Iterate over all type registrations
|
||||
for registration in type_registry.iter() {
|
||||
@@ -110,10 +114,10 @@ pub fn build_entity_operations(
|
||||
let type_path = registration.type_info().type_path();
|
||||
|
||||
// Skip certain components
|
||||
if type_path.ends_with("::NetworkedEntity")
|
||||
|| type_path.ends_with("::NetworkedTransform")
|
||||
|| type_path.ends_with("::NetworkedSelection")
|
||||
|| type_path.ends_with("::NetworkedDrawingPath")
|
||||
if type_path.ends_with("::NetworkedEntity") ||
|
||||
type_path.ends_with("::NetworkedTransform") ||
|
||||
type_path.ends_with("::NetworkedSelection") ||
|
||||
type_path.ends_with("::NetworkedDrawingPath")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -164,7 +168,10 @@ pub fn build_entity_operations(
|
||||
///
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{build_transform_operation, VectorClock};
|
||||
/// use lib::networking::{
|
||||
/// VectorClock,
|
||||
/// build_transform_operation,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// # fn example(transform: &Transform, type_registry: &bevy::reflect::TypeRegistry) {
|
||||
@@ -192,7 +199,10 @@ pub fn build_transform_operation(
|
||||
};
|
||||
|
||||
let builder = ComponentOpBuilder::new(node_id, vector_clock);
|
||||
Ok(builder.set("bevy_transform::components::transform::Transform".to_string(), data))
|
||||
Ok(builder.set(
|
||||
"bevy_transform::components::transform::Transform".to_string(),
|
||||
data,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -208,7 +218,8 @@ mod tests {
|
||||
let node_id = uuid::Uuid::new_v4();
|
||||
let clock = VectorClock::new();
|
||||
|
||||
let op = build_transform_operation(&transform, node_id, clock, &type_registry, None).unwrap();
|
||||
let op =
|
||||
build_transform_operation(&transform, node_id, clock, &type_registry, None).unwrap();
|
||||
|
||||
assert!(op.is_set());
|
||||
assert_eq!(
|
||||
@@ -227,9 +238,7 @@ mod tests {
|
||||
type_registry.register::<Transform>();
|
||||
|
||||
// Spawn entity with Transform
|
||||
let entity = world
|
||||
.spawn(Transform::from_xyz(1.0, 2.0, 3.0))
|
||||
.id();
|
||||
let entity = world.spawn(Transform::from_xyz(1.0, 2.0, 3.0)).id();
|
||||
|
||||
let node_id = uuid::Uuid::new_v4();
|
||||
let clock = VectorClock::new();
|
||||
@@ -250,11 +259,15 @@ mod tests {
|
||||
let node_id = uuid::Uuid::new_v4();
|
||||
let mut clock = VectorClock::new();
|
||||
|
||||
let op1 = build_transform_operation(&transform, node_id, clock.clone(), &type_registry, None).unwrap();
|
||||
let op1 =
|
||||
build_transform_operation(&transform, node_id, clock.clone(), &type_registry, None)
|
||||
.unwrap();
|
||||
assert_eq!(op1.vector_clock().get(node_id), 1);
|
||||
|
||||
clock.increment(node_id);
|
||||
let op2 = build_transform_operation(&transform, node_id, clock.clone(), &type_registry, None).unwrap();
|
||||
let op2 =
|
||||
build_transform_operation(&transform, node_id, clock.clone(), &type_registry, None)
|
||||
.unwrap();
|
||||
assert_eq!(op2.vector_clock().get(node_id), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ use std::collections::{
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::networking::{
|
||||
GossipBridge,
|
||||
NodeVectorClock,
|
||||
messages::{
|
||||
EntityDelta,
|
||||
SyncMessage,
|
||||
@@ -27,8 +29,6 @@ use crate::networking::{
|
||||
NodeId,
|
||||
VectorClock,
|
||||
},
|
||||
GossipBridge,
|
||||
NodeVectorClock,
|
||||
};
|
||||
|
||||
/// Maximum operations to keep per entity (prevents unbounded growth)
|
||||
@@ -62,7 +62,8 @@ struct LogEntry {
|
||||
/// - Max operation age: `MAX_OP_AGE_SECS` (300 seconds / 5 minutes)
|
||||
/// - Max entities: `MAX_ENTITIES` (10,000)
|
||||
///
|
||||
/// When limits are exceeded, oldest operations/entities are pruned automatically.
|
||||
/// When limits are exceeded, oldest operations/entities are pruned
|
||||
/// automatically.
|
||||
#[derive(Resource)]
|
||||
pub struct OperationLog {
|
||||
/// Map from entity ID to list of recent operations
|
||||
@@ -88,7 +89,11 @@ impl OperationLog {
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::{OperationLog, EntityDelta, VectorClock};
|
||||
/// use lib::networking::{
|
||||
/// EntityDelta,
|
||||
/// OperationLog,
|
||||
/// VectorClock,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// let mut log = OperationLog::new();
|
||||
@@ -119,7 +124,10 @@ impl OperationLog {
|
||||
timestamp: std::time::Instant::now(),
|
||||
};
|
||||
|
||||
let log = self.logs.entry(delta.entity_id).or_insert_with(VecDeque::new);
|
||||
let log = self
|
||||
.logs
|
||||
.entry(delta.entity_id)
|
||||
.or_insert_with(VecDeque::new);
|
||||
log.push_back(entry);
|
||||
self.total_ops += 1;
|
||||
|
||||
@@ -134,9 +142,7 @@ impl OperationLog {
|
||||
fn find_oldest_entity(&self) -> Option<uuid::Uuid> {
|
||||
self.logs
|
||||
.iter()
|
||||
.filter_map(|(entity_id, log)| {
|
||||
log.front().map(|entry| (*entity_id, entry.timestamp))
|
||||
})
|
||||
.filter_map(|(entity_id, log)| log.front().map(|entry| (*entity_id, entry.timestamp)))
|
||||
.min_by_key(|(_, timestamp)| *timestamp)
|
||||
.map(|(entity_id, _)| entity_id)
|
||||
}
|
||||
@@ -189,9 +195,7 @@ impl OperationLog {
|
||||
|
||||
for log in self.logs.values_mut() {
|
||||
let before_len = log.len();
|
||||
log.retain(|entry| {
|
||||
now.duration_since(entry.timestamp) < max_age
|
||||
});
|
||||
log.retain(|entry| now.duration_since(entry.timestamp) < max_age);
|
||||
pruned_count += before_len - log.len();
|
||||
}
|
||||
|
||||
@@ -226,7 +230,10 @@ impl Default for OperationLog {
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use lib::networking::{build_sync_request, VectorClock};
|
||||
/// use lib::networking::{
|
||||
/// VectorClock,
|
||||
/// build_sync_request,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// let node_id = Uuid::new_v4();
|
||||
@@ -258,8 +265,7 @@ pub fn build_missing_deltas(deltas: Vec<EntityDelta>) -> VersionedMessage {
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::handle_sync_requests_system;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_systems(Update, handle_sync_requests_system);
|
||||
/// App::new().add_systems(Update, handle_sync_requests_system);
|
||||
/// ```
|
||||
pub fn handle_sync_requests_system(
|
||||
bridge: Option<Res<GossipBridge>>,
|
||||
@@ -296,10 +302,10 @@ pub fn handle_sync_requests_system(
|
||||
} else {
|
||||
debug!("No missing deltas for node {}", requesting_node);
|
||||
}
|
||||
}
|
||||
},
|
||||
| _ => {
|
||||
// Not a SyncRequest, ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,17 +330,14 @@ pub fn handle_missing_deltas_system(world: &mut World) {
|
||||
|
||||
// Apply each delta
|
||||
for delta in deltas {
|
||||
debug!(
|
||||
"Applying missing delta for entity {:?}",
|
||||
delta.entity_id
|
||||
);
|
||||
debug!("Applying missing delta for entity {:?}", delta.entity_id);
|
||||
|
||||
crate::networking::apply_entity_delta(&delta, world);
|
||||
}
|
||||
}
|
||||
},
|
||||
| _ => {
|
||||
// Not MissingDeltas, ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,10 +397,7 @@ pub fn prune_operation_log_system(
|
||||
let after = operation_log.total_operations();
|
||||
|
||||
if before != after {
|
||||
debug!(
|
||||
"Pruned operation log: {} ops -> {} ops",
|
||||
before, after
|
||||
);
|
||||
debug!("Pruned operation log: {} ops -> {} ops", before, after);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -489,7 +489,7 @@ mod tests {
|
||||
} => {
|
||||
assert_eq!(req_node_id, node_id);
|
||||
assert_eq!(vector_clock, clock);
|
||||
}
|
||||
},
|
||||
| _ => panic!("Expected SyncRequest"),
|
||||
}
|
||||
}
|
||||
@@ -507,7 +507,7 @@ mod tests {
|
||||
| SyncMessage::MissingDeltas { deltas } => {
|
||||
assert_eq!(deltas.len(), 1);
|
||||
assert_eq!(deltas[0].entity_id, entity_id);
|
||||
}
|
||||
},
|
||||
| _ => panic!("Expected MissingDeltas"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,11 +144,11 @@ impl ComponentOp {
|
||||
/// Get the component type for this operation
|
||||
pub fn component_type(&self) -> Option<&str> {
|
||||
match self {
|
||||
| ComponentOp::Set { component_type, .. }
|
||||
| ComponentOp::SetAdd { component_type, .. }
|
||||
| ComponentOp::SetRemove { component_type, .. }
|
||||
| ComponentOp::SequenceInsert { component_type, .. }
|
||||
| ComponentOp::SequenceDelete { component_type, .. } => Some(component_type),
|
||||
| ComponentOp::Set { component_type, .. } |
|
||||
ComponentOp::SetAdd { component_type, .. } |
|
||||
ComponentOp::SetRemove { component_type, .. } |
|
||||
ComponentOp::SequenceInsert { component_type, .. } |
|
||||
ComponentOp::SequenceDelete { component_type, .. } => Some(component_type),
|
||||
| ComponentOp::Delete { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -156,12 +156,12 @@ impl ComponentOp {
|
||||
/// Get the vector clock for this operation
|
||||
pub fn vector_clock(&self) -> &VectorClock {
|
||||
match self {
|
||||
| ComponentOp::Set { vector_clock, .. }
|
||||
| ComponentOp::SetAdd { vector_clock, .. }
|
||||
| ComponentOp::SetRemove { vector_clock, .. }
|
||||
| ComponentOp::SequenceInsert { vector_clock, .. }
|
||||
| ComponentOp::SequenceDelete { vector_clock, .. }
|
||||
| ComponentOp::Delete { vector_clock } => vector_clock,
|
||||
| ComponentOp::Set { vector_clock, .. } |
|
||||
ComponentOp::SetAdd { vector_clock, .. } |
|
||||
ComponentOp::SetRemove { vector_clock, .. } |
|
||||
ComponentOp::SequenceInsert { vector_clock, .. } |
|
||||
ComponentOp::SequenceDelete { vector_clock, .. } |
|
||||
ComponentOp::Delete { vector_clock } => vector_clock,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,11 @@ impl ComponentOpBuilder {
|
||||
}
|
||||
|
||||
/// Build a SetRemove operation (OR-Set)
|
||||
pub fn set_remove(mut self, component_type: String, removed_ids: Vec<uuid::Uuid>) -> ComponentOp {
|
||||
pub fn set_remove(
|
||||
mut self,
|
||||
component_type: String,
|
||||
removed_ids: Vec<uuid::Uuid>,
|
||||
) -> ComponentOp {
|
||||
self.vector_clock.increment(self.node_id);
|
||||
ComponentOp::SetRemove {
|
||||
component_type,
|
||||
@@ -259,7 +263,11 @@ impl ComponentOpBuilder {
|
||||
}
|
||||
|
||||
/// Build a SequenceDelete operation (RGA)
|
||||
pub fn sequence_delete(mut self, component_type: String, element_id: uuid::Uuid) -> ComponentOp {
|
||||
pub fn sequence_delete(
|
||||
mut self,
|
||||
component_type: String,
|
||||
element_id: uuid::Uuid,
|
||||
) -> ComponentOp {
|
||||
self.vector_clock.increment(self.node_id);
|
||||
ComponentOp::SequenceDelete {
|
||||
component_type,
|
||||
@@ -352,7 +360,10 @@ mod tests {
|
||||
let clock = VectorClock::new();
|
||||
|
||||
let builder = ComponentOpBuilder::new(node_id, clock);
|
||||
let op = builder.set("Transform".to_string(), ComponentData::Inline(vec![1, 2, 3]));
|
||||
let op = builder.set(
|
||||
"Transform".to_string(),
|
||||
ComponentData::Inline(vec![1, 2, 3]),
|
||||
);
|
||||
|
||||
assert!(op.is_set());
|
||||
assert_eq!(op.vector_clock().get(node_id), 1);
|
||||
|
||||
@@ -5,14 +5,20 @@
|
||||
//!
|
||||
//! ## OR-Set Semantics
|
||||
//!
|
||||
//! - **Add-wins**: If an element is concurrently added and removed, the add wins
|
||||
//! - **Observed-remove**: Removes only affect adds that have been observed (happened-before)
|
||||
//! - **Unique operation IDs**: Each add generates a unique ID to track add/remove pairs
|
||||
//! - **Add-wins**: If an element is concurrently added and removed, the add
|
||||
//! wins
|
||||
//! - **Observed-remove**: Removes only affect adds that have been observed
|
||||
//! (happened-before)
|
||||
//! - **Unique operation IDs**: Each add generates a unique ID to track
|
||||
//! add/remove pairs
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```
|
||||
//! use lib::networking::{OrSet, OrElement};
|
||||
//! use lib::networking::{
|
||||
//! OrElement,
|
||||
//! OrSet,
|
||||
//! };
|
||||
//! use uuid::Uuid;
|
||||
//!
|
||||
//! let node1 = Uuid::new_v4();
|
||||
@@ -157,11 +163,12 @@ where
|
||||
|
||||
/// Check if a value is present in the set
|
||||
///
|
||||
/// A value is present if it has at least one operation ID that's not tombstoned.
|
||||
/// A value is present if it has at least one operation ID that's not
|
||||
/// tombstoned.
|
||||
pub fn contains(&self, value: &T) -> bool {
|
||||
self.elements.iter().any(|(id, (v, _))| {
|
||||
v == value && !self.tombstones.contains(id)
|
||||
})
|
||||
self.elements
|
||||
.iter()
|
||||
.any(|(id, (v, _))| v == value && !self.tombstones.contains(id))
|
||||
}
|
||||
|
||||
/// Get all present values
|
||||
@@ -208,9 +215,7 @@ where
|
||||
let mut seen = HashSet::new();
|
||||
self.elements
|
||||
.iter()
|
||||
.filter(|(id, (value, _))| {
|
||||
!self.tombstones.contains(id) && seen.insert(value)
|
||||
})
|
||||
.filter(|(id, (value, _))| !self.tombstones.contains(id) && seen.insert(value))
|
||||
.count()
|
||||
}
|
||||
|
||||
@@ -249,7 +254,9 @@ where
|
||||
pub fn merge(&mut self, other: &OrSet<T>) {
|
||||
// Union elements
|
||||
for (id, (value, node)) in &other.elements {
|
||||
self.elements.entry(*id).or_insert_with(|| (value.clone(), *node));
|
||||
self.elements
|
||||
.entry(*id)
|
||||
.or_insert_with(|| (value.clone(), *node));
|
||||
}
|
||||
|
||||
// Union tombstones
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use bevy::prelude::*;
|
||||
//! use lib::networking::{NetworkingPlugin, NetworkingConfig};
|
||||
//! use lib::networking::{
|
||||
//! NetworkingConfig,
|
||||
//! NetworkingPlugin,
|
||||
//! };
|
||||
//! use uuid::Uuid;
|
||||
//!
|
||||
//! fn main() {
|
||||
@@ -28,28 +31,28 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::networking::{
|
||||
change_detection::{
|
||||
auto_detect_transform_changes_system,
|
||||
LastSyncVersions,
|
||||
auto_detect_transform_changes_system,
|
||||
},
|
||||
delta_generation::{
|
||||
generate_delta_system,
|
||||
NodeVectorClock,
|
||||
generate_delta_system,
|
||||
},
|
||||
entity_map::{
|
||||
NetworkEntityMap,
|
||||
cleanup_despawned_entities_system,
|
||||
register_networked_entities_system,
|
||||
NetworkEntityMap,
|
||||
},
|
||||
message_dispatcher::message_dispatcher_system,
|
||||
operation_log::{
|
||||
OperationLog,
|
||||
periodic_sync_system,
|
||||
prune_operation_log_system,
|
||||
OperationLog,
|
||||
},
|
||||
tombstones::{
|
||||
TombstoneRegistry,
|
||||
garbage_collect_tombstones_system,
|
||||
handle_local_deletions_system,
|
||||
TombstoneRegistry,
|
||||
},
|
||||
vector_clock::NodeId,
|
||||
};
|
||||
@@ -84,6 +87,51 @@ impl Default for NetworkingConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional session secret for authentication
|
||||
///
|
||||
/// This is a pre-shared secret that controls access to the gossip network.
|
||||
/// If configured, all joining nodes must provide the correct session secret
|
||||
/// to receive the full state.
|
||||
///
|
||||
/// # Security Model
|
||||
///
|
||||
/// The session secret provides network-level access control by:
|
||||
/// - Preventing unauthorized nodes from joining the gossip
|
||||
/// - Hash-based comparison prevents timing attacks
|
||||
/// - Works alongside iroh-gossip's built-in QUIC transport encryption
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// Insert this as a Bevy resource to enable session secret validation:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{
|
||||
/// NetworkingPlugin,
|
||||
/// SessionSecret,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// App::new()
|
||||
/// .add_plugins(NetworkingPlugin::default_with_node_id(Uuid::new_v4()))
|
||||
/// .insert_resource(SessionSecret::new(b"my_secret_key"))
|
||||
/// .run();
|
||||
/// ```
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct SessionSecret(Vec<u8>);
|
||||
|
||||
impl SessionSecret {
|
||||
/// Create a new session secret from bytes
|
||||
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
|
||||
Self(secret.into())
|
||||
}
|
||||
|
||||
/// Get the secret as a byte slice
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Bevy plugin for CRDT networking
|
||||
///
|
||||
/// This plugin sets up all systems and resources needed for distributed
|
||||
@@ -122,7 +170,10 @@ impl Default for NetworkingConfig {
|
||||
///
|
||||
/// ```no_run
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{NetworkingPlugin, NetworkingConfig};
|
||||
/// use lib::networking::{
|
||||
/// NetworkingConfig,
|
||||
/// NetworkingPlugin,
|
||||
/// };
|
||||
/// use uuid::Uuid;
|
||||
///
|
||||
/// App::new()
|
||||
|
||||
@@ -270,13 +270,9 @@ where
|
||||
pub fn values(&self) -> impl Iterator<Item = &T> {
|
||||
let ordered = self.get_ordered_elements();
|
||||
ordered.into_iter().filter_map(move |id| {
|
||||
self.elements.get(&id).and_then(|e| {
|
||||
if !e.is_deleted {
|
||||
Some(&e.value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
self.elements
|
||||
.get(&id)
|
||||
.and_then(|e| if !e.is_deleted { Some(&e.value) } else { None })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -344,9 +340,9 @@ where
|
||||
|
||||
/// Garbage collect tombstones
|
||||
///
|
||||
/// Removes deleted elements that have no children (nothing inserted after them).
|
||||
/// This is safe because if no element references a tombstone as its parent,
|
||||
/// it can be removed without affecting the sequence.
|
||||
/// Removes deleted elements that have no children (nothing inserted after
|
||||
/// them). This is safe because if no element references a tombstone as
|
||||
/// its parent, it can be removed without affecting the sequence.
|
||||
pub fn garbage_collect(&mut self) {
|
||||
// Find all IDs that are referenced as after_id
|
||||
let mut referenced_ids = std::collections::HashSet::new();
|
||||
@@ -357,9 +353,8 @@ where
|
||||
}
|
||||
|
||||
// Remove deleted elements that aren't referenced
|
||||
self.elements.retain(|id, element| {
|
||||
!element.is_deleted || referenced_ids.contains(id)
|
||||
});
|
||||
self.elements
|
||||
.retain(|id, element| !element.is_deleted || referenced_ids.contains(id));
|
||||
}
|
||||
|
||||
/// Get ordered list of element IDs
|
||||
@@ -385,12 +380,12 @@ where
|
||||
|
||||
// Compare vector clocks
|
||||
match elem_a.vector_clock.compare(&elem_b.vector_clock) {
|
||||
Ok(std::cmp::Ordering::Less) => std::cmp::Ordering::Less,
|
||||
Ok(std::cmp::Ordering::Greater) => std::cmp::Ordering::Greater,
|
||||
Ok(std::cmp::Ordering::Equal) | Err(_) => {
|
||||
| Ok(std::cmp::Ordering::Less) => std::cmp::Ordering::Less,
|
||||
| Ok(std::cmp::Ordering::Greater) => std::cmp::Ordering::Greater,
|
||||
| Ok(std::cmp::Ordering::Equal) | Err(_) => {
|
||||
// If clocks are equal or concurrent, use node ID as tiebreaker
|
||||
elem_a.inserting_node.cmp(&elem_b.inserting_node)
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -415,10 +410,7 @@ where
|
||||
/// Calculate the visible position of an element
|
||||
fn calculate_position(&self, element_id: uuid::Uuid) -> usize {
|
||||
let ordered = self.get_ordered_elements();
|
||||
ordered
|
||||
.iter()
|
||||
.position(|id| id == &element_id)
|
||||
.unwrap_or(0)
|
||||
ordered.iter().position(|id| id == &element_id).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,12 @@ pub enum ComponentMergeDecision {
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use bevy::prelude::*;
|
||||
/// use lib::networking::{SyncComponent, SyncStrategy, ClockComparison, ComponentMergeDecision};
|
||||
/// use lib::networking::{
|
||||
/// ClockComparison,
|
||||
/// ComponentMergeDecision,
|
||||
/// SyncComponent,
|
||||
/// SyncStrategy,
|
||||
/// };
|
||||
///
|
||||
/// // Example showing what the trait looks like - normally generated by #[derive(Synced)]
|
||||
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -77,7 +82,8 @@ pub trait SyncComponent: Component + Reflect + Sized {
|
||||
/// Merge remote state with local state
|
||||
///
|
||||
/// The merge logic is strategy-specific:
|
||||
/// - **LWW**: Takes newer value based on vector clock, uses tiebreaker for concurrent
|
||||
/// - **LWW**: Takes newer value based on vector clock, uses tiebreaker for
|
||||
/// concurrent
|
||||
/// - **Set**: Merges both sets (OR-Set semantics)
|
||||
/// - **Sequence**: Merges sequences preserving order (RGA semantics)
|
||||
/// - **Custom**: Calls user-defined ConflictResolver
|
||||
@@ -102,21 +108,22 @@ pub trait SyncComponent: Component + Reflect + Sized {
|
||||
/// use lib::networking::Synced;
|
||||
/// use sync_macros::Synced as SyncedDerive;
|
||||
///
|
||||
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize)]
|
||||
/// #[derive(SyncedDerive)]
|
||||
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize, SyncedDerive)]
|
||||
/// #[sync(version = 1, strategy = "LastWriteWins")]
|
||||
/// struct Health(f32);
|
||||
///
|
||||
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize)]
|
||||
/// #[derive(SyncedDerive)]
|
||||
/// #[derive(Component, Reflect, Clone, serde::Serialize, serde::Deserialize, SyncedDerive)]
|
||||
/// #[sync(version = 1, strategy = "LastWriteWins")]
|
||||
/// struct Position { x: f32, y: f32 }
|
||||
/// struct Position {
|
||||
/// x: f32,
|
||||
/// y: f32,
|
||||
/// }
|
||||
///
|
||||
/// let mut world = World::new();
|
||||
/// world.spawn((
|
||||
/// Health(100.0),
|
||||
/// Position { x: 0.0, y: 0.0 },
|
||||
/// Synced, // Marker enables sync
|
||||
/// Synced, // Marker enables sync
|
||||
/// ));
|
||||
/// ```
|
||||
#[derive(Component, Reflect, Default, Clone, Copy)]
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! ## Resurrection Prevention
|
||||
//!
|
||||
//! If a peer creates an entity (Set operation) while another peer deletes it:
|
||||
//! - Use vector clock comparison: if delete happened-after create, deletion wins
|
||||
//! - Use vector clock comparison: if delete happened-after create, deletion
|
||||
//! wins
|
||||
//! - If concurrent, deletion wins (delete bias for safety)
|
||||
//! - This prevents "zombie" entities from reappearing
|
||||
//!
|
||||
@@ -29,12 +30,12 @@ use std::collections::HashMap;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::networking::{
|
||||
GossipBridge,
|
||||
NodeVectorClock,
|
||||
vector_clock::{
|
||||
NodeId,
|
||||
VectorClock,
|
||||
},
|
||||
GossipBridge,
|
||||
NodeVectorClock,
|
||||
};
|
||||
|
||||
/// How long to keep tombstones before garbage collection (in seconds)
|
||||
@@ -134,7 +135,8 @@ impl TombstoneRegistry {
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - The entity has a tombstone AND
|
||||
/// - The operation's clock happened-before or is concurrent with the deletion
|
||||
/// - The operation's clock happened-before or is concurrent with the
|
||||
/// deletion
|
||||
///
|
||||
/// This prevents operations on deleted entities from being applied.
|
||||
pub fn should_ignore_operation(
|
||||
@@ -150,7 +152,8 @@ impl TombstoneRegistry {
|
||||
// deletion_clock.happened_before(operation_clock) => don't ignore
|
||||
|
||||
// If concurrent, deletion wins (delete bias) => ignore
|
||||
// !operation_clock.happened_before(deletion_clock) && !deletion_clock.happened_before(operation_clock) => ignore
|
||||
// !operation_clock.happened_before(deletion_clock) &&
|
||||
// !deletion_clock.happened_before(operation_clock) => ignore
|
||||
|
||||
// So we DON'T ignore only if deletion happened-before operation
|
||||
!tombstone.deletion_clock.happened_before(operation_clock)
|
||||
@@ -168,9 +171,8 @@ impl TombstoneRegistry {
|
||||
|
||||
let before_count = self.tombstones.len();
|
||||
|
||||
self.tombstones.retain(|_, tombstone| {
|
||||
now.duration_since(tombstone.timestamp) < ttl
|
||||
});
|
||||
self.tombstones
|
||||
.retain(|_, tombstone| now.duration_since(tombstone.timestamp) < ttl);
|
||||
|
||||
let after_count = self.tombstones.len();
|
||||
|
||||
@@ -254,14 +256,13 @@ pub fn handle_local_deletions_system(
|
||||
}
|
||||
|
||||
// Broadcast deletion
|
||||
let message = crate::networking::VersionedMessage::new(
|
||||
crate::networking::SyncMessage::EntityDelta {
|
||||
let message =
|
||||
crate::networking::VersionedMessage::new(crate::networking::SyncMessage::EntityDelta {
|
||||
entity_id: delta.entity_id,
|
||||
node_id: delta.node_id,
|
||||
vector_clock: delta.vector_clock.clone(),
|
||||
operations: delta.operations.clone(),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if let Err(e) = bridge.send(message) {
|
||||
error!("Failed to broadcast Delete operation: {}", e);
|
||||
|
||||
@@ -27,8 +27,8 @@ pub type NodeId = uuid::Uuid;
|
||||
/// # Causal Relationships
|
||||
///
|
||||
/// Given two vector clocks A and B:
|
||||
/// - **A happened-before B** if all of A's counters ≤ B's counters and at
|
||||
/// least one is <
|
||||
/// - **A happened-before B** if all of A's counters ≤ B's counters and at least
|
||||
/// one is <
|
||||
/// - **A and B are concurrent** if neither happened-before the other
|
||||
/// - **A and B are identical** if all counters are equal
|
||||
///
|
||||
@@ -42,16 +42,16 @@ pub type NodeId = uuid::Uuid;
|
||||
/// let node2 = Uuid::new_v4();
|
||||
///
|
||||
/// let mut clock1 = VectorClock::new();
|
||||
/// clock1.increment(node1); // node1: 1
|
||||
/// clock1.increment(node1); // node1: 1
|
||||
///
|
||||
/// let mut clock2 = VectorClock::new();
|
||||
/// clock2.increment(node2); // node2: 1
|
||||
/// clock2.increment(node2); // node2: 1
|
||||
///
|
||||
/// // These are concurrent - neither happened before the other
|
||||
/// assert!(clock1.is_concurrent_with(&clock2));
|
||||
///
|
||||
/// // Merge the clocks
|
||||
/// clock1.merge(&clock2); // node1: 1, node2: 1
|
||||
/// clock1.merge(&clock2); // node1: 1, node2: 1
|
||||
/// assert!(clock1.happened_before(&clock2) == false);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -116,11 +116,11 @@ impl VectorClock {
|
||||
/// let node2 = Uuid::new_v4();
|
||||
///
|
||||
/// let mut clock1 = VectorClock::new();
|
||||
/// clock1.increment(node1); // node1: 1
|
||||
/// clock1.increment(node1); // node1: 2
|
||||
/// clock1.increment(node1); // node1: 1
|
||||
/// clock1.increment(node1); // node1: 2
|
||||
///
|
||||
/// let mut clock2 = VectorClock::new();
|
||||
/// clock2.increment(node2); // node2: 1
|
||||
/// clock2.increment(node2); // node2: 1
|
||||
///
|
||||
/// clock1.merge(&clock2);
|
||||
/// assert_eq!(clock1.get(node1), 2);
|
||||
@@ -147,36 +147,36 @@ impl VectorClock {
|
||||
/// let node = Uuid::new_v4();
|
||||
///
|
||||
/// let mut clock1 = VectorClock::new();
|
||||
/// clock1.increment(node); // node: 1
|
||||
/// clock1.increment(node); // node: 1
|
||||
///
|
||||
/// let mut clock2 = VectorClock::new();
|
||||
/// clock2.increment(node); // node: 1
|
||||
/// clock2.increment(node); // node: 2
|
||||
/// clock2.increment(node); // node: 1
|
||||
/// clock2.increment(node); // node: 2
|
||||
///
|
||||
/// assert!(clock1.happened_before(&clock2));
|
||||
/// assert!(!clock2.happened_before(&clock1));
|
||||
/// ```
|
||||
pub fn happened_before(&self, other: &VectorClock) -> bool {
|
||||
// Check if all our counters are <= other's counters
|
||||
let all_less_or_equal = self.clocks.iter().all(|(node_id, &our_counter)| {
|
||||
let their_counter = other.get(*node_id);
|
||||
our_counter <= their_counter
|
||||
});
|
||||
// Single-pass optimization: check both conditions simultaneously
|
||||
let mut any_strictly_less = false;
|
||||
|
||||
if !all_less_or_equal {
|
||||
return false;
|
||||
// Check our nodes in a single pass
|
||||
for (node_id, &our_counter) in &self.clocks {
|
||||
let their_counter = other.get(*node_id);
|
||||
|
||||
// Early exit if we have a counter greater than theirs
|
||||
if our_counter > their_counter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Track if any counter is strictly less
|
||||
if our_counter < their_counter {
|
||||
any_strictly_less = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if at least one counter is strictly less
|
||||
// First check if any of our nodes has a lower counter
|
||||
let mut any_strictly_less = self.clocks.iter().any(|(node_id, &our_counter)| {
|
||||
let their_counter = other.get(*node_id);
|
||||
our_counter < their_counter
|
||||
});
|
||||
|
||||
// Also check if they have nodes we don't know about with non-zero values
|
||||
// For nodes not in self.clocks, we treat them as having counter 0
|
||||
// If other has a node with counter > 0 that we don't have, that counts as "strictly less"
|
||||
// If we haven't found a strictly less counter yet, check if they have
|
||||
// nodes we don't know about with non-zero values (those count as strictly less)
|
||||
if !any_strictly_less {
|
||||
any_strictly_less = other.clocks.iter().any(|(node_id, &their_counter)| {
|
||||
!self.clocks.contains_key(node_id) && their_counter > 0
|
||||
@@ -202,10 +202,10 @@ impl VectorClock {
|
||||
/// let node2 = Uuid::new_v4();
|
||||
///
|
||||
/// let mut clock1 = VectorClock::new();
|
||||
/// clock1.increment(node1); // node1: 1
|
||||
/// clock1.increment(node1); // node1: 1
|
||||
///
|
||||
/// let mut clock2 = VectorClock::new();
|
||||
/// clock2.increment(node2); // node2: 1
|
||||
/// clock2.increment(node2); // node2: 1
|
||||
///
|
||||
/// assert!(clock1.is_concurrent_with(&clock2));
|
||||
/// assert!(clock2.is_concurrent_with(&clock1));
|
||||
@@ -422,7 +422,10 @@ mod tests {
|
||||
clock2.increment(node);
|
||||
|
||||
assert_eq!(clock1.compare(&clock2).unwrap(), std::cmp::Ordering::Less);
|
||||
assert_eq!(clock2.compare(&clock1).unwrap(), std::cmp::Ordering::Greater);
|
||||
assert_eq!(
|
||||
clock2.compare(&clock1).unwrap(),
|
||||
std::cmp::Ordering::Greater
|
||||
);
|
||||
assert_eq!(clock1.compare(&clock1).unwrap(), std::cmp::Ordering::Equal);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user