Identity: C2a's StoredSchemaId/StoredVersion owns; main's PersistedSchemaId, PersistedIdentity and both MarkWritten are deleted. Stamp rule: ReadStore judges the stamp before the body. Flush over damage refuses (merge-r1-precedence). Mesh single-file writes route through SingleFileMessagePackBackingStore. TS: store-file.ts commits, C2a's header handling kept. Rust: C2a's readable_store_format in front of the grown decoder. Build not yet verified at this commit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
3243 lines
116 KiB
Rust
3243 lines
116 KiB
Rust
use anyhow::Result;
|
|
use chrono::Duration;
|
|
use chrono::Utc;
|
|
use cultcache_rs::CacheBackingStore;
|
|
use cultcache_rs::CultCache;
|
|
use cultcache_rs::DatabaseEntry;
|
|
use cultcache_rs::SingleFileMessagePackBackingStore;
|
|
use cultnet_rs::CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS;
|
|
use cultnet_rs::CultMesh;
|
|
use cultnet_rs::CultMeshAuthorityLease;
|
|
use cultnet_rs::CultMeshPeerCard;
|
|
use cultnet_rs::CultMeshRudpClientOptions;
|
|
use cultnet_rs::CultMeshRudpSocketOptions;
|
|
use cultnet_rs::CultNetClientSecurityOptions;
|
|
use cultnet_rs::CultNetDocumentBinding;
|
|
use cultnet_rs::CultNetDocumentMutationContract;
|
|
use cultnet_rs::CultNetDocumentOperation;
|
|
use cultnet_rs::CultNetDocumentPutOptions;
|
|
use cultnet_rs::CultNetDocumentRegistry;
|
|
use cultnet_rs::CultNetMessage;
|
|
use cultnet_rs::CultNetMutationAuthority;
|
|
use cultnet_rs::CultNetRawDocumentRecord;
|
|
use cultnet_rs::CultNetRawPayloadEncoding;
|
|
use cultnet_rs::CultNetReadOnlySnapshotPolicy;
|
|
use cultnet_rs::CultNetReactiveDocumentOptions;
|
|
use cultnet_rs::CultNetReconnectController;
|
|
use cultnet_rs::CultNetReconnectPolicyOptions;
|
|
use cultnet_rs::CultNetRudpPacket;
|
|
use cultnet_rs::CultNetRudpPacketType;
|
|
use cultnet_rs::CultNetRudpReconnectLoop;
|
|
use cultnet_rs::CultNetRudpSendOptions;
|
|
use cultnet_rs::CultNetRudpSession;
|
|
use cultnet_rs::CultNetRudpSessionOptions;
|
|
use cultnet_rs::CultNetRudpSocketMode;
|
|
use cultnet_rs::CultNetRudpSocketTransportConnection;
|
|
use cultnet_rs::CultNetRudpSocketTransportOptions;
|
|
use cultnet_rs::CultNetSchemaKind;
|
|
use cultnet_rs::CultNetSchemaRegistration;
|
|
use cultnet_rs::CultNetSchemaRegistry;
|
|
use cultnet_rs::CultNetSecret;
|
|
use cultnet_rs::CultNetServerSecurityOptions;
|
|
use cultnet_rs::CultNetShardCatalog;
|
|
use cultnet_rs::CultNetShardDescriptor;
|
|
use cultnet_rs::CultNetTransportChannel;
|
|
use cultnet_rs::CultNetTransportDelivery;
|
|
use cultnet_rs::CultNetTransportDescriptor;
|
|
use cultnet_rs::CultNetTransportFrame;
|
|
use cultnet_rs::CultNetTransportOrdering;
|
|
use cultnet_rs::CultNetTransportProfile;
|
|
use cultnet_rs::serve_read_only_raw_snapshot;
|
|
use cultnet_rs::CultNetTransportProtocol;
|
|
use cultnet_rs::CultNetWireContract;
|
|
use cultnet_rs::LengthPrefixedMessageFramer;
|
|
use cultnet_rs::TcpFramedTransportConnection;
|
|
use cultnet_rs::TcpFramedTransportProfileOptions;
|
|
use cultnet_rs::builtin_schema_registry;
|
|
use cultnet_rs::compute_reconnect_delay_ms;
|
|
use cultnet_rs::create_reconnect_policy;
|
|
use cultnet_rs::create_rudp_transport_profile;
|
|
use cultnet_rs::create_tcp_framed_transport_profile;
|
|
use cultnet_rs::decode_cultnet_message_from_slice;
|
|
use cultnet_rs::decode_rudp_packet;
|
|
use cultnet_rs::encode_cultnet_message_for_wire;
|
|
use cultnet_rs::encode_cultnet_message_to_vec;
|
|
use cultnet_rs::encode_frame;
|
|
use cultnet_rs::encode_rudp_packet;
|
|
use pretty_assertions::assert_eq;
|
|
use std::cell::RefCell;
|
|
use std::collections::VecDeque;
|
|
use std::net::UdpSocket;
|
|
use std::rc::Rc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread;
|
|
use std::time::Duration as StdDuration;
|
|
use std::time::Instant as StdInstant;
|
|
|
|
const TS_HELLO_FRAME: &[u8] = include_bytes!("fixtures/cultnet-ts-hello.frame");
|
|
const TS_LEGACY_LOGIN_FRAME: &[u8] = include_bytes!("fixtures/cultnet-ts-legacy-login.frame");
|
|
|
|
fn bind_udp_socket() -> Result<UdpSocket> {
|
|
let socket = UdpSocket::bind("127.0.0.1:0")?;
|
|
socket.set_read_timeout(Some(StdDuration::from_millis(20)))?;
|
|
Ok(socket)
|
|
}
|
|
|
|
fn pump_rudp_handshake(
|
|
client: &mut CultNetRudpSocketTransportConnection,
|
|
server: &mut CultNetRudpSocketTransportConnection,
|
|
) -> Result<()> {
|
|
for _ in 0..20 {
|
|
let _ = server.receive_once()?;
|
|
let _ = client.receive_once()?;
|
|
let _ = server.receive_once()?;
|
|
if client.connected() && server.connected() {
|
|
return Ok(());
|
|
}
|
|
thread::sleep(StdDuration::from_millis(5));
|
|
}
|
|
anyhow::bail!("RUDP socket handshake did not complete");
|
|
}
|
|
|
|
fn receive_rudp_frame(
|
|
transport: &mut CultNetRudpSocketTransportConnection,
|
|
) -> Result<CultNetTransportFrame> {
|
|
for _ in 0..20 {
|
|
if let Some(frame) = transport.receive_once()? {
|
|
return Ok(frame);
|
|
}
|
|
thread::sleep(StdDuration::from_millis(5));
|
|
}
|
|
anyhow::bail!("RUDP socket frame was not delivered")
|
|
}
|
|
|
|
fn receive_rudp_schema_message(
|
|
transport: &mut CultNetRudpSocketTransportConnection,
|
|
) -> Result<CultNetMessage> {
|
|
for _ in 0..20 {
|
|
if let Some(message) = transport.receive_schema_message_once()? {
|
|
return Ok(message);
|
|
}
|
|
thread::sleep(StdDuration::from_millis(5));
|
|
}
|
|
anyhow::bail!("RUDP schema message was not delivered")
|
|
}
|
|
|
|
fn pump_rudp_server_until_connected(
|
|
server: Arc<Mutex<CultNetRudpSocketTransportConnection>>,
|
|
done: Arc<AtomicBool>,
|
|
) -> thread::JoinHandle<Result<()>> {
|
|
thread::spawn(move || {
|
|
while !done.load(Ordering::SeqCst) {
|
|
{
|
|
let mut server = server.lock().expect("RUDP server mutex poisoned");
|
|
let _ = server.receive_once()?;
|
|
server.poll_resends()?;
|
|
if server.connected() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
thread::sleep(StdDuration::from_millis(5));
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
|
#[cultcache(
|
|
type = "ghostlight.agent-state",
|
|
schema = "GhostlightAgentStateFixture"
|
|
)]
|
|
struct GhostlightAgentStateFixture {
|
|
#[cultcache(key = 0)]
|
|
schema_version: String,
|
|
#[cultcache(key = 1)]
|
|
agent_id: String,
|
|
#[cultcache(key = 2)]
|
|
display_name: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
|
#[cultcache(
|
|
type = "ghostlight.agent-state.ui",
|
|
schema = "GhostlightAgentStateUiFixture"
|
|
)]
|
|
struct GhostlightAgentStateUiFixture {
|
|
#[cultcache(key = 0)]
|
|
schema_version: String,
|
|
#[cultcache(key = 1)]
|
|
agent_id: String,
|
|
#[cultcache(key = 2)]
|
|
display_name: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
|
#[cultcache(
|
|
type = "ghostlight.reactive-note",
|
|
schema = "GhostlightReactiveNoteFixture"
|
|
)]
|
|
struct GhostlightReactiveNoteFixture {
|
|
#[cultcache(key = 0)]
|
|
body: String,
|
|
#[cultcache(key = 1)]
|
|
revision: i32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
|
#[cultcache(
|
|
type = "ghostlight.reactive-note.ui",
|
|
schema = "GhostlightReactiveNoteUiFixture"
|
|
)]
|
|
struct GhostlightReactiveNoteUiFixture {
|
|
#[cultcache(key = 0)]
|
|
body: String,
|
|
#[cultcache(key = 1)]
|
|
revision: i32,
|
|
}
|
|
|
|
#[test]
|
|
fn security_helpers_round_trip_encrypted_strings_and_validate_sessions() -> Result<()> {
|
|
let server_security = CultNetServerSecurityOptions::development();
|
|
let client_security = server_security.to_client_options();
|
|
let nonce = CultNetSecret::new_nonce();
|
|
let encrypted = CultNetSecret::encrypt_string(Some("hello"), &nonce, &client_security)?
|
|
.expect("non-empty input encrypts");
|
|
assert_eq!(
|
|
CultNetSecret::decrypt_string(Some(&encrypted), Some(&nonce), &server_security)?,
|
|
Some("hello".to_string())
|
|
);
|
|
|
|
let token = CultNetSecret::create_session_token(
|
|
"runtime-face",
|
|
chrono::Utc::now() + Duration::minutes(1),
|
|
&server_security,
|
|
)?;
|
|
let validated = CultNetSecret::try_validate_session_token(Some(&token), &server_security)?
|
|
.expect("token validates before expiry");
|
|
assert_eq!(validated.user_id, "runtime-face");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cultnet_schema_messages_round_trip_through_messagepack_frames() -> Result<()> {
|
|
let message = CultNetMessage::Hello {
|
|
runtime_id: "voidbot-main".to_string(),
|
|
runtime_kind: "rust-worker".to_string(),
|
|
agent_id: Some("void".to_string()),
|
|
role: None,
|
|
display_name: Some("Void".to_string()),
|
|
supported_document_types: Some(vec!["ghostlight.agent-state".to_string()]),
|
|
supported_mutation_contracts: Some(vec![CultNetDocumentMutationContract {
|
|
document_type: "ghostlight.agent-state".to_string(),
|
|
payload_schema_version: Some("ghostlight.agent_state.v0".to_string()),
|
|
operations: vec![
|
|
CultNetDocumentOperation::Snapshot,
|
|
CultNetDocumentOperation::DocumentPut,
|
|
],
|
|
authority: CultNetMutationAuthority::Coordinator,
|
|
intent_document_types: Some(vec!["ghostlight.agent-state.intent.v0".to_string()]),
|
|
receipt_document_types: Some(vec!["ghostlight.agent-state.receipt.v0".to_string()]),
|
|
notes: Some(vec![
|
|
"Persona may request bounded memory mutation; coordinator reviews authority."
|
|
.to_string(),
|
|
]),
|
|
}]),
|
|
supported_message_versions: None,
|
|
transport_profiles: Some(vec![CultNetTransportProfile {
|
|
schema_version: "cultnet.transport_profile.v0".to_string(),
|
|
runtime_id: "voidbot-main".to_string(),
|
|
transports: vec![CultNetTransportDescriptor {
|
|
transport_id: "direct-pipe".to_string(),
|
|
protocol: CultNetTransportProtocol::TcpFramed,
|
|
host: None,
|
|
port: None,
|
|
path: None,
|
|
discovery_group: None,
|
|
wire_contracts: Some(vec!["cultnet.schema.v0".to_string()]),
|
|
reconnect_policy: None,
|
|
channels: vec![CultNetTransportChannel {
|
|
channel_id: "schema".to_string(),
|
|
delivery: CultNetTransportDelivery::Reliable,
|
|
ordering: CultNetTransportOrdering::Ordered,
|
|
max_payload_bytes: None,
|
|
max_fragment_bytes: None,
|
|
max_pending_reliable_packets: None,
|
|
reliable_expire_after_ms: None,
|
|
}],
|
|
}],
|
|
}]),
|
|
supports_schema_catalog: Some(true),
|
|
};
|
|
let payload = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?;
|
|
let frame = encode_frame(&payload)?;
|
|
assert_eq!(&frame[..4], &(payload.len() as u32).to_be_bytes());
|
|
|
|
let mut framer = LengthPrefixedMessageFramer::new();
|
|
assert!(framer.push(&frame[..2]).is_empty());
|
|
let frames = framer.push(&frame[2..]);
|
|
assert_eq!(frames.len(), 1);
|
|
let decoded =
|
|
decode_cultnet_message_from_slice(&frames[0], CultNetWireContract::CultNetSchemaV0)?;
|
|
assert_eq!(decoded, message);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn tcp_framed_transport_carries_schema_payloads_with_stats() -> Result<()> {
|
|
let payload = b"cultnet-payload".to_vec();
|
|
let profile = create_tcp_framed_transport_profile(
|
|
"rust-transport",
|
|
TcpFramedTransportProfileOptions {
|
|
transport_id: Some("test-tcp".to_string()),
|
|
..TcpFramedTransportProfileOptions::default()
|
|
},
|
|
);
|
|
let mut sender = TcpFramedTransportConnection::new(Vec::<u8>::new(), profile.clone());
|
|
sender.send("schema", &payload)?;
|
|
assert_eq!(sender.stats().frames_sent, 1);
|
|
assert_eq!(sender.stats().bytes_sent, (payload.len() + 4) as u64);
|
|
assert!(sender.send("unreliable", &[]).is_err());
|
|
|
|
let bytes = sender.into_inner();
|
|
let mut receiver = TcpFramedTransportConnection::new(std::io::Cursor::new(bytes), profile);
|
|
let frame = receiver.receive()?;
|
|
assert_eq!(frame.channel_id, "schema");
|
|
assert_eq!(frame.payload, payload);
|
|
assert_eq!(receiver.stats().frames_received, 1);
|
|
assert_eq!(
|
|
receiver.stats().bytes_received,
|
|
(frame.payload.len() + 4) as u64
|
|
);
|
|
assert_eq!(
|
|
receiver.profile.transports[0].protocol,
|
|
CultNetTransportProtocol::TcpFramed
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_packet_codec_uses_deterministic_reliable_ordered_fixture() -> Result<()> {
|
|
let encoded = encode_rudp_packet(&CultNetRudpPacket {
|
|
packet_type: CultNetRudpPacketType::Data,
|
|
connection_id: 0x01020304,
|
|
sequence: 0x0000002a,
|
|
ack: 0x00000029,
|
|
ack_mask: 0x80000001,
|
|
channel_id: "schema".to_string(),
|
|
reliable: true,
|
|
ordered: true,
|
|
sequenced: false,
|
|
fragment_id: 7,
|
|
fragment_index: 1,
|
|
fragment_count: 3,
|
|
payload: b"hello".to_vec(),
|
|
})?;
|
|
|
|
assert_eq!(
|
|
encoded,
|
|
vec![
|
|
67, 78, 82, 48, 0, 3, 11, 42, 1, 2, 3, 4, 0, 0, 0, 42, 0, 0, 0, 41, 128, 0, 0, 1, 0, 7,
|
|
0, 1, 0, 3, 0, 0, 0, 5, 6, 0, 115, 99, 104, 101, 109, 97, 104, 101, 108, 108, 111,
|
|
]
|
|
);
|
|
|
|
let decoded = decode_rudp_packet(&encoded)?;
|
|
assert_eq!(decoded.packet_type, CultNetRudpPacketType::Data);
|
|
assert_eq!(decoded.connection_id, 0x01020304);
|
|
assert_eq!(decoded.sequence, 0x0000002a);
|
|
assert_eq!(decoded.ack, 0x00000029);
|
|
assert_eq!(decoded.ack_mask, 0x80000001);
|
|
assert_eq!(decoded.channel_id, "schema");
|
|
assert!(decoded.reliable);
|
|
assert!(decoded.ordered);
|
|
assert!(!decoded.sequenced);
|
|
assert_eq!(decoded.fragment_id, 7);
|
|
assert_eq!(decoded.fragment_index, 1);
|
|
assert_eq!(decoded.fragment_count, 3);
|
|
assert_eq!(decoded.payload, b"hello");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_transport_profile_advertises_state_and_realtime_channels() {
|
|
let profile = create_rudp_transport_profile(
|
|
"rust-rudp",
|
|
cultnet_rs::RudpTransportProfileOptions {
|
|
media_delivery: None,
|
|
transport_id: Some("public-rudp".to_string()),
|
|
host: Some("127.0.0.1".to_string()),
|
|
port: Some(7777),
|
|
max_payload_bytes: Some(1200),
|
|
max_fragment_bytes: Some(1000),
|
|
max_pending_reliable_packets: Some(64),
|
|
reconnect_policy: None,
|
|
media_reliable_expire_after_ms: None,
|
|
},
|
|
);
|
|
|
|
assert_eq!(
|
|
profile.transports[0].protocol,
|
|
CultNetTransportProtocol::Rudp
|
|
);
|
|
assert_eq!(
|
|
profile.transports[0]
|
|
.reconnect_policy
|
|
.as_ref()
|
|
.map(|policy| policy.schema_version.as_str()),
|
|
Some("cultnet.reconnect_policy.v0")
|
|
);
|
|
assert_eq!(
|
|
profile.transports[0]
|
|
.reconnect_policy
|
|
.as_ref()
|
|
.map(|policy| policy.base_delay_ms),
|
|
Some(1_000)
|
|
);
|
|
let channels = profile.transports[0]
|
|
.channels
|
|
.iter()
|
|
.map(|channel| {
|
|
(
|
|
channel.channel_id.as_str(),
|
|
channel.delivery,
|
|
channel.ordering,
|
|
channel.max_pending_reliable_packets,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
channels,
|
|
vec![
|
|
(
|
|
"schema",
|
|
CultNetTransportDelivery::Reliable,
|
|
CultNetTransportOrdering::Ordered,
|
|
Some(64)
|
|
),
|
|
(
|
|
"latest",
|
|
CultNetTransportDelivery::Unreliable,
|
|
CultNetTransportOrdering::Sequenced,
|
|
Some(64)
|
|
),
|
|
(
|
|
"realtime",
|
|
CultNetTransportDelivery::Unreliable,
|
|
CultNetTransportOrdering::Unordered,
|
|
Some(64)
|
|
),
|
|
(
|
|
"media",
|
|
CultNetTransportDelivery::Reliable,
|
|
CultNetTransportOrdering::Unordered,
|
|
Some(64)
|
|
),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnect_policy_exposes_portable_delay_contract() {
|
|
let policy = create_reconnect_policy(CultNetReconnectPolicyOptions {
|
|
policy_id: "rudp-default".to_string(),
|
|
max_attempts: Some(8),
|
|
..CultNetReconnectPolicyOptions::default()
|
|
});
|
|
|
|
assert_eq!(policy.schema_version, "cultnet.reconnect_policy.v0");
|
|
assert_eq!(policy.policy_id, "rudp-default");
|
|
assert_eq!(policy.max_attempts, Some(8));
|
|
assert_eq!(compute_reconnect_delay_ms(&policy, 1, 0), 1_000);
|
|
assert_eq!(compute_reconnect_delay_ms(&policy, 3, 17), 4_017);
|
|
assert_eq!(compute_reconnect_delay_ms(&policy, 9, 999), 30_250);
|
|
assert_eq!(compute_reconnect_delay_ms(&policy, 0, 0), 1_000);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnect_controller_schedules_attempts_and_reset() {
|
|
let policy = create_reconnect_policy(CultNetReconnectPolicyOptions {
|
|
max_attempts: Some(2),
|
|
..CultNetReconnectPolicyOptions::default()
|
|
});
|
|
let mut controller = CultNetReconnectController::new(policy);
|
|
|
|
let first = controller.record_failure(10_000, 0);
|
|
assert_eq!(first.attempt, 1);
|
|
assert!(first.should_retry);
|
|
assert_eq!(first.delay_ms, 1_000);
|
|
assert_eq!(first.next_attempt_at_ms, Some(11_000));
|
|
assert!(!first.exhausted);
|
|
assert!(!controller.can_attempt(10_999));
|
|
assert!(controller.can_attempt(11_000));
|
|
|
|
let second = controller.record_failure(11_000, 17);
|
|
assert_eq!(second.attempt, 2);
|
|
assert_eq!(second.delay_ms, 2_017);
|
|
assert_eq!(second.next_attempt_at_ms, Some(13_017));
|
|
assert!(second.should_retry);
|
|
|
|
let exhausted = controller.record_failure(13_017, 0);
|
|
assert_eq!(exhausted.attempt, 2);
|
|
assert!(!exhausted.should_retry);
|
|
assert_eq!(exhausted.delay_ms, 0);
|
|
assert_eq!(exhausted.next_attempt_at_ms, None);
|
|
assert!(exhausted.exhausted);
|
|
assert!(!controller.can_attempt(99_000));
|
|
|
|
controller.reset();
|
|
assert_eq!(controller.attempt(), 0);
|
|
assert_eq!(controller.next_attempt_at_ms(), None);
|
|
assert!(!controller.exhausted());
|
|
assert!(controller.can_attempt(99_000));
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_reconnect_loop_consumes_shared_controller() -> Result<()> {
|
|
let server_socket = bind_udp_socket()?;
|
|
let remote_addr = server_socket.local_addr()?;
|
|
let opened_local_addrs = Rc::new(RefCell::new(Vec::new()));
|
|
let opened_for_factory = Rc::clone(&opened_local_addrs);
|
|
let connection_id = 0x2233_4455;
|
|
let policy = create_reconnect_policy(CultNetReconnectPolicyOptions {
|
|
max_attempts: Some(2),
|
|
..CultNetReconnectPolicyOptions::default()
|
|
});
|
|
|
|
let mut loop_ = CultNetRudpReconnectLoop::new(policy, b"join".to_vec(), move || {
|
|
let socket = bind_udp_socket()?;
|
|
opened_for_factory.borrow_mut().push(socket.local_addr()?);
|
|
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client(
|
|
"rust-rudp-reconnect",
|
|
socket,
|
|
remote_addr,
|
|
connection_id,
|
|
))
|
|
});
|
|
|
|
{
|
|
let first = loop_.start()?;
|
|
assert!(first.stats().bytes_sent > 0);
|
|
}
|
|
assert!(loop_.transport().is_some());
|
|
assert_eq!(opened_local_addrs.borrow().len(), 1);
|
|
|
|
let decision = loop_.handle_closed(10_000, 17).expect("retry decision");
|
|
assert_eq!(decision.attempt, 1);
|
|
assert!(decision.should_retry);
|
|
assert_eq!(decision.delay_ms, 1_017);
|
|
assert_eq!(decision.next_attempt_at_ms, Some(11_017));
|
|
assert_eq!(loop_.reconnect_controller.attempt(), 1);
|
|
assert_eq!(
|
|
loop_.reconnect_controller.next_attempt_at_ms(),
|
|
Some(11_017)
|
|
);
|
|
|
|
assert!(!loop_.reconnect_if_due(11_016)?);
|
|
assert_eq!(opened_local_addrs.borrow().len(), 1);
|
|
assert!(loop_.reconnect_if_due(11_017)?);
|
|
assert_eq!(opened_local_addrs.borrow().len(), 2);
|
|
assert!(loop_.transport().is_some());
|
|
|
|
loop_.mark_connected();
|
|
assert_eq!(loop_.reconnect_controller.attempt(), 0);
|
|
|
|
loop_.stop();
|
|
assert!(loop_.transport().is_none());
|
|
assert_eq!(loop_.reconnect_controller.attempt(), 0);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_handshake_acks_reliable_connect_and_accept_packets() -> Result<()> {
|
|
let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 0x0a0b0c0d,
|
|
initial_sequence: Some(1),
|
|
resend_delay_ms: 50,
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 0x0a0b0c0d,
|
|
initial_sequence: Some(100),
|
|
resend_delay_ms: 50,
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
|
|
let connect = client.create_connect(0, b"join".to_vec())?;
|
|
assert_eq!(connect.packet_type, CultNetRudpPacketType::Connect);
|
|
assert_eq!(connect.sequence, 1);
|
|
assert_eq!(client.pending_reliable_sequences(), vec![1]);
|
|
|
|
let accept = server.accept_connect(&connect, 10, b"ok".to_vec())?;
|
|
assert_eq!(accept.packet_type, CultNetRudpPacketType::Accept);
|
|
assert_eq!(accept.ack, 1);
|
|
assert!(server.connected());
|
|
assert_eq!(server.pending_reliable_sequences(), vec![100]);
|
|
|
|
client.receive(&accept, 20)?;
|
|
assert!(client.connected());
|
|
assert!(client.pending_reliable_sequences().is_empty());
|
|
|
|
let ack = client.create_ack();
|
|
assert_eq!(ack.ack, 100);
|
|
server.receive(&ack, 30)?;
|
|
assert!(server.pending_reliable_sequences().is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_computes_ack_masks_and_clears_pending_reliable_packets() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 7,
|
|
initial_sequence: Some(10),
|
|
resend_delay_ms: 100,
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 7,
|
|
initial_sequence: Some(200),
|
|
resend_delay_ms: 100,
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 0, Vec::new())?;
|
|
sender.receive(&accept, 0)?;
|
|
|
|
let options = CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
};
|
|
let first = sender.send("schema", b"first".to_vec(), options.clone())?;
|
|
let second = sender.send("schema", b"second".to_vec(), options.clone())?;
|
|
let third = sender.send("schema", b"third".to_vec(), options)?;
|
|
assert_eq!(sender.pending_reliable_sequences(), vec![11, 12, 13]);
|
|
|
|
receiver.receive(&first, 0)?;
|
|
receiver.receive(&third, 0)?;
|
|
let ack_with_gap = receiver.create_ack();
|
|
assert_eq!(ack_with_gap.ack, 13);
|
|
assert_eq!(ack_with_gap.ack_mask, 0b110);
|
|
sender.receive(&ack_with_gap, 0)?;
|
|
assert_eq!(sender.pending_reliable_sequences(), vec![12]);
|
|
|
|
receiver.receive(&second, 0)?;
|
|
let full_ack = receiver.create_ack();
|
|
assert_eq!(full_ack.ack, 13);
|
|
assert_eq!(full_ack.ack_mask, 0b111);
|
|
sender.receive(&full_ack, 0)?;
|
|
assert!(sender.pending_reliable_sequences().is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_schedules_reliable_resends_until_acked() -> Result<()> {
|
|
let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 99,
|
|
initial_sequence: Some(1),
|
|
resend_delay_ms: 100,
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
session.assume_connected(0);
|
|
let sent = session.send(
|
|
"schema",
|
|
b"payload".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
now_ms: 10,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
|
|
assert!(session.due_resends(90).is_empty());
|
|
assert_eq!(
|
|
session
|
|
.due_resends(110)
|
|
.iter()
|
|
.map(|packet| packet.sequence)
|
|
.collect::<Vec<_>>(),
|
|
vec![sent.sequence]
|
|
);
|
|
assert!(session.due_resends(150).is_empty());
|
|
|
|
session.receive(
|
|
&CultNetRudpPacket {
|
|
packet_type: CultNetRudpPacketType::Ack,
|
|
connection_id: 99,
|
|
sequence: 51,
|
|
ack: sent.sequence,
|
|
ack_mask: 0,
|
|
channel_id: "control".to_string(),
|
|
reliable: false,
|
|
ordered: false,
|
|
sequenced: false,
|
|
fragment_id: 0,
|
|
fragment_index: 0,
|
|
fragment_count: 0,
|
|
payload: Vec::new(),
|
|
},
|
|
0,
|
|
)?;
|
|
assert!(session.due_resends(250).is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_pings_and_detects_receive_timeout() -> Result<()> {
|
|
let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 101,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 101,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = client.create_connect(0, b"join".to_vec())?;
|
|
let accept = server.accept_connect(&connect, 10, Vec::new())?;
|
|
client.receive(&accept, 20)?;
|
|
|
|
let ping = client.create_ping(b"pulse".to_vec());
|
|
let ping_result = server.receive(&ping, 30)?;
|
|
let pong = ping_result.reply.expect("ping should produce pong");
|
|
assert_eq!(pong.packet_type, CultNetRudpPacketType::Pong);
|
|
assert_eq!(pong.payload, b"pulse");
|
|
|
|
let pong_result = client.receive(&pong, 40)?;
|
|
assert!(pong_result.pong);
|
|
assert_eq!(pong_result.pong_payload, b"pulse");
|
|
assert!(!client.check_timeout(90, 50));
|
|
assert!(client.check_timeout(91, 50));
|
|
assert!(!client.connected());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_lossy_packets_cannot_create_reliable_ordered_gaps() -> Result<()> {
|
|
let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 198,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 198,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = client.create_connect(0, Vec::new())?;
|
|
let accept = server.accept_connect(&connect, 1, Vec::new())?;
|
|
client.receive(&accept, 2)?;
|
|
|
|
let ping = client.create_ping(b"pulse".to_vec());
|
|
let pong = server
|
|
.receive(&ping, 3)?
|
|
.reply
|
|
.expect("ping should produce pong");
|
|
let realtime = server.send(
|
|
"realtime",
|
|
b"discarded realtime".to_vec(),
|
|
CultNetRudpSendOptions::default(),
|
|
)?;
|
|
let latest = server.send(
|
|
"latest",
|
|
b"discarded latest state".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
sequenced: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
let schema = server.send(
|
|
"schema",
|
|
b"committed response".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
|
|
assert_eq!(ping.sequence, 0);
|
|
assert_eq!(pong.sequence, 0);
|
|
assert_eq!(realtime.sequence, 0);
|
|
assert_eq!(latest.sequence, 1);
|
|
assert_eq!(schema.sequence, accept.sequence + 1);
|
|
assert_eq!(
|
|
client.receive(&schema, 4)?.delivered[0].payload,
|
|
b"committed response"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_unreliable_sequenced_delivery_is_scoped_to_its_channel() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 197,
|
|
initial_sequence: Some(50),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 197,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 1, Vec::new())?;
|
|
sender.receive(&accept, 2)?;
|
|
let options = CultNetRudpSendOptions {
|
|
sequenced: true,
|
|
..CultNetRudpSendOptions::default()
|
|
};
|
|
let older = sender.send("latest", b"older".to_vec(), options.clone())?;
|
|
let newer = sender.send("latest", b"newer".to_vec(), options)?;
|
|
|
|
assert_eq!(older.sequence, 1);
|
|
assert_eq!(newer.sequence, 2);
|
|
assert_eq!(receiver.receive(&newer, 3)?.delivered[0].payload, b"newer");
|
|
assert!(receiver.receive(&older, 4)?.delivered.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_rejects_unreliable_ordered_delivery() -> Result<()> {
|
|
let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 196,
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
session.assume_connected(0);
|
|
let error = session
|
|
.send(
|
|
"schema",
|
|
b"cannot order what will not retransmit".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("ordered delivery requires reliability")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_bounds_pending_reliable_packets_before_enqueue() -> Result<()> {
|
|
let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 102,
|
|
initial_sequence: Some(1),
|
|
max_pending_reliable_packets: Some(2),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
session.assume_connected(0);
|
|
session.send(
|
|
"schema",
|
|
b"first".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
session.send(
|
|
"schema",
|
|
b"second".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
let error = session
|
|
.send(
|
|
"schema",
|
|
b"third".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(error.to_string().contains("reliable send queue is full"));
|
|
assert_eq!(session.pending_reliable_sequences(), vec![1, 2]);
|
|
|
|
let mut fragmented = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 103,
|
|
initial_sequence: Some(1),
|
|
max_pending_reliable_packets: Some(3),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
fragmented.assume_connected(0);
|
|
let error = fragmented
|
|
.send_many(
|
|
"schema",
|
|
b"fragment-me".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
Some(3),
|
|
)
|
|
.unwrap_err();
|
|
assert!(error.to_string().contains("reliable send queue is full"));
|
|
assert!(fragmented.pending_reliable_sequences().is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads_in_sequence()
|
|
-> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 123,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 123,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 0, Vec::new())?;
|
|
sender.receive(&accept, 0)?;
|
|
|
|
let options = CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
};
|
|
let first = sender.send("schema", b"first".to_vec(), options.clone())?;
|
|
let second = sender.send("schema", b"second".to_vec(), options.clone())?;
|
|
let third = sender.send("schema", b"third".to_vec(), options)?;
|
|
|
|
assert_eq!(
|
|
receiver
|
|
.receive(&first, 0)?
|
|
.delivered
|
|
.iter()
|
|
.map(|frame| String::from_utf8(frame.payload.clone()).unwrap())
|
|
.collect::<Vec<_>>(),
|
|
vec!["first"]
|
|
);
|
|
assert!(receiver.receive(&third, 0)?.delivered.is_empty());
|
|
assert!(receiver.receive(&first, 0)?.delivered.is_empty());
|
|
assert_eq!(
|
|
receiver
|
|
.receive(&second, 0)?
|
|
.delivered
|
|
.iter()
|
|
.map(|frame| String::from_utf8(frame.payload.clone()).unwrap())
|
|
.collect::<Vec<_>>(),
|
|
vec!["second", "third"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_skips_control_packets_while_ordering_schema_payloads() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 124,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 124,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 0, Vec::new())?;
|
|
sender.receive(&accept, 0)?;
|
|
|
|
let options = CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
};
|
|
let first = sender.send("schema", b"first".to_vec(), options.clone())?;
|
|
let control = sender.create_ack();
|
|
let second = sender.send("schema", b"second".to_vec(), options)?;
|
|
assert_eq!(control.sequence, 0);
|
|
assert_eq!(second.sequence, first.sequence + 1);
|
|
|
|
assert_eq!(
|
|
receiver
|
|
.receive(&first, 0)?
|
|
.delivered
|
|
.iter()
|
|
.map(|frame| String::from_utf8(frame.payload.clone()).unwrap())
|
|
.collect::<Vec<_>>(),
|
|
vec!["first"]
|
|
);
|
|
assert!(receiver.receive(&control, 0)?.delivered.is_empty());
|
|
assert_eq!(
|
|
receiver
|
|
.receive(&second, 0)?
|
|
.delivered
|
|
.iter()
|
|
.map(|frame| String::from_utf8(frame.payload.clone()).unwrap())
|
|
.collect::<Vec<_>>(),
|
|
vec!["second"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_fragments_and_reassembles_reliable_ordered_payloads() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 456,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 456,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 0, Vec::new())?;
|
|
sender.receive(&accept, 0)?;
|
|
|
|
let packets = sender.send_many(
|
|
"schema",
|
|
b"fragment-me-please".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
now_ms: 10,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
Some(5),
|
|
)?;
|
|
assert_eq!(packets.len(), 4);
|
|
assert_eq!(
|
|
packets
|
|
.iter()
|
|
.map(|packet| packet.fragment_count)
|
|
.collect::<Vec<_>>(),
|
|
vec![4, 4, 4, 4]
|
|
);
|
|
assert_eq!(
|
|
packets
|
|
.iter()
|
|
.map(|packet| packet.fragment_index)
|
|
.collect::<Vec<_>>(),
|
|
vec![0, 1, 2, 3]
|
|
);
|
|
assert!(
|
|
packets
|
|
.iter()
|
|
.all(|packet| packet.fragment_id == packets[0].fragment_id)
|
|
);
|
|
|
|
assert!(receiver.receive(&packets[0], 0)?.delivered.is_empty());
|
|
assert!(receiver.receive(&packets[1], 0)?.delivered.is_empty());
|
|
assert!(receiver.receive(&packets[2], 0)?.delivered.is_empty());
|
|
let delivered = receiver.receive(&packets[3], 0)?.delivered;
|
|
assert_eq!(delivered.len(), 1);
|
|
assert_eq!(delivered[0].payload, b"fragment-me-please");
|
|
assert_eq!(delivered[0].sequence, packets[0].sequence);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames() -> Result<()> {
|
|
let server_socket = bind_udp_socket()?;
|
|
let client_socket = bind_udp_socket()?;
|
|
let server_addr = server_socket.local_addr()?;
|
|
let connection_id = 0x1020_3040;
|
|
let mut server =
|
|
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
|
media_delivery: None,
|
|
runtime_id: "rust-rudp-server".to_string(),
|
|
socket: server_socket,
|
|
mode: CultNetRudpSocketMode::Server,
|
|
remote_addr: None,
|
|
connection_id,
|
|
initial_sequence: Some(100),
|
|
resend_delay_ms: 25,
|
|
transport_id: None,
|
|
max_payload_bytes: None,
|
|
max_fragment_bytes: None,
|
|
max_pending_reliable_packets: None,
|
|
reconnect_policy: None,
|
|
media_reliable_expire_after_ms: None,
|
|
})?;
|
|
let mut client =
|
|
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
|
media_delivery: None,
|
|
runtime_id: "rust-rudp-client".to_string(),
|
|
socket: client_socket,
|
|
mode: CultNetRudpSocketMode::Client,
|
|
remote_addr: Some(server_addr),
|
|
connection_id,
|
|
initial_sequence: Some(1),
|
|
resend_delay_ms: 25,
|
|
transport_id: None,
|
|
max_payload_bytes: None,
|
|
max_fragment_bytes: None,
|
|
max_pending_reliable_packets: None,
|
|
reconnect_policy: None,
|
|
media_reliable_expire_after_ms: None,
|
|
})?;
|
|
|
|
client.connect(b"join".to_vec())?;
|
|
pump_rudp_handshake(&mut client, &mut server)?;
|
|
assert!(client.connected());
|
|
assert!(server.connected());
|
|
|
|
client.send("schema", b"client-state".to_vec())?;
|
|
let server_frame = receive_rudp_frame(&mut server)?;
|
|
assert_eq!(server_frame.channel_id, "schema");
|
|
assert_eq!(server_frame.payload, b"client-state");
|
|
|
|
server.send("schema", b"server-state".to_vec())?;
|
|
let client_frame = receive_rudp_frame(&mut client)?;
|
|
assert_eq!(client_frame.channel_id, "schema");
|
|
assert_eq!(client_frame.payload, b"server-state");
|
|
|
|
client.send_schema_message(&CultNetMessage::Hello {
|
|
runtime_id: "rust-rudp-client".to_string(),
|
|
runtime_kind: "rust-worker".to_string(),
|
|
agent_id: None,
|
|
role: Some("schema".to_string()),
|
|
display_name: None,
|
|
supported_document_types: None,
|
|
supported_mutation_contracts: None,
|
|
supported_message_versions: None,
|
|
transport_profiles: Some(vec![client.profile.clone()]),
|
|
supports_schema_catalog: Some(true),
|
|
})?;
|
|
let message = receive_rudp_schema_message(&mut server)?;
|
|
let CultNetMessage::Hello {
|
|
runtime_id,
|
|
transport_profiles,
|
|
supports_schema_catalog,
|
|
..
|
|
} = message
|
|
else {
|
|
anyhow::bail!("RUDP schema message did not decode as hello");
|
|
};
|
|
assert_eq!(runtime_id, "rust-rudp-client");
|
|
assert_eq!(supports_schema_catalog, Some(true));
|
|
assert_eq!(
|
|
transport_profiles.expect("hello advertises transport")[0].transports[0].protocol,
|
|
CultNetTransportProtocol::Rudp
|
|
);
|
|
assert_eq!(
|
|
server.profile.transports[0].protocol,
|
|
CultNetTransportProtocol::Rudp
|
|
);
|
|
assert_eq!(client.stats().frames_sent, 2);
|
|
assert_eq!(server.stats().frames_received, 2);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames() -> Result<()> {
|
|
let server_socket = bind_udp_socket()?;
|
|
let client_socket = bind_udp_socket()?;
|
|
let server_addr = server_socket.local_addr()?;
|
|
let connection_id = 0x1020_3041;
|
|
let mut server =
|
|
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
|
media_delivery: None,
|
|
runtime_id: "rust-rudp-fragment-server".to_string(),
|
|
socket: server_socket,
|
|
mode: CultNetRudpSocketMode::Server,
|
|
remote_addr: None,
|
|
connection_id,
|
|
initial_sequence: Some(100),
|
|
resend_delay_ms: 25,
|
|
transport_id: None,
|
|
max_payload_bytes: None,
|
|
max_fragment_bytes: Some(8),
|
|
max_pending_reliable_packets: None,
|
|
reconnect_policy: None,
|
|
media_reliable_expire_after_ms: None,
|
|
})?;
|
|
let mut client =
|
|
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
|
media_delivery: None,
|
|
runtime_id: "rust-rudp-fragment-client".to_string(),
|
|
socket: client_socket,
|
|
mode: CultNetRudpSocketMode::Client,
|
|
remote_addr: Some(server_addr),
|
|
connection_id,
|
|
initial_sequence: Some(1),
|
|
resend_delay_ms: 25,
|
|
transport_id: None,
|
|
max_payload_bytes: None,
|
|
max_fragment_bytes: Some(8),
|
|
max_pending_reliable_packets: None,
|
|
reconnect_policy: None,
|
|
media_reliable_expire_after_ms: None,
|
|
})?;
|
|
|
|
client.connect(b"join".to_vec())?;
|
|
pump_rudp_handshake(&mut client, &mut server)?;
|
|
|
|
let payload = b"this-schema-frame-is-larger-than-one-rudp-fragment".to_vec();
|
|
client.send("schema", payload.clone())?;
|
|
let server_frame = receive_rudp_frame(&mut server)?;
|
|
assert_eq!(server_frame.channel_id, "schema");
|
|
assert_eq!(server_frame.payload, payload);
|
|
assert_eq!(client.stats().frames_sent, 1);
|
|
assert_eq!(server.stats().frames_received, 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_session_advances_large_fragment_sets_through_a_bounded_reliable_window() -> Result<()> {
|
|
let connection_id = 0x1020_3042;
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id,
|
|
initial_sequence: Some(1),
|
|
resend_delay_ms: 25,
|
|
max_pending_reliable_packets: None,
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id,
|
|
initial_sequence: Some(100),
|
|
resend_delay_ms: 25,
|
|
max_pending_reliable_packets: None,
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 0, Vec::new())?;
|
|
sender.receive(&accept, 0)?;
|
|
|
|
let fragment_count = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17;
|
|
let payload = (0..fragment_count * 8)
|
|
.map(|index| (index % 251) as u8)
|
|
.collect::<Vec<_>>();
|
|
let initial = sender.send_many(
|
|
"schema",
|
|
payload.clone(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
now_ms: 1,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
Some(8),
|
|
)?;
|
|
assert_eq!(initial.len(), CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS);
|
|
let oldest_sequence = initial[0].sequence;
|
|
assert_eq!(sender.pending_reliable_sequences().len(), initial.len());
|
|
assert_eq!(sender.queued_reliable_packet_count(), 17);
|
|
|
|
let mut wire = VecDeque::from(initial);
|
|
let mut delivered = Vec::new();
|
|
while !wire.is_empty() {
|
|
let admitted = wire.len();
|
|
for _ in 0..admitted {
|
|
let packet = wire.pop_front().expect("admitted packet remains queued");
|
|
let received = receiver.receive(&packet, 2)?;
|
|
delivered.extend(received.delivered);
|
|
}
|
|
let acknowledged = sender.receive(&receiver.create_ack(), 3)?;
|
|
wire.extend(acknowledged.ready_to_send);
|
|
}
|
|
|
|
assert_eq!(sender.outstanding_reliable_packet_count(), 0);
|
|
assert_eq!(delivered.len(), 1);
|
|
assert_eq!(delivered[0].payload, payload);
|
|
let old_ack = receiver.create_ack_for_received(oldest_sequence);
|
|
assert_eq!(old_ack.ack, oldest_sequence);
|
|
assert_eq!(old_ack.ack_mask, 0);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_socket_flush_waits_for_large_fragment_delivery() -> Result<()> {
|
|
let server_socket = bind_udp_socket()?;
|
|
let client_socket = bind_udp_socket()?;
|
|
let server_addr = server_socket.local_addr()?;
|
|
let connection_id = 0x1020_3043;
|
|
let mut server =
|
|
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
|
media_delivery: None,
|
|
runtime_id: "rust-rudp-window-server".into(),
|
|
socket: server_socket,
|
|
mode: CultNetRudpSocketMode::Server,
|
|
remote_addr: None,
|
|
connection_id,
|
|
initial_sequence: Some(100),
|
|
resend_delay_ms: 25,
|
|
transport_id: None,
|
|
max_payload_bytes: None,
|
|
max_fragment_bytes: Some(8),
|
|
max_pending_reliable_packets: None,
|
|
reconnect_policy: None,
|
|
media_reliable_expire_after_ms: None,
|
|
})?;
|
|
let mut client =
|
|
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
|
|
media_delivery: None,
|
|
runtime_id: "rust-rudp-window-client".into(),
|
|
socket: client_socket,
|
|
mode: CultNetRudpSocketMode::Client,
|
|
remote_addr: Some(server_addr),
|
|
connection_id,
|
|
initial_sequence: Some(1),
|
|
resend_delay_ms: 25,
|
|
transport_id: None,
|
|
max_payload_bytes: None,
|
|
max_fragment_bytes: Some(8),
|
|
max_pending_reliable_packets: None,
|
|
reconnect_policy: None,
|
|
media_reliable_expire_after_ms: None,
|
|
})?;
|
|
client.connect(b"join".to_vec())?;
|
|
pump_rudp_handshake(&mut client, &mut server)?;
|
|
|
|
let payload = vec![0x5a; (CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17) * 8];
|
|
client.send("schema", payload.clone())?;
|
|
assert_eq!(
|
|
client.pending_reliable_packet_count(),
|
|
CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS
|
|
);
|
|
assert_eq!(
|
|
client.outstanding_reliable_packet_count(),
|
|
CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 17
|
|
);
|
|
|
|
let server_worker = thread::spawn(move || -> Result<CultNetTransportFrame> {
|
|
let delivery_deadline = StdInstant::now() + StdDuration::from_secs(5);
|
|
let delivered = loop {
|
|
if let Some(frame) = server.receive_once()? {
|
|
break frame;
|
|
}
|
|
if StdInstant::now() >= delivery_deadline {
|
|
anyhow::bail!("RUDP large socket frame was not delivered");
|
|
}
|
|
};
|
|
let linger_deadline = StdInstant::now() + StdDuration::from_millis(250);
|
|
while StdInstant::now() < linger_deadline {
|
|
let _ = server.receive_once()?;
|
|
server.poll_resends()?;
|
|
}
|
|
Ok(delivered)
|
|
});
|
|
client.flush_reliable(StdDuration::from_secs(5))?;
|
|
let delivered = server_worker
|
|
.join()
|
|
.map_err(|_| anyhow::anyhow!("RUDP server worker panicked"))??;
|
|
assert_eq!(delivered.payload, payload);
|
|
assert_eq!(client.outstanding_reliable_packet_count(), 0);
|
|
Ok(())
|
|
}
|
|
|
|
fn rudp_client_bound_host(endpoint: &str, bind_host: Option<&str>) -> Result<String> {
|
|
let client = CultMesh::create_rudp_client_for_endpoint(
|
|
"rust-cultmesh-bind-client",
|
|
0x2030_4060,
|
|
endpoint,
|
|
CultMeshRudpSocketOptions {
|
|
bind_host: bind_host.map(str::to_string),
|
|
..CultMeshRudpSocketOptions::default()
|
|
},
|
|
)?;
|
|
Ok(client.profile.transports[0]
|
|
.host
|
|
.clone()
|
|
.expect("RUDP client profile advertises its bound host"))
|
|
}
|
|
|
|
/// A client bound to loopback cannot send to another host on Windows, so a
|
|
/// client for a remote endpoint binds the unspecified address of its family.
|
|
#[test]
|
|
fn cultmesh_rudp_client_for_a_remote_endpoint_binds_the_unspecified_address() -> Result<()> {
|
|
assert_eq!(rudp_client_bound_host("rudp://10.77.0.1:17872", None)?, "0.0.0.0");
|
|
assert_eq!(rudp_client_bound_host("rudp://[2001:db8::1]:17872", None)?, "::");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cultmesh_rudp_client_for_a_loopback_endpoint_binds_loopback() -> Result<()> {
|
|
assert_eq!(rudp_client_bound_host("rudp://127.0.0.1:17872", None)?, "127.0.0.1");
|
|
assert_eq!(rudp_client_bound_host("rudp://[::1]:17872", None)?, "::1");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cultmesh_rudp_client_binds_an_explicit_bind_host_whatever_the_endpoint() -> Result<()> {
|
|
assert_eq!(
|
|
rudp_client_bound_host("rudp://10.77.0.1:17872", Some("127.0.0.1"))?,
|
|
"127.0.0.1"
|
|
);
|
|
assert_eq!(
|
|
rudp_client_bound_host("rudp://127.0.0.1:17872", Some("0.0.0.0"))?,
|
|
"0.0.0.0"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cultmesh_rudp_server_binds_loopback_unless_told_otherwise() -> Result<()> {
|
|
let server = CultMesh::create_rudp_server(
|
|
"rust-cultmesh-bind-server",
|
|
0x2030_4061,
|
|
CultMeshRudpSocketOptions::default(),
|
|
)?;
|
|
assert_eq!(server.profile.transports[0].host.as_deref(), Some("127.0.0.1"));
|
|
let server = CultMesh::create_rudp_server(
|
|
"rust-cultmesh-bind-server",
|
|
0x2030_4062,
|
|
CultMeshRudpSocketOptions {
|
|
bind_host: Some("0.0.0.0".to_string()),
|
|
..CultMeshRudpSocketOptions::default()
|
|
},
|
|
)?;
|
|
assert_eq!(server.profile.transports[0].host.as_deref(), Some("0.0.0.0"));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cultmesh_facade_creates_rudp_client_from_peer_endpoint() -> Result<()> {
|
|
let connection_id = 0x2030_4050;
|
|
let options = CultMeshRudpSocketOptions {
|
|
resend_delay_ms: 25,
|
|
max_fragment_bytes: Some(8),
|
|
max_pending_reliable_packets: Some(16),
|
|
..CultMeshRudpSocketOptions::default()
|
|
};
|
|
let mut server =
|
|
CultMesh::create_rudp_server("rust-cultmesh-server", connection_id, options.clone())?;
|
|
let server_port = server.profile.transports[0]
|
|
.port
|
|
.expect("RUDP server profile advertises its local port");
|
|
let endpoint = CultMesh::parse_rudp_endpoint(&format!("rudp://127.0.0.1:{server_port}"))?;
|
|
assert_eq!(endpoint.host, "127.0.0.1");
|
|
assert_eq!(endpoint.port, server_port);
|
|
assert_eq!(
|
|
CultMesh::parse_rudp_endpoint("RuDp://localhost:4100")?.host,
|
|
"localhost"
|
|
);
|
|
assert_eq!(
|
|
CultMesh::parse_rudp_endpoint("rudp://[::1]:4100")?.uri(),
|
|
"rudp://[::1]:4100"
|
|
);
|
|
|
|
let peer = CultMeshPeerCard::new("rust-cultmesh-server", "local", [endpoint.uri()])
|
|
.with_roles(["schema"])
|
|
.with_authority_lease_id("lease:rust-cultmesh-server");
|
|
let mut peers = CultMesh::create_peer_catalog();
|
|
peers.upsert(peer.clone())?;
|
|
assert_eq!(peers.find("local", Some("schema")), vec![peer.clone()]);
|
|
let now = Utc::now();
|
|
let mut leases = CultMesh::create_authority_lease_catalog();
|
|
assert!(
|
|
CultMesh::create_rudp_client_for_authorized_peer(
|
|
"rust-cultmesh-client",
|
|
connection_id,
|
|
&peers,
|
|
&leases,
|
|
"local",
|
|
"schema",
|
|
None,
|
|
now,
|
|
options.clone(),
|
|
)
|
|
.is_err()
|
|
);
|
|
leases.upsert(CultMeshAuthorityLease {
|
|
lease_id: "lease:rust-cultmesh-server".to_string(),
|
|
verse_id: "local".to_string(),
|
|
peer_id: "rust-cultmesh-server".to_string(),
|
|
roles: vec!["schema".to_string()],
|
|
shard_ids: Vec::new(),
|
|
issuer_runtime_id: Some("odin".to_string()),
|
|
valid_from: now - Duration::minutes(1),
|
|
expires_at: now + Duration::minutes(1),
|
|
})?;
|
|
|
|
let mut client = CultMesh::create_rudp_client_for_authorized_peer(
|
|
"rust-cultmesh-client",
|
|
connection_id,
|
|
&peers,
|
|
&leases,
|
|
"local",
|
|
"schema",
|
|
None,
|
|
now,
|
|
options,
|
|
)?;
|
|
client.connect(b"join".to_vec())?;
|
|
pump_rudp_handshake(&mut client, &mut server)?;
|
|
|
|
client.send("schema", b"client-state".to_vec())?;
|
|
let server_frame = receive_rudp_frame(&mut server)?;
|
|
assert_eq!(server_frame.channel_id, "schema");
|
|
assert_eq!(server_frame.payload, b"client-state");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cultmesh_facade_connects_authorized_rudp_client_for_schema_messages() -> Result<()> {
|
|
let connection_id = 0x2030_4051;
|
|
let options = CultMeshRudpSocketOptions {
|
|
resend_delay_ms: 25,
|
|
max_fragment_bytes: Some(1024),
|
|
max_pending_reliable_packets: Some(16),
|
|
..CultMeshRudpSocketOptions::default()
|
|
};
|
|
let server = Arc::new(Mutex::new(CultMesh::create_rudp_server(
|
|
"rust-cultmesh-connected-server",
|
|
connection_id,
|
|
options.clone(),
|
|
)?));
|
|
let server_port = server
|
|
.lock()
|
|
.expect("RUDP server mutex poisoned")
|
|
.profile
|
|
.transports[0]
|
|
.port
|
|
.expect("RUDP server profile advertises its local port");
|
|
let endpoint = CultMesh::parse_rudp_endpoint(&format!("rudp://127.0.0.1:{server_port}"))?;
|
|
let peer = CultMeshPeerCard::new("rust-cultmesh-connected-server", "local", [endpoint.uri()])
|
|
.with_roles(["schema"])
|
|
.with_authority_lease_id("lease:rust-cultmesh-connected-server");
|
|
let mut peers = CultMesh::create_peer_catalog();
|
|
peers.upsert(peer)?;
|
|
let now = Utc::now();
|
|
let mut leases = CultMesh::create_authority_lease_catalog();
|
|
leases.upsert(CultMeshAuthorityLease {
|
|
lease_id: "lease:rust-cultmesh-connected-server".to_string(),
|
|
verse_id: "local".to_string(),
|
|
peer_id: "rust-cultmesh-connected-server".to_string(),
|
|
roles: vec!["schema".to_string()],
|
|
shard_ids: Vec::new(),
|
|
issuer_runtime_id: Some("odin".to_string()),
|
|
valid_from: now - Duration::minutes(1),
|
|
expires_at: now + Duration::minutes(1),
|
|
})?;
|
|
|
|
let done = Arc::new(AtomicBool::new(false));
|
|
let pump = pump_rudp_server_until_connected(server.clone(), done.clone());
|
|
let mut client = CultMesh::connect_rudp_client_for_authorized_peer(
|
|
"rust-cultmesh-connected-client",
|
|
connection_id,
|
|
&peers,
|
|
&leases,
|
|
"local",
|
|
"schema",
|
|
None,
|
|
now,
|
|
CultMeshRudpClientOptions {
|
|
socket_options: options,
|
|
connect_payload: b"join".to_vec(),
|
|
connect_timeout: StdDuration::from_secs(1),
|
|
poll_interval: StdDuration::from_millis(5),
|
|
},
|
|
)?;
|
|
done.store(true, Ordering::SeqCst);
|
|
pump.join()
|
|
.map_err(|_| anyhow::anyhow!("RUDP server pump panicked"))??;
|
|
|
|
assert!(client.connected());
|
|
assert!(
|
|
server
|
|
.lock()
|
|
.expect("RUDP server mutex poisoned")
|
|
.connected()
|
|
);
|
|
client.send_schema_message(&CultNetMessage::SchemaCatalogRequest {
|
|
message_id: "rust-cultmesh-rudp-schema-catalog".to_string(),
|
|
include_schema_json: Some(true),
|
|
schema_ids: None,
|
|
kinds: Some(vec![CultNetSchemaKind::DocumentPayload]),
|
|
})?;
|
|
let request = {
|
|
let mut server = server.lock().expect("RUDP server mutex poisoned");
|
|
receive_rudp_schema_message(&mut server)?
|
|
};
|
|
let CultNetMessage::SchemaCatalogRequest {
|
|
message_id, kinds, ..
|
|
} = request
|
|
else {
|
|
anyhow::bail!("RUDP schema message did not decode as schema catalog request");
|
|
};
|
|
assert_eq!(message_id, "rust-cultmesh-rudp-schema-catalog");
|
|
assert_eq!(kinds, Some(vec![CultNetSchemaKind::DocumentPayload]));
|
|
assert_eq!(
|
|
client.profile.transports[0].protocol,
|
|
CultNetTransportProtocol::Rudp
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cultmesh_facade_exposes_schema_registry_and_shard_catalog_owners() -> Result<()> {
|
|
let mut schemas = CultMesh::create_schema_registry();
|
|
schemas.register(CultNetSchemaRegistration {
|
|
schema_id: "rust.cultmesh.note.v1".to_string(),
|
|
kind: CultNetSchemaKind::DocumentPayload,
|
|
wire_contracts: vec![CultNetWireContract::CultNetSchemaV0],
|
|
schema_version: Some("rust.cultmesh.note.v1".to_string()),
|
|
document_type: Some("rust.cultmesh.note".to_string()),
|
|
title: Some("Rust CultMesh Note".to_string()),
|
|
schema_json: Some(
|
|
r#"{"$id":"rust.cultmesh.note.v1","type":"object","properties":{"body":{"type":"string"}}}"#
|
|
.to_string(),
|
|
),
|
|
})?;
|
|
assert_eq!(
|
|
schemas
|
|
.get("rust.cultmesh.note.v1", false)
|
|
.expect("registered schema is discoverable")
|
|
.document_type
|
|
.as_deref(),
|
|
Some("rust.cultmesh.note")
|
|
);
|
|
|
|
let builtins = CultMesh::create_builtin_schema_registry()?;
|
|
let shared_contracts = builtins.list(&cultnet_rs::CultNetSchemaCatalogOptions {
|
|
include_schema_json: false,
|
|
schema_ids: None,
|
|
kinds: Some(vec![CultNetSchemaKind::SharedContract]),
|
|
});
|
|
assert!(shared_contracts.iter().any(|schema| {
|
|
schema.schema_id
|
|
== "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json"
|
|
&& schema.schema_version.as_deref() == Some("cultnet.transport_profile.v0")
|
|
}));
|
|
|
|
let mut shards = CultMesh::create_shard_catalog();
|
|
shards.upsert(CultNetShardDescriptor {
|
|
shard_id: "rust-notes-a".to_string(),
|
|
owner_runtime_id: "rust-cultmesh".to_string(),
|
|
epoch: 7,
|
|
is_primary: Some(true),
|
|
schema_ids: vec!["rust.cultmesh.note.v1".to_string()],
|
|
key_prefix: Some("note:".to_string()),
|
|
primary_endpoints: vec!["rudp://127.0.0.1:4100".to_string()],
|
|
replica_endpoints: Vec::new(),
|
|
read_replica_endpoints: Vec::new(),
|
|
region: None,
|
|
authority_lease_id: Some("lease:rust-notes-a".to_string()),
|
|
})?;
|
|
let matching = shards.list(&cultnet_rs::CultNetShardCatalogOptions {
|
|
schema_ids: Some(vec!["rust.cultmesh.note.v1".to_string()]),
|
|
record_keys: Some(vec!["note:1".to_string()]),
|
|
});
|
|
assert_eq!(matching.len(), 1);
|
|
assert_eq!(matching[0].shard_id, "rust-notes-a");
|
|
assert!(shards.get("rust-notes-a").is_some());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cultmesh_authority_leases_gate_peer_contact_hints() -> Result<()> {
|
|
let now = Utc::now();
|
|
let peer = CultMeshPeerCard::new("rust-peer", "public", ["rudp://127.0.0.1:4100"])
|
|
.with_roles(["shard-primary"])
|
|
.with_authority_lease_id("lease:rust-peer");
|
|
let mut leases = CultMesh::create_authority_lease_catalog();
|
|
let mut peers = CultMesh::create_peer_catalog();
|
|
peers.upsert(peer.clone())?;
|
|
|
|
assert!(!leases.is_authorized(&peer, "shard-primary", Some("players"), now));
|
|
assert!(
|
|
peers
|
|
.find_authorized("public", "shard-primary", &leases, Some("players"), now)
|
|
.is_empty()
|
|
);
|
|
leases.upsert(CultMeshAuthorityLease {
|
|
lease_id: "lease:rust-peer".to_string(),
|
|
verse_id: "public".to_string(),
|
|
peer_id: "rust-peer".to_string(),
|
|
roles: vec!["shard-primary".to_string()],
|
|
shard_ids: vec!["players".to_string()],
|
|
issuer_runtime_id: Some("odin".to_string()),
|
|
valid_from: now - Duration::minutes(1),
|
|
expires_at: now + Duration::minutes(1),
|
|
})?;
|
|
|
|
assert!(leases.is_authorized(&peer, "shard-primary", Some("players"), now));
|
|
assert_eq!(
|
|
peers.find_authorized("public", "shard-primary", &leases, Some("players"), now),
|
|
vec![peer.clone()]
|
|
);
|
|
assert_eq!(
|
|
peers.first_authorized("public", "shard-primary", &leases, Some("players"), now),
|
|
Some(peer.clone())
|
|
);
|
|
assert!(!leases.is_authorized(&peer, "schema", Some("players"), now));
|
|
assert!(
|
|
peers
|
|
.first_authorized("public", "schema", &leases, Some("players"), now)
|
|
.is_none()
|
|
);
|
|
assert!(!leases.is_authorized(&peer, "shard-primary", Some("inventory"), now));
|
|
assert!(!leases.is_authorized(
|
|
&peer,
|
|
"shard-primary",
|
|
Some("players"),
|
|
now + Duration::minutes(1)
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_gamecult_networking_contract_round_trips_login_union() -> Result<()> {
|
|
let message = CultNetMessage::Login {
|
|
nonce: CultNetSecret::to_base64_url(b"nonce"),
|
|
auth: CultNetSecret::to_base64_url(b"auth"),
|
|
password: CultNetSecret::to_base64_url(b"password"),
|
|
};
|
|
let wire =
|
|
encode_cultnet_message_for_wire(&message, CultNetWireContract::GameCultNetworkingV0)?;
|
|
let items = wire.as_array().expect("legacy union is an array");
|
|
assert_eq!(items[0].as_i64(), Some(0));
|
|
assert_eq!(
|
|
items[1].as_array().unwrap()[0].as_slice(),
|
|
Some(&b"nonce"[..])
|
|
);
|
|
|
|
let bytes = rmp_serde::to_vec(&wire)?;
|
|
let decoded =
|
|
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::GameCultNetworkingV0)?;
|
|
assert_eq!(decoded, message);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rust_decodes_typescript_generated_cultnet_frames() -> Result<()> {
|
|
let mut framer = LengthPrefixedMessageFramer::new();
|
|
let hello_frames = framer.push(TS_HELLO_FRAME);
|
|
assert_eq!(hello_frames.len(), 1);
|
|
let hello =
|
|
decode_cultnet_message_from_slice(&hello_frames[0], CultNetWireContract::CultNetSchemaV0)?;
|
|
assert_eq!(
|
|
hello,
|
|
CultNetMessage::Hello {
|
|
runtime_id: "voidbot-main".to_string(),
|
|
runtime_kind: "node-worker".to_string(),
|
|
agent_id: Some("void".to_string()),
|
|
role: None,
|
|
display_name: Some("Void".to_string()),
|
|
supported_document_types: Some(vec!["ghostlight.agent-state".to_string()]),
|
|
supported_mutation_contracts: None,
|
|
supported_message_versions: None,
|
|
transport_profiles: None,
|
|
supports_schema_catalog: None,
|
|
}
|
|
);
|
|
|
|
let mut framer = LengthPrefixedMessageFramer::new();
|
|
let login_frames = framer.push(TS_LEGACY_LOGIN_FRAME);
|
|
assert_eq!(login_frames.len(), 1);
|
|
let login = decode_cultnet_message_from_slice(
|
|
&login_frames[0],
|
|
CultNetWireContract::GameCultNetworkingV0,
|
|
)?;
|
|
assert_eq!(
|
|
login,
|
|
CultNetMessage::Login {
|
|
nonce: "bm9uY2U".to_string(),
|
|
auth: "YXV0aA".to_string(),
|
|
password: "cGFzc3dvcmQ".to_string(),
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn document_registry_advertises_mutation_contracts_with_bindings() -> Result<()> {
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
let contract = CultNetDocumentMutationContract {
|
|
document_type: "ghostlight.agent-state".to_string(),
|
|
payload_schema_version: Some("ghostlight.agent_state.v0".to_string()),
|
|
operations: vec![
|
|
CultNetDocumentOperation::Snapshot,
|
|
CultNetDocumentOperation::IntentSubmit,
|
|
CultNetDocumentOperation::ReceiptWatch,
|
|
],
|
|
authority: CultNetMutationAuthority::Coordinator,
|
|
intent_document_types: Some(vec!["ghostlight.agent-state.intent.v0".to_string()]),
|
|
receipt_document_types: Some(vec!["ghostlight.agent-state.receipt.v0".to_string()]),
|
|
notes: Some(vec![
|
|
"Intent documents are reviewed before cache mutation.".to_string(),
|
|
]),
|
|
};
|
|
registry.register(
|
|
CultNetDocumentBinding::for_entry::<GhostlightAgentStateFixture>(
|
|
"ghostlight.agent_state.v0".to_string(),
|
|
)
|
|
.with_mutation_contract(contract.clone()),
|
|
)?;
|
|
|
|
assert_eq!(registry.mutation_contracts(), vec![contract]);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn a_replicated_payload_nested_past_the_bound_is_refused_typed_on_apply() -> Result<()> {
|
|
let temp = tempfile::tempdir()?;
|
|
let store = temp.path().join("foreign.msgpack");
|
|
let limit = cultcache_rs::MAX_PAYLOAD_DEPTH;
|
|
let envelope = |key: &str, levels: usize| cultcache_rs::CultCacheEnvelope {
|
|
key: key.to_string(),
|
|
r#type: GhostlightAgentStateFixture::TYPE.to_string(),
|
|
payload: [vec![0x91u8; levels], vec![0xc0]].concat(),
|
|
stored_at: "2026-10-11T00:00:00Z".to_string(),
|
|
schema_id: None,
|
|
};
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(CultNetDocumentBinding::for_entry::<GhostlightAgentStateFixture>(
|
|
"ghostlight.agent_state.v0".to_string(),
|
|
));
|
|
// A store written by something that does not hold the bound.
|
|
let mut backing = SingleFileMessagePackBackingStore::new(&store);
|
|
backing.push(&envelope("at", limit))?;
|
|
let mut cache = CultCache::new();
|
|
cache.register_entry_type::<GhostlightAgentStateFixture>()?;
|
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store))?;
|
|
cache.pull_all_backing_stores()?;
|
|
|
|
// The raw lane copies payload bytes without decoding; the receiver's apply is the door.
|
|
let at_limit = registry.create_raw_snapshot_response(&cache, "at", None, None)?;
|
|
let CultNetMessage::SnapshotResponseRaw { documents, .. } = &at_limit else {
|
|
panic!("expected a raw snapshot response");
|
|
};
|
|
assert_eq!(documents.len(), 1);
|
|
|
|
backing.push(&envelope("past", limit + 1))?;
|
|
let mut cache = CultCache::new();
|
|
cache.register_entry_type::<GhostlightAgentStateFixture>()?;
|
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store))?;
|
|
cache.pull_all_backing_stores()?;
|
|
let past = registry.create_raw_snapshot_response(&cache, "past", None, Some(&["past".to_string()]))?;
|
|
let mut target = CultCache::new();
|
|
target.register_entry_type::<GhostlightAgentStateFixture>()?;
|
|
let error = registry
|
|
.apply_raw_snapshot_response::<GhostlightAgentStateFixture>(&mut target, &past)
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
error.downcast_ref::<cultcache_rs::PayloadTooDeep>(),
|
|
Some(&cultcache_rs::PayloadTooDeep { depth: limit + 1, limit })
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn document_registry_replicates_typed_cultcache_state() -> Result<()> {
|
|
let temp = tempfile::tempdir()?;
|
|
let origin_store = temp.path().join("origin.msgpack");
|
|
let target_store = temp.path().join("target.msgpack");
|
|
let payload = GhostlightAgentStateFixture {
|
|
schema_version: "ghostlight.agent_state.v0".to_string(),
|
|
agent_id: "epiphany.persona".to_string(),
|
|
display_name: "Persona".to_string(),
|
|
};
|
|
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(CultNetDocumentBinding::for_entry::<
|
|
GhostlightAgentStateFixture,
|
|
>("ghostlight.agent_state.v0".to_string()))?;
|
|
|
|
let mut origin = CultCache::new();
|
|
origin.register_entry_type::<GhostlightAgentStateFixture>()?;
|
|
origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?;
|
|
origin.pull_all_backing_stores()?;
|
|
origin.put("epiphany.persona", &payload)?;
|
|
|
|
let snapshot = registry.create_raw_snapshot_response(&origin, "snapshot-1", None, None)?;
|
|
|
|
let mut target = CultCache::new();
|
|
target.register_entry_type::<GhostlightAgentStateFixture>()?;
|
|
target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?;
|
|
target.pull_all_backing_stores()?;
|
|
let applied =
|
|
registry.apply_raw_snapshot_response::<GhostlightAgentStateFixture>(&mut target, &snapshot)?;
|
|
let synced = registry.sync_raw_document_from_snapshot_response::<GhostlightAgentStateFixture>(
|
|
&mut target,
|
|
&snapshot,
|
|
"epiphany.persona",
|
|
)?;
|
|
assert_eq!(applied, vec![payload.clone()]);
|
|
assert_eq!(synced, payload);
|
|
assert_eq!(
|
|
target.get_required::<GhostlightAgentStateFixture>("epiphany.persona")?,
|
|
payload
|
|
);
|
|
|
|
let direct_put = registry.create_raw_document_put_message(
|
|
"put-1",
|
|
"epiphany.persona",
|
|
&GhostlightAgentStateFixture {
|
|
display_name: "Persona Prime".to_string(),
|
|
..payload
|
|
},
|
|
CultNetDocumentPutOptions::default(),
|
|
)?;
|
|
let updated = registry
|
|
.apply_raw_document_put_message::<GhostlightAgentStateFixture>(&mut target, &direct_put)?;
|
|
assert_eq!(updated.display_name, "Persona Prime");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> {
|
|
let temp = tempfile::tempdir()?;
|
|
let origin_store = temp.path().join("origin-raw.msgpack");
|
|
let target_store = temp.path().join("target-raw.msgpack");
|
|
let payload = GhostlightAgentStateFixture {
|
|
schema_version: "ghostlight.agent_state.v0".to_string(),
|
|
agent_id: "epiphany.persona".to_string(),
|
|
display_name: "Persona".to_string(),
|
|
};
|
|
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(CultNetDocumentBinding::for_entry::<
|
|
GhostlightAgentStateFixture,
|
|
>("ghostlight.agent_state.v0".to_string()))?;
|
|
|
|
let mut origin = CultCache::new();
|
|
origin.register_entry_type::<GhostlightAgentStateFixture>()?;
|
|
origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?;
|
|
origin.pull_all_backing_stores()?;
|
|
origin.put("epiphany.persona", &payload)?;
|
|
|
|
let raw_snapshot =
|
|
registry.create_raw_snapshot_response(&origin, "raw-snapshot-1", None, None)?;
|
|
let source_envelope =
|
|
origin.get_required_envelope::<GhostlightAgentStateFixture>("epiphany.persona")?;
|
|
|
|
let mut target = CultCache::new();
|
|
target.register_entry_type::<GhostlightAgentStateFixture>()?;
|
|
target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?;
|
|
target.pull_all_backing_stores()?;
|
|
let applied = registry
|
|
.apply_raw_snapshot_response::<GhostlightAgentStateFixture>(&mut target, &raw_snapshot)?;
|
|
let target_envelope =
|
|
target.get_required_envelope::<GhostlightAgentStateFixture>("epiphany.persona")?;
|
|
|
|
assert_eq!(applied, vec![payload.clone()]);
|
|
assert_eq!(target_envelope.payload, source_envelope.payload);
|
|
assert_eq!(
|
|
target.get_required::<GhostlightAgentStateFixture>("epiphany.persona")?,
|
|
payload
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
// R-Q3/R-R (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3, 2026-09-22): corrects
|
|
// this test's own earlier ruling from the commit 2 fix batch, which lowered an *explicit* empty
|
|
// or all-blank v0 list to "no filter". Only an *omitted* list (`None`) means "no filter" now - a
|
|
// present list, even one that empties out after trimming/deduping blanks, answers empty
|
|
// (`recordKeys: []` matches v0's own pre-cut server, which tested `filter?.RecordKeys != null`,
|
|
// not a length). A v1 Selection with the same shape is refused outright
|
|
// (validation_refuses_a_blank_entry_in_schemas_or_keys,
|
|
// validation_refuses_undeclared_index_role_and_empty_lists in tests/selection.rs) - that is a v1
|
|
// door rule this v0 lowering never reaches (`serve_read_only_raw_snapshot` never calls `validate`).
|
|
#[test]
|
|
fn serve_read_only_raw_snapshot_omitted_v0_schema_list_is_no_filter_present_empty_or_blank_answers_empty()
|
|
-> Result<()> {
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
|
GhostlightAgentStateFixture,
|
|
>("ghostlight.agent-state".to_string(), None))?;
|
|
|
|
let mut policy = CultNetReadOnlySnapshotPolicy::new();
|
|
policy.allow("ghostlight.agent-state", "row-1")?;
|
|
policy.allow("ghostlight.agent-state", "row-2")?;
|
|
|
|
let source = vec![
|
|
CultNetRawDocumentRecord {
|
|
schema_id: "ghostlight.agent-state".to_string(),
|
|
record_key: "row-1".to_string(),
|
|
stored_at: "now".to_string(),
|
|
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
|
payload: vec![1, 2, 3],
|
|
source_runtime_id: None,
|
|
source_agent_id: None,
|
|
source_role: None,
|
|
tags: None,
|
|
},
|
|
CultNetRawDocumentRecord {
|
|
schema_id: "ghostlight.agent-state".to_string(),
|
|
record_key: "row-2".to_string(),
|
|
stored_at: "now".to_string(),
|
|
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
|
payload: vec![4, 5, 6],
|
|
source_runtime_id: None,
|
|
source_agent_id: None,
|
|
source_role: None,
|
|
tags: None,
|
|
},
|
|
];
|
|
|
|
// The field omitted entirely: no filter, both rows.
|
|
let omitted_request = CultNetMessage::SnapshotRequest {
|
|
message_id: "req-1".to_string(),
|
|
schema_ids: None,
|
|
record_keys: None,
|
|
};
|
|
let omitted_response =
|
|
serve_read_only_raw_snapshot(®istry, &policy, &source, &omitted_request)?;
|
|
let CultNetMessage::SnapshotResponseRaw { documents, .. } = omitted_response else {
|
|
panic!("expected a raw snapshot response");
|
|
};
|
|
assert_eq!(documents.len(), 2, "schema_ids omitted must lower to no filter, not zero rows");
|
|
|
|
// A present list - empty, or made only of blanks - answers empty (R-R), not "no filter".
|
|
for schema_ids in [Some(Vec::new()), Some(vec![" ".to_string()])] {
|
|
let request = CultNetMessage::SnapshotRequest {
|
|
message_id: "req-1".to_string(),
|
|
schema_ids: schema_ids.clone(),
|
|
record_keys: None,
|
|
};
|
|
let response = serve_read_only_raw_snapshot(®istry, &policy, &source, &request)?;
|
|
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
|
|
panic!("expected a raw snapshot response");
|
|
};
|
|
assert_eq!(
|
|
documents.len(),
|
|
0,
|
|
"schema_ids = {schema_ids:?} is present and empty after trimming, so it must answer \
|
|
empty, not fall back to every row"
|
|
);
|
|
}
|
|
|
|
// A mixed list keeps its non-blank entries, deduplicated (matches
|
|
// CultNetV0SelectionLowering.Lower exactly, 568e8e3: Distinct(StringComparer.Ordinal)).
|
|
let mixed_request = CultNetMessage::SnapshotRequest {
|
|
message_id: "req-2".to_string(),
|
|
schema_ids: Some(vec![
|
|
"ghostlight.agent-state".to_string(),
|
|
" ".to_string(),
|
|
"ghostlight.agent-state".to_string(),
|
|
]),
|
|
record_keys: None,
|
|
};
|
|
let mixed_response = serve_read_only_raw_snapshot(®istry, &policy, &source, &mixed_request)?;
|
|
let CultNetMessage::SnapshotResponseRaw { documents, .. } = mixed_response else {
|
|
panic!("expected a raw snapshot response");
|
|
};
|
|
assert_eq!(
|
|
documents.len(),
|
|
2,
|
|
"a mixed list keeps its one distinct non-blank schema id, still selecting both rows of that schema"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// R-R: v0's pre-cut server preserved the caller's requested key order (a `HashSet<string>` built
|
|
// from the array, which enumerates in insertion order), not plain snapshot-source order.
|
|
#[test]
|
|
fn serve_read_only_raw_snapshot_keeps_the_requested_record_key_order() -> Result<()> {
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
|
GhostlightAgentStateFixture,
|
|
>("ghostlight.agent-state".to_string(), None))?;
|
|
|
|
let mut policy = CultNetReadOnlySnapshotPolicy::new();
|
|
policy.allow("ghostlight.agent-state", "row-1")?;
|
|
policy.allow("ghostlight.agent-state", "row-2")?;
|
|
policy.allow("ghostlight.agent-state", "row-3")?;
|
|
|
|
// Source order is row-1, row-2, row-3; the request asks for them in the opposite order.
|
|
let source = vec![
|
|
CultNetRawDocumentRecord {
|
|
schema_id: "ghostlight.agent-state".to_string(),
|
|
record_key: "row-1".to_string(),
|
|
stored_at: "now".to_string(),
|
|
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
|
payload: vec![1],
|
|
source_runtime_id: None,
|
|
source_agent_id: None,
|
|
source_role: None,
|
|
tags: None,
|
|
},
|
|
CultNetRawDocumentRecord {
|
|
schema_id: "ghostlight.agent-state".to_string(),
|
|
record_key: "row-2".to_string(),
|
|
stored_at: "now".to_string(),
|
|
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
|
payload: vec![2],
|
|
source_runtime_id: None,
|
|
source_agent_id: None,
|
|
source_role: None,
|
|
tags: None,
|
|
},
|
|
CultNetRawDocumentRecord {
|
|
schema_id: "ghostlight.agent-state".to_string(),
|
|
record_key: "row-3".to_string(),
|
|
stored_at: "now".to_string(),
|
|
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
|
payload: vec![3],
|
|
source_runtime_id: None,
|
|
source_agent_id: None,
|
|
source_role: None,
|
|
tags: None,
|
|
},
|
|
];
|
|
|
|
let request = CultNetMessage::SnapshotRequest {
|
|
message_id: "req-1".to_string(),
|
|
schema_ids: None,
|
|
record_keys: Some(vec!["row-3".to_string(), "row-1".to_string(), "row-2".to_string()]),
|
|
};
|
|
let response = serve_read_only_raw_snapshot(®istry, &policy, &source, &request)?;
|
|
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
|
|
panic!("expected a raw snapshot response");
|
|
};
|
|
let keys: Vec<&str> = documents.iter().map(|d| d.record_key.as_str()).collect();
|
|
assert_eq!(keys, vec!["row-3", "row-1", "row-2"], "the requested key order must survive, not the source's");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn document_registry_binds_one_type_per_schema_id_and_refuses_a_second_typed() -> Result<()> {
|
|
let canonical = |schema_id: &str| {
|
|
CultNetDocumentBinding::for_entry_with_schema_id::<GhostlightAgentStateFixture>(schema_id.to_string(), None)
|
|
};
|
|
let ui = |schema_id: &str| {
|
|
CultNetDocumentBinding::for_entry_with_schema_id::<GhostlightAgentStateUiFixture>(schema_id.to_string(), None)
|
|
};
|
|
let canonical_type = GhostlightAgentStateFixture::TYPE.to_string();
|
|
let ui_type = GhostlightAgentStateUiFixture::TYPE.to_string();
|
|
|
|
// Another type for a held schema id, and a held type for another schema id, in both registration orders.
|
|
let cases = [
|
|
(canonical("id.agent"), ui("id.agent"), "id.agent", [&canonical_type, &ui_type]),
|
|
(ui("id.agent"), canonical("id.agent"), "id.agent", [&ui_type, &canonical_type]),
|
|
(canonical("id.agent"), canonical("id.other"), "id.other", [&canonical_type, &canonical_type]),
|
|
(canonical("id.other"), canonical("id.agent"), "id.agent", [&canonical_type, &canonical_type]),
|
|
];
|
|
for (first, second, schema_id, names) in cases {
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(first.clone())?;
|
|
let error = registry.register(second.clone()).err().expect("a second claimant is refused");
|
|
let conflict = error
|
|
.downcast_ref::<cultcache_rs::SchemaConflictError>()
|
|
.unwrap_or_else(|| panic!("{error:#}"));
|
|
assert_eq!(conflict.schema_id, schema_id);
|
|
assert_eq!(conflict.schema_names, names.map(|name| name.clone()).to_vec());
|
|
assert_eq!(conflict.record_key, "");
|
|
assert!(error.to_string().contains(schema_id), "{error:#}");
|
|
// The refused binding left nothing behind: the first binding still answers for its type and its schema id.
|
|
assert_eq!(registry.binding(&first.document_type).map(|held| held.schema_id.as_str()), Some(first.schema_id.as_str()));
|
|
assert_eq!(
|
|
registry.binding_by_schema_id(&first.schema_id).map(|held| held.document_type.as_str()),
|
|
Some(first.document_type.as_str())
|
|
);
|
|
if second.schema_id != first.schema_id {
|
|
assert!(registry.binding_by_schema_id(&second.schema_id).is_none());
|
|
}
|
|
}
|
|
|
|
// A type bound again to the schema id it holds replaces its binding.
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(canonical("id.agent"))?;
|
|
registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<GhostlightAgentStateFixture>(
|
|
"id.agent".to_string(),
|
|
Some("ghostlight.agent_state.v2".to_string()),
|
|
))?;
|
|
assert_eq!(
|
|
registry.binding(&canonical_type).and_then(|held| held.payload_schema_version.as_deref()),
|
|
Some("ghostlight.agent_state.v2")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn raw_snapshot_replication_hydrates_the_receivers_own_type_for_the_schema() -> Result<()> {
|
|
let temp = tempfile::tempdir()?;
|
|
let origin_store = temp.path().join("origin.msgpack");
|
|
let target_store = temp.path().join("target.msgpack");
|
|
let canonical = GhostlightAgentStateFixture {
|
|
schema_version: "ghostlight.agent_state.v0".to_string(),
|
|
agent_id: "epiphany.persona".to_string(),
|
|
display_name: "Persona".to_string(),
|
|
};
|
|
|
|
let mut origin_registry = CultNetDocumentRegistry::new();
|
|
origin_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
|
GhostlightAgentStateFixture,
|
|
>(
|
|
"ghostlight.agent_state.v0".to_string(),
|
|
"ghostlight.agent_state.v0".to_string(),
|
|
))?;
|
|
let mut origin = CultCache::new();
|
|
origin.register_entry_type::<GhostlightAgentStateFixture>()?;
|
|
origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?;
|
|
origin.pull_all_backing_stores()?;
|
|
origin.put("epiphany.persona", &canonical)?;
|
|
let raw_snapshot = origin_registry.create_raw_snapshot_response(
|
|
&origin,
|
|
"raw-snapshot-receiver-type",
|
|
Some(&["ghostlight.agent_state.v0".to_string()]),
|
|
Some(&["epiphany.persona".to_string()]),
|
|
)?;
|
|
|
|
let mut target_registry = CultNetDocumentRegistry::new();
|
|
target_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
|
GhostlightAgentStateUiFixture,
|
|
>(
|
|
"ghostlight.agent_state.v0".to_string(),
|
|
"ghostlight.agent_state.v0".to_string(),
|
|
))?;
|
|
let mut target = CultCache::new();
|
|
target.register_entry_type::<GhostlightAgentStateUiFixture>()?;
|
|
target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?;
|
|
target.pull_all_backing_stores()?;
|
|
|
|
let applied = target_registry
|
|
.sync_raw_document_from_snapshot_response::<GhostlightAgentStateUiFixture>(
|
|
&mut target,
|
|
&raw_snapshot,
|
|
"epiphany.persona",
|
|
)?;
|
|
|
|
assert_eq!(
|
|
applied,
|
|
GhostlightAgentStateUiFixture {
|
|
schema_version: "ghostlight.agent_state.v0".to_string(),
|
|
agent_id: "epiphany.persona".to_string(),
|
|
display_name: "Persona".to_string(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
target.get_required::<GhostlightAgentStateUiFixture>("epiphany.persona")?,
|
|
applied
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn reactive_document_coalesces_direct_member_writes() -> Result<()> {
|
|
let temp = tempfile::tempdir()?;
|
|
let target_store = temp.path().join("target-reactive.msgpack");
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
|
GhostlightReactiveNoteUiFixture,
|
|
>(
|
|
"ghostlight.reactive_note.v0".to_string(),
|
|
"ghostlight.reactive_note.v0".to_string(),
|
|
))?;
|
|
let cache = Arc::new(Mutex::new(CultCache::new()));
|
|
{
|
|
let mut cache = cache.lock().expect("cache mutex");
|
|
cache.register_entry_type::<GhostlightReactiveNoteUiFixture>()?;
|
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?;
|
|
cache.pull_all_backing_stores()?;
|
|
cache.put(
|
|
"note:reactive",
|
|
&GhostlightReactiveNoteUiFixture {
|
|
body: "initial".to_string(),
|
|
revision: 1,
|
|
},
|
|
)?;
|
|
}
|
|
|
|
let reactive = registry.reactive_document::<GhostlightReactiveNoteUiFixture>(
|
|
Arc::clone(&cache),
|
|
"note:reactive",
|
|
CultNetReactiveDocumentOptions {
|
|
flush_delay: StdDuration::from_millis(5),
|
|
..CultNetReactiveDocumentOptions::default()
|
|
},
|
|
)?;
|
|
{
|
|
let current = reactive.current();
|
|
let mut current = current.lock().expect("reactive current mutex");
|
|
current.body = "first-local-edit".to_string();
|
|
current.body = "second-local-edit".to_string();
|
|
current.revision = 2;
|
|
}
|
|
|
|
for _ in 0..30 {
|
|
let body = cache
|
|
.lock()
|
|
.expect("cache mutex")
|
|
.get_required::<GhostlightReactiveNoteUiFixture>("note:reactive")?
|
|
.body;
|
|
if body == "second-local-edit" {
|
|
break;
|
|
}
|
|
thread::sleep(StdDuration::from_millis(5));
|
|
}
|
|
|
|
let stored = cache
|
|
.lock()
|
|
.expect("cache mutex")
|
|
.get_required::<GhostlightReactiveNoteUiFixture>("note:reactive")?;
|
|
assert_eq!(stored.body, "second-local-edit");
|
|
assert_eq!(stored.revision, 2);
|
|
assert!(!reactive.is_dirty());
|
|
assert!(reactive.last_error().is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn reactive_document_tracks_canonical_reconciliation_delta() -> Result<()> {
|
|
let temp = tempfile::tempdir()?;
|
|
let target_store = temp.path().join("target-reactive-reconcile.msgpack");
|
|
let mut registry = CultNetDocumentRegistry::new();
|
|
registry.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
|
GhostlightReactiveNoteFixture,
|
|
>(
|
|
"ghostlight.reactive_note.v0".to_string(),
|
|
"ghostlight.reactive_note.v0".to_string(),
|
|
))?;
|
|
let cache = Arc::new(Mutex::new(CultCache::new()));
|
|
{
|
|
let mut cache = cache.lock().expect("cache mutex");
|
|
cache.register_entry_type::<GhostlightReactiveNoteFixture>()?;
|
|
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?;
|
|
cache.pull_all_backing_stores()?;
|
|
cache.put(
|
|
"note:reconcile",
|
|
&GhostlightReactiveNoteFixture {
|
|
body: "initial".to_string(),
|
|
revision: 1,
|
|
},
|
|
)?;
|
|
}
|
|
|
|
let reactive = registry.reactive_document::<GhostlightReactiveNoteFixture>(
|
|
Arc::clone(&cache),
|
|
"note:reconcile",
|
|
CultNetReactiveDocumentOptions {
|
|
flush_delay: StdDuration::from_secs(60),
|
|
detect_local_changes: false,
|
|
..CultNetReactiveDocumentOptions::default()
|
|
},
|
|
)?;
|
|
reactive.update(|note| {
|
|
note.body = "local-prediction".to_string();
|
|
note.revision = 2;
|
|
})?;
|
|
let authoritative = registry.create_raw_document_put_message(
|
|
"put-authoritative",
|
|
"note:reconcile",
|
|
&GhostlightReactiveNoteFixture {
|
|
body: "authoritative".to_string(),
|
|
revision: 7,
|
|
},
|
|
CultNetDocumentPutOptions::default(),
|
|
)?;
|
|
|
|
reactive.apply_raw_document_put_message(&authoritative)?;
|
|
|
|
let current = reactive.current();
|
|
let current = current.lock().expect("reactive current mutex").clone();
|
|
assert_eq!(current.body, "local-prediction");
|
|
let reconciliation = reactive
|
|
.reconciliation()
|
|
.expect("dirty canonical apply records reconciliation");
|
|
assert_eq!(reconciliation.canonical.body, "authoritative");
|
|
assert_eq!(reconciliation.predicted.body, "local-prediction");
|
|
assert_eq!(
|
|
reconciliation.delta.get("0"),
|
|
Some(&serde_json::json!("local-prediction"))
|
|
);
|
|
assert_eq!(
|
|
reconciliation.delta.get("1"),
|
|
Some(&serde_json::json!(-5.0))
|
|
);
|
|
|
|
reactive.flush()?;
|
|
let stored = cache
|
|
.lock()
|
|
.expect("cache mutex")
|
|
.get_required::<GhostlightReactiveNoteFixture>("note:reconcile")?;
|
|
assert_eq!(stored.body, "local-prediction");
|
|
assert!(reactive.reconciliation().is_none());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn client_security_keeps_the_connection_key_visible_without_exposing_cipher_logic() -> Result<()> {
|
|
let options = CultNetClientSecurityOptions::development();
|
|
assert_eq!(options.connection_key, "gamecult-dev-connection-key");
|
|
assert_ne!(options.encryption_key(), [0_u8; 32]);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_schema_registry_advertises_canonical_ghostlight_schema_without_inline_body_by_default()
|
|
-> Result<()> {
|
|
let registry = builtin_schema_registry()?;
|
|
let response = registry.create_catalog_response(&CultNetMessage::SchemaCatalogRequest {
|
|
message_id: "catalog-1".to_string(),
|
|
include_schema_json: None,
|
|
schema_ids: None,
|
|
kinds: None,
|
|
})?;
|
|
|
|
let CultNetMessage::SchemaCatalogResponse { schemas, .. } = response else {
|
|
panic!("expected catalog response");
|
|
};
|
|
|
|
let ghostlight = schemas
|
|
.iter()
|
|
.find(|schema| schema.document_type.as_deref() == Some("ghostlight.agent-state"))
|
|
.expect("ghostlight agent-state schema is advertised");
|
|
|
|
assert_eq!(ghostlight.kind, CultNetSchemaKind::DocumentPayload);
|
|
assert_eq!(
|
|
ghostlight.schema_version.as_deref(),
|
|
Some("ghostlight.agent_state.v0")
|
|
);
|
|
assert_eq!(ghostlight.schema_json, None);
|
|
assert!(!ghostlight.content_hash.is_empty());
|
|
|
|
let transport_profile = schemas
|
|
.iter()
|
|
.find(|schema| schema.schema_version.as_deref() == Some("cultnet.transport_profile.v0"))
|
|
.expect("transport profile schema is advertised");
|
|
|
|
assert_eq!(transport_profile.kind, CultNetSchemaKind::SharedContract);
|
|
assert_eq!(
|
|
transport_profile.schema_id,
|
|
"https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json"
|
|
);
|
|
assert_eq!(
|
|
transport_profile.wire_contracts,
|
|
vec![CultNetWireContract::CultNetSchemaV0]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_schema_registry_advertises_shard_catalog_wire_messages() -> Result<()> {
|
|
let registry = builtin_schema_registry()?;
|
|
let response = registry.create_catalog_response(&CultNetMessage::SchemaCatalogRequest {
|
|
message_id: "catalog-shards".to_string(),
|
|
include_schema_json: None,
|
|
schema_ids: None,
|
|
kinds: Some(vec![CultNetSchemaKind::WireMessage]),
|
|
})?;
|
|
|
|
let CultNetMessage::SchemaCatalogResponse { schemas, .. } = response else {
|
|
panic!("expected catalog response");
|
|
};
|
|
|
|
assert!(schemas.iter().any(|schema| {
|
|
schema.schema_version.as_deref() == Some("cultnet.shard_catalog_request.v0")
|
|
}));
|
|
assert!(schemas.iter().any(|schema| {
|
|
schema.schema_version.as_deref() == Some("cultnet.shard_catalog_response.v0")
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn shard_catalog_filters_descriptors_and_applies_remote_responses() -> Result<()> {
|
|
let mut catalog = CultNetShardCatalog::new();
|
|
catalog.upsert(CultNetShardDescriptor {
|
|
shard_id: "notes-a".to_string(),
|
|
owner_runtime_id: "rust-primary".to_string(),
|
|
epoch: 3,
|
|
is_primary: Some(true),
|
|
schema_ids: vec!["note.v0".to_string()],
|
|
key_prefix: Some("note:".to_string()),
|
|
primary_endpoints: vec!["rudp://127.0.0.1:4100".to_string()],
|
|
replica_endpoints: vec![],
|
|
read_replica_endpoints: vec![],
|
|
region: Some("local".to_string()),
|
|
authority_lease_id: Some("lease:notes-a".to_string()),
|
|
})?;
|
|
catalog.upsert(CultNetShardDescriptor {
|
|
shard_id: "players-a".to_string(),
|
|
owner_runtime_id: "rust-primary".to_string(),
|
|
epoch: 1,
|
|
is_primary: Some(false),
|
|
schema_ids: vec!["player.v0".to_string()],
|
|
key_prefix: Some("player:".to_string()),
|
|
primary_endpoints: vec!["cultnet://127.0.0.1:3075".to_string()],
|
|
replica_endpoints: vec![],
|
|
read_replica_endpoints: vec![],
|
|
region: None,
|
|
authority_lease_id: None,
|
|
})?;
|
|
|
|
let response = catalog.create_catalog_response(&CultNetMessage::ShardCatalogRequest {
|
|
message_id: "shards".to_string(),
|
|
schema_ids: Some(vec!["note.v0".to_string()]),
|
|
record_keys: Some(vec!["note:1".to_string()]),
|
|
})?;
|
|
|
|
let CultNetMessage::ShardCatalogResponse { message_id, shards } = &response else {
|
|
panic!("expected shard catalog response");
|
|
};
|
|
assert_eq!(message_id, "shards");
|
|
assert_eq!(shards.len(), 1);
|
|
assert_eq!(shards[0].shard_id, "notes-a");
|
|
|
|
let bytes = encode_cultnet_message_to_vec(&response, CultNetWireContract::CultNetSchemaV0)?;
|
|
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)?;
|
|
assert_eq!(decoded, response);
|
|
|
|
let mut applied = CultNetShardCatalog::new();
|
|
let applied_shards = applied.apply_response(&decoded)?;
|
|
assert_eq!(applied_shards.len(), 1);
|
|
assert!(applied.get("notes-a").is_some_and(|shard| {
|
|
shard.serves(Some("note.v0"), Some("note:2"))
|
|
&& !shard.serves(Some("note.v0"), Some("player:2"))
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn schema_discovery_round_trips_over_legacy_gamecult_contract_when_inline_schemas_are_requested()
|
|
-> Result<()> {
|
|
let registry = {
|
|
let mut registry = CultNetSchemaRegistry::new();
|
|
registry.register(cultnet_rs::CultNetSchemaRegistration {
|
|
schema_id: "https://example.test/contracts/example.schema.json".to_string(),
|
|
kind: CultNetSchemaKind::SharedContract,
|
|
wire_contracts: vec![
|
|
CultNetWireContract::CultNetSchemaV0,
|
|
CultNetWireContract::GameCultNetworkingV0,
|
|
],
|
|
schema_version: None,
|
|
document_type: None,
|
|
title: Some("Example Schema".to_string()),
|
|
schema_json: Some(
|
|
r#"{
|
|
"$schema":"https://json-schema.org/draft/2020-12/schema",
|
|
"$id":"https://example.test/contracts/example.schema.json",
|
|
"title":"Example Schema",
|
|
"type":"object",
|
|
"properties":{"value":{"type":"string"}},
|
|
"required":["value"],
|
|
"additionalProperties":false
|
|
}"#
|
|
.to_string(),
|
|
),
|
|
})?;
|
|
registry
|
|
};
|
|
|
|
let response = registry.create_catalog_response(&CultNetMessage::SchemaCatalogRequest {
|
|
message_id: "catalog-legacy".to_string(),
|
|
include_schema_json: Some(true),
|
|
schema_ids: None,
|
|
kinds: None,
|
|
})?;
|
|
let wire =
|
|
encode_cultnet_message_for_wire(&response, CultNetWireContract::GameCultNetworkingV0)?;
|
|
let bytes = rmp_serde::to_vec(&wire)?;
|
|
let decoded =
|
|
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::GameCultNetworkingV0)?;
|
|
|
|
let CultNetMessage::SchemaCatalogResponse {
|
|
message_id,
|
|
schemas,
|
|
} = decoded
|
|
else {
|
|
panic!("expected legacy schema catalog response");
|
|
};
|
|
|
|
assert_eq!(message_id, "catalog-legacy");
|
|
assert_eq!(
|
|
schemas[0].schema_id,
|
|
"https://example.test/contracts/example.schema.json"
|
|
);
|
|
assert!(
|
|
schemas[0]
|
|
.schema_json
|
|
.as_deref()
|
|
.is_some_and(|schema| schema.contains("\"value\""))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_sequenced_channel_tracking_is_bounded() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 401,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 401,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 1, Vec::new())?;
|
|
sender.receive(&accept, 2)?;
|
|
|
|
// A peer picks its own channel ids, so the sequenced map is attacker-sized.
|
|
let mut error = None;
|
|
for channel in 0..256u32 {
|
|
let packet = sender.send(
|
|
&format!("channel-{channel}"),
|
|
b"state".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
sequenced: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
if let Err(refused) = receiver.receive(&packet, 3) {
|
|
error = Some(refused);
|
|
break;
|
|
}
|
|
}
|
|
let error = error.expect("unbounded sequenced channel tracking");
|
|
assert!(
|
|
error.to_string().contains("too many sequenced channels"),
|
|
"unexpected refusal: {error}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_ordered_channel_tracking_is_bounded() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 402,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 402,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 1, Vec::new())?;
|
|
sender.receive(&accept, 2)?;
|
|
|
|
// Channels are tracked only while they hold a frame, so the bound is on
|
|
// channels held behind one missing sequence.
|
|
let gap = sender.send(
|
|
"gap",
|
|
b"lost".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: false,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
sender.receive(&receiver.create_ack_for(gap.sequence), 3)?;
|
|
let mut error = None;
|
|
for channel in 0..256u32 {
|
|
let packet = sender.send(
|
|
&format!("ordered-{channel}"),
|
|
b"state".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
match receiver.receive(&packet, 3) {
|
|
Ok(result) => {
|
|
assert!(result.delivered.is_empty(), "the gap holds every frame");
|
|
sender.receive(&receiver.create_ack_for(packet.sequence), 3)?;
|
|
}
|
|
Err(refused) => {
|
|
error = Some(refused);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let error = error.expect("unbounded ordered channel tracking");
|
|
assert!(
|
|
error.to_string().contains("too many ordered channels"),
|
|
"unexpected refusal: {error}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_ordered_hold_buffer_is_bounded() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 403,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 403,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 1, Vec::new())?;
|
|
sender.receive(&accept, 2)?;
|
|
|
|
let options = CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered: true,
|
|
..CultNetRudpSendOptions::default()
|
|
};
|
|
// Withhold the first frame so every later one has to be held.
|
|
let withheld = sender.send("schema", b"first".to_vec(), options.clone())?;
|
|
sender.receive(&receiver.create_ack_for(withheld.sequence), 3)?;
|
|
|
|
let mut error = None;
|
|
for step in 0..4_096u32 {
|
|
let packet = sender.send("schema", vec![(step % 251) as u8], options.clone())?;
|
|
match receiver.receive(&packet, 4) {
|
|
Ok(result) => {
|
|
assert!(result.delivered.is_empty(), "gap should hold every frame");
|
|
sender.receive(&receiver.create_ack_for(packet.sequence), 4)?;
|
|
}
|
|
Err(refused) => {
|
|
error = Some(refused);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let error = error.expect("unbounded ordered hold buffer");
|
|
assert!(
|
|
error.to_string().contains("ordered hold buffer is full"),
|
|
"unexpected refusal: {error}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn rudp_ordered_pair() -> Result<(CultNetRudpSession, CultNetRudpSession)> {
|
|
let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 410,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 410,
|
|
initial_sequence: Some(500),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = client.create_connect(0, Vec::new())?;
|
|
let accept = server.accept_connect(&connect, 0, Vec::new())?;
|
|
client.receive(&accept, 0)?;
|
|
Ok((client, server))
|
|
}
|
|
|
|
fn rudp_send_options(ordered: bool) -> CultNetRudpSendOptions {
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
ordered,
|
|
..CultNetRudpSendOptions::default()
|
|
}
|
|
}
|
|
|
|
fn rudp_delivered_names(delivered: &[cultnet_rs::CultNetRudpDeliveredFrame]) -> Vec<String> {
|
|
delivered
|
|
.iter()
|
|
.map(|frame| String::from_utf8(frame.payload.clone()).unwrap())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_ordered_frame_waits_for_a_gap_filled_by_another_channel() -> Result<()> {
|
|
let (mut sender, mut receiver) = rudp_ordered_pair()?;
|
|
let o1 = sender.send("schema", b"o1".to_vec(), rudp_send_options(true))?;
|
|
let u = sender.send("media", b"u".to_vec(), rudp_send_options(false))?;
|
|
let o2 = sender.send("schema", b"o2".to_vec(), rudp_send_options(true))?;
|
|
let o3 = sender.send("schema", b"o3".to_vec(), rudp_send_options(true))?;
|
|
let o4 = sender.send("schema", b"o4".to_vec(), rudp_send_options(true))?;
|
|
|
|
assert_eq!(rudp_delivered_names(&receiver.receive(&o1, 1)?.delivered), ["o1"]);
|
|
// `u` is lost, so nothing behind it may be delivered.
|
|
assert!(receiver.receive(&o2, 2)?.delivered.is_empty());
|
|
assert!(receiver.receive(&o3, 3)?.delivered.is_empty());
|
|
assert!(receiver.receive(&o4, 4)?.delivered.is_empty());
|
|
// The other channel's packet fills the gap and releases every ordered
|
|
// frame behind it in the same call.
|
|
assert_eq!(
|
|
rudp_delivered_names(&receiver.receive(&u, 5)?.delivered),
|
|
["u", "o2", "o3", "o4"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_two_ordered_channels_release_each_other_in_sequence_order() -> Result<()> {
|
|
let (mut sender, mut receiver) = rudp_ordered_pair()?;
|
|
let a1 = sender.send("schema", b"A1".to_vec(), rudp_send_options(true))?;
|
|
let b1 = sender.send("other", b"B1".to_vec(), rudp_send_options(true))?;
|
|
let a2 = sender.send("schema", b"A2".to_vec(), rudp_send_options(true))?;
|
|
let a3 = sender.send("schema", b"A3".to_vec(), rudp_send_options(true))?;
|
|
|
|
let mut delivered = rudp_delivered_names(&receiver.receive(&a1, 1)?.delivered);
|
|
delivered.extend(rudp_delivered_names(&receiver.receive(&a2, 2)?.delivered));
|
|
delivered.extend(rudp_delivered_names(&receiver.receive(&a3, 3)?.delivered));
|
|
assert_eq!(delivered, ["A1"], "B1 is missing, so A2 and A3 wait");
|
|
assert_eq!(
|
|
rudp_delivered_names(&receiver.receive(&b1, 4)?.delivered),
|
|
["B1", "A2", "A3"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_channel_first_used_after_other_traffic_loses_nothing() -> Result<()> {
|
|
let (mut sender, mut receiver) = rudp_ordered_pair()?;
|
|
let c1 = sender.send("late", b"C1".to_vec(), rudp_send_options(true))?;
|
|
let c2 = sender.send("late", b"C2".to_vec(), rudp_send_options(true))?;
|
|
let x = sender.send("schema", b"X".to_vec(), rudp_send_options(true))?;
|
|
|
|
// X, then C2, arrive while C1 is still in flight.
|
|
let mut delivered = rudp_delivered_names(&receiver.receive(&x, 1)?.delivered);
|
|
delivered.extend(rudp_delivered_names(&receiver.receive(&c2, 2)?.delivered));
|
|
assert!(delivered.is_empty(), "C1 is missing, so nothing is delivered");
|
|
assert_eq!(
|
|
rudp_delivered_names(&receiver.receive(&c1, 3)?.delivered),
|
|
["C1", "C2", "X"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_accept_seeds_the_watermark_of_the_connecting_side() -> Result<()> {
|
|
let (mut client, mut server) = rudp_ordered_pair()?;
|
|
let s1 = server.send("schema", b"s1".to_vec(), rudp_send_options(true))?;
|
|
let s2 = server.send("schema", b"s2".to_vec(), rudp_send_options(true))?;
|
|
assert!(client.receive(&s2, 1)?.delivered.is_empty());
|
|
assert_eq!(
|
|
rudp_delivered_names(&client.receive(&s1, 2)?.delivered),
|
|
["s1", "s2"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_reset_forgets_held_frames_and_reseeds_the_watermark_from_the_next_handshake() -> Result<()> {
|
|
let (mut sender, mut receiver) = rudp_ordered_pair()?;
|
|
sender.send("schema", b"s1".to_vec(), rudp_send_options(true))?;
|
|
let s2 = sender.send("schema", b"s2".to_vec(), rudp_send_options(true))?;
|
|
assert!(receiver.receive(&s2, 1)?.delivered.is_empty());
|
|
|
|
receiver.reset_peer_state();
|
|
let mut next = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 410,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let accept = receiver.accept_connect(&next.create_connect(0, Vec::new())?, 2, Vec::new())?;
|
|
next.receive(&accept, 2)?;
|
|
let d1 = next.send("schema", b"d1".to_vec(), rudp_send_options(true))?;
|
|
let d2 = next.send("schema", b"d2".to_vec(), rudp_send_options(true))?;
|
|
|
|
// The held s2 died with the reset, and the watermark follows the new peer.
|
|
assert!(receiver.receive(&d2, 3)?.delivered.is_empty());
|
|
assert_eq!(rudp_delivered_names(&receiver.receive(&d1, 4)?.delivered), ["d1", "d2"]);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_large_ordered_frame_without_a_gap_is_never_held_or_refused() -> Result<()> {
|
|
let (mut sender, mut receiver) = rudp_ordered_pair()?;
|
|
let packets = sender.send_many(
|
|
"schema",
|
|
vec![7u8; 5 * 1024 * 1024],
|
|
rudp_send_options(true),
|
|
Some(60_000),
|
|
)?;
|
|
let mut queue = packets;
|
|
let mut delivered = Vec::new();
|
|
while !queue.is_empty() {
|
|
let mut next = Vec::new();
|
|
for packet in &queue {
|
|
delivered.extend(receiver.receive(packet, 1)?.delivered);
|
|
let ack = receiver.create_ack_for_received(packet.sequence);
|
|
next.extend(sender.receive(&ack, 1)?.ready_to_send);
|
|
}
|
|
queue = next;
|
|
}
|
|
assert_eq!(delivered.len(), 1);
|
|
assert_eq!(delivered[0].payload.len(), 5 * 1024 * 1024);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_ordered_hold_buffer_is_bounded_in_bytes() -> Result<()> {
|
|
let (mut sender, mut receiver) = rudp_ordered_pair()?;
|
|
let withheld = sender.send("schema", b"first".to_vec(), rudp_send_options(true))?;
|
|
sender.receive(&receiver.create_ack_for(withheld.sequence), 1)?;
|
|
|
|
// Four 1 MiB frames fit the 4 MiB hold; the fifth does not.
|
|
let mut refused_at = None;
|
|
for step in 0..8u32 {
|
|
let packet = sender.send("schema", vec![7u8; 1024 * 1024], rudp_send_options(true))?;
|
|
match receiver.receive(&packet, 2) {
|
|
Ok(result) => {
|
|
assert!(result.delivered.is_empty(), "the gap holds every frame");
|
|
sender.receive(&receiver.create_ack_for(packet.sequence), 2)?;
|
|
}
|
|
Err(refusal) => {
|
|
assert!(refusal.to_string().contains("ordered hold buffer is full"), "{refusal}");
|
|
refused_at = Some(step);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert_eq!(refused_at, Some(4));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_far_ahead_sequence_cannot_starve_the_session() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 404,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 404,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 1, Vec::new())?;
|
|
sender.receive(&accept, 2)?;
|
|
|
|
let options = CultNetRudpSendOptions {
|
|
reliable: true,
|
|
..CultNetRudpSendOptions::default()
|
|
};
|
|
let first = sender.send("schema", b"before".to_vec(), options.clone())?;
|
|
assert_eq!(receiver.receive(&first, 3)?.delivered.len(), 1);
|
|
sender.receive(&receiver.create_ack_for(first.sequence), 3)?;
|
|
|
|
// A forged packet far past the current highest. Recording it would push the
|
|
// receive state beyond every sequence still in flight.
|
|
let forged = CultNetRudpPacket {
|
|
packet_type: CultNetRudpPacketType::Data,
|
|
connection_id: 404,
|
|
sequence: first.sequence.saturating_add(500_000),
|
|
ack: 0,
|
|
ack_mask: 0,
|
|
channel_id: "schema".to_string(),
|
|
reliable: true,
|
|
ordered: false,
|
|
sequenced: false,
|
|
fragment_id: 0,
|
|
fragment_index: 0,
|
|
fragment_count: 0,
|
|
payload: b"hostile".to_vec(),
|
|
};
|
|
assert!(receiver.receive(&forged, 4)?.delivered.is_empty());
|
|
|
|
// The session must still accept the next legitimate packet.
|
|
let second = sender.send("schema", b"after".to_vec(), options)?;
|
|
assert_eq!(
|
|
receiver.receive(&second, 5)?.delivered[0].payload,
|
|
b"after".to_vec()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_reliable_sequence_exhaustion_is_reported_not_fatal() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 405,
|
|
// One sequence left: the handshake takes it, so the next send is the
|
|
// first allocation with nowhere to go.
|
|
initial_sequence: Some(u32::MAX - 1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut receiver = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 405,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = sender.create_connect(0, Vec::new())?;
|
|
let accept = receiver.accept_connect(&connect, 1, Vec::new())?;
|
|
sender.receive(&accept, 2)?;
|
|
|
|
let error = sender
|
|
.send(
|
|
"schema",
|
|
b"one past the end".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("sequence space is exhausted"),
|
|
"unexpected error: {error}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn tcp_framed_receive_refuses_a_length_beyond_the_advertised_maximum() -> Result<()> {
|
|
let profile = create_tcp_framed_transport_profile(
|
|
"rust-transport",
|
|
TcpFramedTransportProfileOptions {
|
|
transport_id: Some("bounded-tcp".to_string()),
|
|
max_payload_bytes: Some(64),
|
|
..TcpFramedTransportProfileOptions::default()
|
|
},
|
|
);
|
|
let advertised = profile
|
|
.transports
|
|
.iter()
|
|
.find(|transport| transport.protocol == CultNetTransportProtocol::TcpFramed)
|
|
.and_then(|transport| {
|
|
transport
|
|
.channels
|
|
.iter()
|
|
.find(|channel| channel.channel_id == "schema")
|
|
})
|
|
.and_then(|channel| channel.max_payload_bytes)
|
|
.expect("tcp_framed schema channel advertises a maximum payload");
|
|
|
|
// Only a header, declaring more than the profile allows. Honouring it would
|
|
// reserve the peer's number before a single payload byte arrives.
|
|
let mut header = ((advertised as u64 + 1) as u32).to_be_bytes().to_vec();
|
|
header.extend_from_slice(b"short");
|
|
let mut receiver = TcpFramedTransportConnection::new(std::io::Cursor::new(header), profile);
|
|
let error = receiver.receive().unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("exceeds advertised maximum"),
|
|
"unexpected error: {error}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_reliable_expiry_drops_late_packets_and_leaves_durable_sends_alone() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 7,
|
|
initial_sequence: Some(10),
|
|
resend_delay_ms: 100,
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
accept_rudp_session(&mut sender, 7)?;
|
|
|
|
let expiring = sender.send(
|
|
"media",
|
|
b"late".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
now_ms: 0,
|
|
reliable_expire_after_ms: Some(50),
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
let durable = sender.send(
|
|
"schema",
|
|
b"state".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
now_ms: 0,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
assert_eq!(sender.pending_reliable_sequences().len(), 2);
|
|
assert_eq!(sender.reliable_packets_expired(), 0);
|
|
|
|
// One millisecond before the deadline nothing is dropped: the boundary is
|
|
// inclusive, so a packet is never discarded early.
|
|
sender.send(
|
|
"schema",
|
|
b"tick".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: false,
|
|
now_ms: 50,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
assert_eq!(sender.reliable_packets_expired(), 0);
|
|
|
|
sender.send(
|
|
"schema",
|
|
b"tick".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: false,
|
|
now_ms: 51,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
assert_eq!(sender.reliable_packets_expired(), 1);
|
|
assert_eq!(
|
|
sender.pending_reliable_sequences(),
|
|
vec![durable.sequence],
|
|
"a send without an expiry deadline is never dropped"
|
|
);
|
|
assert_ne!(expiring.sequence, durable.sequence);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_reliable_expiry_also_reclaims_the_send_backlog() -> Result<()> {
|
|
let mut sender = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 9,
|
|
initial_sequence: Some(10),
|
|
resend_delay_ms: 100,
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
accept_rudp_session(&mut sender, 9)?;
|
|
|
|
let fragment_count = CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS + 5;
|
|
let payload = (0..fragment_count * 8)
|
|
.map(|index| (index % 251) as u8)
|
|
.collect::<Vec<_>>();
|
|
sender.send_many(
|
|
"media",
|
|
payload,
|
|
CultNetRudpSendOptions {
|
|
reliable: true,
|
|
now_ms: 0,
|
|
reliable_expire_after_ms: Some(30),
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
Some(8),
|
|
)?;
|
|
assert_eq!(
|
|
sender.pending_reliable_sequences().len(),
|
|
CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS
|
|
);
|
|
assert_eq!(sender.queued_reliable_packet_count(), 5);
|
|
|
|
// The deadline is fixed at admission, so waiting in the backlog spends it
|
|
// exactly as waiting for an acknowledgement does. A queued packet promoted
|
|
// after its moment has passed would be as useless as a retransmitted one.
|
|
sender.send(
|
|
"schema",
|
|
b"tick".to_vec(),
|
|
CultNetRudpSendOptions {
|
|
reliable: false,
|
|
now_ms: 31,
|
|
..CultNetRudpSendOptions::default()
|
|
},
|
|
)?;
|
|
assert_eq!(sender.pending_reliable_sequences().len(), 0);
|
|
assert_eq!(sender.queued_reliable_packet_count(), 0);
|
|
assert_eq!(
|
|
sender.reliable_packets_expired(),
|
|
fragment_count as u64,
|
|
"both the in-flight window and the backlog are counted"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn accept_rudp_session(session: &mut CultNetRudpSession, _connection_id: u32) -> Result<()> {
|
|
session.assume_connected(0);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_profile_expires_only_the_media_channel() -> Result<()> {
|
|
let socket = std::net::UdpSocket::bind("127.0.0.1:0")?;
|
|
let peer = socket.local_addr()?;
|
|
let options = CultNetRudpSocketTransportOptions::client("expiry-profile", socket, peer, 11);
|
|
let connection = CultNetRudpSocketTransportConnection::new(options)?;
|
|
|
|
let channels = connection
|
|
.profile
|
|
.transports
|
|
.iter()
|
|
.flat_map(|transport| transport.channels.iter())
|
|
.map(|channel| (channel.channel_id.as_str(), channel.reliable_expire_after_ms))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
channels.contains(&("media", Some(75))),
|
|
"the media channel advertises its expiry so a peer can see it: {channels:?}"
|
|
);
|
|
for (channel_id, expiry) in &channels {
|
|
if *channel_id != "media" {
|
|
assert_eq!(
|
|
*expiry, None,
|
|
"state channel {channel_id} must never expire a reliable send"
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn connected_rudp_pair() -> Result<(CultNetRudpSession, CultNetRudpSession)> {
|
|
let mut client = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 311,
|
|
initial_sequence: Some(1),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
connection_id: 311,
|
|
initial_sequence: Some(100),
|
|
..CultNetRudpSessionOptions::default()
|
|
});
|
|
let connect = client.create_connect(0, Vec::new())?;
|
|
let accept = server.accept_connect(&connect, 1, Vec::new())?;
|
|
client.receive(&accept, 2)?;
|
|
Ok((client, server))
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_evicts_stalest_incomplete_fragment_set_instead_of_dying() -> Result<()> {
|
|
let (mut client, mut server) = connected_rudp_pair()?;
|
|
client.set_max_pending_fragment_sets(4)?;
|
|
let fragmented = |server: &mut CultNetRudpSession, fill: u8| {
|
|
server.send_many(
|
|
"media",
|
|
vec![fill; 2500],
|
|
CultNetRudpSendOptions::default(),
|
|
Some(1000),
|
|
)
|
|
};
|
|
|
|
// Eight payloads each lose their last fragment on the wire. Before, the
|
|
// fifth new set errored the receiver out and every later fragmented
|
|
// payload with it.
|
|
let mut stranded = Vec::new();
|
|
for fill in 1..=8u8 {
|
|
let packets = fragmented(&mut server, fill)?;
|
|
assert_eq!(packets.len(), 3);
|
|
for packet in &packets[..2] {
|
|
assert!(client.receive(packet, 10)?.delivered.is_empty());
|
|
}
|
|
stranded.push(packets[2].clone());
|
|
}
|
|
assert_eq!(client.fragment_sets_evicted(), 4);
|
|
|
|
// A complete payload still lands.
|
|
let complete = fragmented(&mut server, 9)?;
|
|
let mut delivered = Vec::new();
|
|
for packet in &complete {
|
|
delivered.extend(client.receive(packet, 11)?.delivered);
|
|
}
|
|
assert_eq!(delivered.len(), 1);
|
|
assert_eq!(delivered[0].payload, vec![9u8; 2500]);
|
|
assert_eq!(client.fragment_sets_evicted(), 5);
|
|
|
|
// The eviction order is stalest-first: set 8 is still pending and completes
|
|
// when its late fragment shows up; set 1 was evicted long ago, so its late
|
|
// fragment opens a fresh set and delivers nothing.
|
|
assert_eq!(
|
|
client.receive(&stranded[7], 12)?.delivered[0].payload,
|
|
vec![8u8; 2500]
|
|
);
|
|
assert!(client.receive(&stranded[0], 13)?.delivered.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn rudp_fragment_ids_wrap_instead_of_saturating() -> Result<()> {
|
|
let (_client, mut server) = connected_rudp_pair()?;
|
|
let mut ids = Vec::new();
|
|
for _ in 0..(u16::MAX as usize + 1) {
|
|
let packets = server.send_many(
|
|
"media",
|
|
vec![0u8; 1500],
|
|
CultNetRudpSendOptions::default(),
|
|
Some(1000),
|
|
)?;
|
|
ids.push(packets[0].fragment_id);
|
|
}
|
|
assert_eq!(ids[0], 1);
|
|
assert_eq!(ids[u16::MAX as usize - 1], u16::MAX);
|
|
// The 65,536th set must not share an id with the 65,535th.
|
|
assert_eq!(ids[u16::MAX as usize], 1);
|
|
Ok(())
|
|
}
|