CultLib/packages/cultmesh-rs/tests/rudp_document_server.rs
Meta Vens a71f62e997 cultmesh-rs: pin the refusal at a queue full to the brim, and a real EMSGSIZE
The queue-full test used an empty queue, so a reliable refusal fitted and
the reliable-refusal mutant lived. The new test fills a queue of 8
exactly with a reply the peer never acknowledges; the next reply cannot
be queued, and its refusal must still arrive, acknowledging nothing the
peer sent. A RawPeer helper drives the peer packet by packet; the gap
test uses it too.

A response too large for any datagram fails with the kernel's EMSGSIZE:
the rejection is ResponseSendFailed("EMSGSIZE"), the goodbye is exactly
"packet could not be sent: EMSGSIZE", and no refusal is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:01:33 +02:00

1721 lines
57 KiB
Rust

use anyhow::Result;
use cultmesh_rs::{
CultMeshRudpApplicationOperation, CultMeshRudpDocumentServer,
CultMeshRudpDocumentServerOptions, CultMeshRudpPollOutcome, CultMeshRudpRawDocumentReceipt,
CultMeshRudpRawDocumentSink, CultMeshRudpRejectionReason, CultMeshRudpServerClock,
CultMeshRudpSnapshotQuery, CultMeshRudpSnapshotSource,
};
use cultnet_rs::{
CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding,
CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode,
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
use pretty_assertions::assert_eq;
use std::net::{SocketAddr, UdpSocket};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
#[derive(Default)]
struct SinkState {
receipts: Vec<CultMeshRudpRawDocumentReceipt>,
failures_remaining: usize,
}
#[derive(Clone, Default)]
struct Sink(Arc<Mutex<SinkState>>);
impl Sink {
fn fail_once() -> Self {
Self(Arc::new(Mutex::new(SinkState {
failures_remaining: 1,
..Default::default()
})))
}
}
impl CultMeshRudpRawDocumentSink for Sink {
fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> {
let mut state = self.0.lock().unwrap();
if state.failures_remaining > 0 {
state.failures_remaining -= 1;
anyhow::bail!("injected sink failure CANARY-7f3a");
}
state.receipts.push(receipt);
Ok(())
}
}
#[derive(Default)]
struct SourceState {
documents: Vec<CultNetRawDocumentRecord>,
failures_remaining: usize,
calls: usize,
}
#[derive(Clone, Default)]
struct Source(Arc<Mutex<SourceState>>);
impl Source {
fn documents(documents: Vec<CultNetRawDocumentRecord>) -> Self {
Self(Arc::new(Mutex::new(SourceState {
documents,
..Default::default()
})))
}
fn fail_once(documents: Vec<CultNetRawDocumentRecord>) -> Self {
Self(Arc::new(Mutex::new(SourceState {
documents,
failures_remaining: 1,
..Default::default()
})))
}
}
/// `document` without the source provenance a peer reports about itself.
fn stripped(document: &CultNetRawDocumentRecord) -> CultNetRawDocumentRecord {
CultNetRawDocumentRecord {
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
..document.clone()
}
}
impl CultMeshRudpSnapshotSource for Source {
fn raw_snapshot(
&mut self,
_: &CultMeshRudpSnapshotQuery,
) -> Result<Vec<CultNetRawDocumentRecord>> {
let mut state = self.0.lock().unwrap();
state.calls += 1;
if state.failures_remaining > 0 {
state.failures_remaining -= 1;
anyhow::bail!("injected source failure CANARY-7f3a");
}
Ok(state.documents.clone())
}
}
/// A source that serves `Source`'s records without source provenance, and says
/// so to admission through `served_record`. `Source` keeps the default.
#[derive(Clone, Default)]
struct StrippingSource {
inner: Source,
/// Refuses to say what it would serve.
served_record_fails: bool,
}
impl CultMeshRudpSnapshotSource for StrippingSource {
fn raw_snapshot(
&mut self,
query: &CultMeshRudpSnapshotQuery,
) -> Result<Vec<CultNetRawDocumentRecord>> {
Ok(self.inner.raw_snapshot(query)?.iter().map(stripped).collect())
}
fn served_record(
&mut self,
document: &CultNetRawDocumentRecord,
) -> Result<CultNetRawDocumentRecord> {
if self.served_record_fails {
anyhow::bail!("injected served-record failure");
}
Ok(stripped(document))
}
}
fn stripping_server(
options: CultMeshRudpDocumentServerOptions,
sink: Sink,
source: StrippingSource,
) -> Result<CultMeshRudpDocumentServer<Sink, StrippingSource, Clock>> {
CultMeshRudpDocumentServer::new(
UdpSocket::bind("127.0.0.1:0")?,
sink,
source,
Clock::new(64_000),
options,
)
}
#[derive(Clone)]
struct Clock {
unix: Arc<AtomicU64>,
monotonic: Arc<AtomicU64>,
}
impl Clock {
fn new(now: u64) -> Self {
Self {
unix: Arc::new(AtomicU64::new(now)),
monotonic: Arc::new(AtomicU64::new(now)),
}
}
fn set(&self, now: u64) {
self.unix.store(now, Ordering::SeqCst);
self.monotonic.store(now, Ordering::SeqCst);
}
fn set_unix(&self, now: u64) {
self.unix.store(now, Ordering::SeqCst);
}
fn set_monotonic(&self, now: u64) {
self.monotonic.store(now, Ordering::SeqCst);
}
}
impl CultMeshRudpServerClock for Clock {
fn now_unix_millis(&self) -> u64 {
self.unix.load(Ordering::SeqCst)
}
fn now_monotonic_millis(&self) -> u64 {
self.monotonic.load(Ordering::SeqCst)
}
}
type Server = CultMeshRudpDocumentServer<Sink, Source, Clock>;
fn document(key: &str, payload: Vec<u8>) -> CultNetRawDocumentRecord {
CultNetRawDocumentRecord {
schema_id: "test.raw.v1".into(),
record_key: key.into(),
stored_at: "2026-09-03T00:00:00Z".into(),
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
payload,
source_runtime_id: Some(format!("runtime-{key}")),
source_agent_id: None,
source_role: None,
tags: None,
}
}
fn server(
options: CultMeshRudpDocumentServerOptions,
clock: Clock,
sink: Sink,
source: Source,
) -> Result<Server> {
CultMeshRudpDocumentServer::new(
UdpSocket::bind("127.0.0.1:0")?,
sink,
source,
clock,
options,
)
}
fn client(target: SocketAddr, id: u32) -> Result<CultNetRudpSocketTransportConnection> {
client_on(UdpSocket::bind("127.0.0.1:0")?, target, id, 1)
}
fn client_on(
socket: UdpSocket,
target: SocketAddr,
id: u32,
initial_sequence: u32,
) -> Result<CultNetRudpSocketTransportConnection> {
socket.set_nonblocking(true)?;
CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions {
media_delivery: None,
runtime_id: "test-client".into(),
socket,
mode: CultNetRudpSocketMode::Client,
remote_addr: Some(target),
connection_id: id,
initial_sequence: Some(initial_sequence),
resend_delay_ms: 10,
transport_id: None,
max_payload_bytes: None,
max_fragment_bytes: Some(1200),
max_pending_reliable_packets: Some(64),
media_reliable_expire_after_ms: None,
reconnect_policy: None,
})
}
fn connect<Q: CultMeshRudpSnapshotSource>(
server: &mut CultMeshRudpDocumentServer<Sink, Q, Clock>,
clients: &mut [&mut CultNetRudpSocketTransportConnection],
) -> Result<()> {
for client in &mut *clients {
client.connect(Vec::new())?;
}
for _ in 0..500 {
server.poll_once()?;
for client in &mut *clients {
client.receive_once()?;
}
if clients.iter().all(|client| client.connected()) {
for _ in 0..clients.len() {
server.poll_once()?;
}
return Ok(());
}
thread::sleep(Duration::from_millis(1));
}
anyhow::bail!("client connection timed out")
}
/// The encoded size of the snapshot response that carries `document` alone
/// under a one-character message id, the shortest CultNet encodes: the
/// smallest response that could ever serve it.
fn served_alone_bytes(document: &CultNetRawDocumentRecord) -> Result<usize> {
Ok(encode_cultnet_message_to_vec(
&CultNetMessage::SnapshotResponseRaw {
message_id: "r".into(),
documents: vec![document.clone()],
},
CultNetWireContract::CultNetSchemaV0,
)?
.len())
}
/// A document keyed `key` whose served-alone response is exactly `bytes`.
fn document_served_at(key: &str, bytes: usize) -> Result<CultNetRawDocumentRecord> {
let probe = bytes / 2;
let base = served_alone_bytes(&document(key, vec![7; probe]))?;
let fitted = document(key, vec![7; probe + bytes - base]);
assert_eq!(
served_alone_bytes(&fitted)?,
bytes,
"fixture: the served size is linear in the payload"
);
Ok(fitted)
}
/// Polls the server and client until the client receives a frame.
fn serve_until_frame<Q: CultMeshRudpSnapshotSource>(
server: &mut CultMeshRudpDocumentServer<Sink, Q, Clock>,
client: &mut CultNetRudpSocketTransportConnection,
) -> Result<Vec<u8>> {
for _ in 0..2_000 {
let outcome = server.poll_once()?;
assert!(
!matches!(outcome, CultMeshRudpPollOutcome::ApplicationRejected(_)),
"{outcome:?}"
);
if let Some(frame) = client.receive_once()? {
return Ok(frame.payload);
}
thread::sleep(Duration::from_millis(1));
}
anyhow::bail!("the client received no frame")
}
/// Polls the server, and the client so its sends progress, until the server
/// returns a rejection or the sink holds `receipts`.
fn poll_until_rejected_or_stored<Q: CultMeshRudpSnapshotSource>(
server: &mut CultMeshRudpDocumentServer<Sink, Q, Clock>,
client: &mut CultNetRudpSocketTransportConnection,
sink: &Sink,
receipts: usize,
) -> Result<Option<CultMeshRudpRejectionReason>> {
for _ in 0..5_000 {
client.receive_once()?;
client.poll_resends()?;
match server.poll_once()? {
CultMeshRudpPollOutcome::ApplicationRejected(rejection) => {
return Ok(Some(rejection.reason));
}
CultMeshRudpPollOutcome::Idle => thread::sleep(Duration::from_millis(1)),
_ => {}
}
if sink.0.lock().unwrap().receipts.len() >= receipts {
return Ok(None);
}
}
anyhow::bail!("the put was neither refused nor stored")
}
/// What a refused peer is sent: a `cultnet.error.v0` carrying fixed text, never
/// a sink's or source's error (the fixtures' errors carry a canary), then the goodbye. The
/// refused message is never acknowledged, so its receipt stays pending until the
/// goodbye invalidates it. Returns the refusal's text.
fn refusal_seen_by(
client: &mut CultNetRudpSocketTransportConnection,
receipt: &CultNetRudpReliableSendReceipt,
) -> Result<String> {
let mut refusal = None;
for _ in 0..2_000 {
if let Some(frame) = client.receive_once()? {
refusal = Some(decode_cultnet_message_from_slice(
&frame.payload,
CultNetWireContract::CultNetSchemaV0,
)?);
break;
}
thread::sleep(Duration::from_millis(1));
}
let Some(CultNetMessage::Error {
error,
code,
details,
}) = refusal
else {
panic!("the refused peer was sent no refusal: {refusal:?}");
};
assert_eq!((code, details), (None, None));
assert!(
!error.contains("CANARY"),
"a sink's or source's text reached the peer: {error}"
);
assert_eq!(
client.reliable_send_status(receipt),
CultNetRudpReliableSendStatus::Pending,
"a refusal must not acknowledge the refused message"
);
for _ in 0..2_000 {
client.receive_once()?;
if !client.connected() {
break;
}
thread::sleep(Duration::from_millis(1));
}
assert!(!client.connected(), "the refusal is followed by a goodbye");
assert_eq!(
client.reliable_send_status(receipt),
CultNetRudpReliableSendStatus::Invalidated
);
Ok(error)
}
/// Reads every datagram waiting at the server, such as a refused peer's
/// acknowledgement of its refusal, which reaches no session and is dropped.
fn drain_until_idle<Q: CultMeshRudpSnapshotSource>(
server: &mut CultMeshRudpDocumentServer<Sink, Q, Clock>,
) -> Result<()> {
for _ in 0..100 {
match server.poll_once()? {
CultMeshRudpPollOutcome::Idle => return Ok(()),
CultMeshRudpPollOutcome::Handled => {}
rejected => panic!("draining must not reject: {rejected:?}"),
}
}
anyhow::bail!("the server never went idle")
}
fn send(client: &mut CultNetRudpSocketTransportConnection, message: &CultNetMessage) -> Result<()> {
client.send(
"schema",
encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?,
)
}
fn send_reliable(
client: &mut CultNetRudpSocketTransportConnection,
message: &CultNetMessage,
) -> Result<CultNetRudpReliableSendReceipt> {
client.send_reliable(
"schema",
encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?,
)
}
#[test]
fn same_connection_id_is_peer_scoped_and_raw_bytes_are_untouched() -> Result<()> {
let clock = Clock::new(42_000);
let sink = Sink::default();
let mut server = server(Default::default(), clock, sink.clone(), Source::default())?;
let mut a = client(server.local_addr()?, 77)?;
let mut b = client(server.local_addr()?, 77)?;
connect(&mut server, &mut [&mut a, &mut b])?;
let a_bytes = vec![0xc4, 0x04, 0x00, 0xff, 0x80, 0x01];
let b_bytes = vec![0x92, 0x01, 0xc4, 0x02, 0x00, 0xff];
send(
&mut a,
&CultNetMessage::DocumentPutRaw {
message_id: "a".into(),
document: document("a", a_bytes.clone()),
},
)?;
send(
&mut b,
&CultNetMessage::DocumentPutRaw {
message_id: "b".into(),
document: document("b", b_bytes.clone()),
},
)?;
for _ in 0..500 {
server.poll_once()?;
a.receive_once()?;
b.receive_once()?;
if sink.0.lock().unwrap().receipts.len() == 2 {
break;
}
}
let receipts = sink.0.lock().unwrap();
assert_eq!(server.session_count(), 2);
assert_eq!(receipts.receipts.len(), 2);
assert_ne!(
receipts.receipts[0].session.remote_addr,
receipts.receipts[1].session.remote_addr
);
assert_eq!(
receipts
.receipts
.iter()
.find(|r| r.message_id == "a")
.unwrap()
.document
.payload,
a_bytes
);
assert_eq!(
receipts
.receipts
.iter()
.find(|r| r.message_id == "b")
.unwrap()
.document
.payload,
b_bytes
);
assert!(
receipts
.receipts
.iter()
.all(|r| r.received_at_unix_millis == 42_000)
);
Ok(())
}
#[test]
fn a_second_connect_from_a_client_replaces_its_session_and_a_fresh_epoch_is_separate() -> Result<()>
{
let clock = Clock::new(45_000);
let sink = Sink::default();
let mut server = server(Default::default(), clock, sink.clone(), Source::default())?;
let target = server.local_addr()?;
let mut incumbent = client(target, 91)?;
connect(&mut server, &mut [&mut incumbent])?;
incumbent.connect(Vec::new())?;
server.poll_once()?;
incumbent.receive_once()?;
assert_eq!(server.session_count(), 1);
send(
&mut incumbent,
&CultNetMessage::DocumentPutRaw {
message_id: "after-duplicate".into(),
document: document("incumbent", vec![1, 2, 3]),
},
)?;
for _ in 0..20 {
server.poll_once()?;
if sink.0.lock().unwrap().receipts.len() == 1 {
break;
}
}
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
let mut fresh_epoch = client(target, 92)?;
connect(&mut server, &mut [&mut fresh_epoch])?;
assert_eq!(server.session_count(), 2);
Ok(())
}
/// A client that restarts on the same address and connection id starts a new
/// session: its Connect carries a sequence the accepted one did not, so the
/// server does not mistake it for a retransmit, and what it sends is delivered.
#[test]
fn a_restarted_client_on_the_same_address_and_connection_id_is_admitted() -> Result<()> {
let sink = Sink::default();
let mut server = server(
Default::default(),
Clock::new(46_000),
sink.clone(),
Source::default(),
)?;
let target = server.local_addr()?;
let shared = UdpSocket::bind("127.0.0.1:0")?;
let twin = shared.try_clone()?;
let mut first = client_on(shared, target, 93, 50_000)?;
connect(&mut server, &mut [&mut first])?;
for index in 0..3 {
send(
&mut first,
&CultNetMessage::DocumentPutRaw {
message_id: format!("before-{index}"),
document: document("before", vec![index]),
},
)?;
}
for _ in 0..40 {
server.poll_once()?;
first.receive_once()?;
}
assert_eq!(sink.0.lock().unwrap().receipts.len(), 3);
let mut restarted = client_on(twin, target, 93, 7)?;
connect(&mut server, &mut [&mut restarted])?;
assert_eq!(server.session_count(), 1);
send(
&mut restarted,
&CultNetMessage::DocumentPutRaw {
message_id: "after-restart".into(),
document: document("after", vec![9]),
},
)?;
for _ in 0..40 {
server.poll_once()?;
restarted.receive_once()?;
}
let receipts = sink.0.lock().unwrap();
assert!(
receipts
.receipts
.iter()
.any(|r| r.message_id == "after-restart"),
"the restarted client's frame was not delivered"
);
Ok(())
}
/// A restarted client is a new session, and starts with a new payload budget:
/// what the old one spent is not charged to it.
#[test]
fn a_restarted_client_starts_with_a_fresh_payload_budget() -> Result<()> {
let put = |id: &str| CultNetMessage::DocumentPutRaw {
message_id: id.into(),
document: document("budget", vec![7; 64]),
};
let encoded =
encode_cultnet_message_to_vec(&put("put-a"), CultNetWireContract::CultNetSchemaV0)?;
let budget = encoded
.len()
.max(served_alone_bytes(&document("budget", vec![7; 64]))?);
let options = CultMeshRudpDocumentServerOptions {
max_admitted_payload_bytes: budget * 2,
max_admitted_payload_bytes_per_session: budget,
max_snapshot_response_bytes: budget,
..Default::default()
};
let sink = Sink::default();
let mut server = server(options, Clock::new(47_000), sink.clone(), Source::default())?;
let target = server.local_addr()?;
let shared = UdpSocket::bind("127.0.0.1:0")?;
let twin = shared.try_clone()?;
let mut first = client_on(shared, target, 94, 50_000)?;
connect(&mut server, &mut [&mut first])?;
send(&mut first, &put("put-a"))?;
for _ in 0..20 {
server.poll_once()?;
}
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
let mut restarted = client_on(twin, target, 94, 7)?;
connect(&mut server, &mut [&mut restarted])?;
send(&mut restarted, &put("put-b"))?;
for _ in 0..20 {
server.poll_once()?;
}
assert_eq!(
sink.0.lock().unwrap().receipts.len(),
2,
"the restarted client was charged what the old session spent"
);
Ok(())
}
#[test]
fn application_rejection_is_nonfatal_peer_scoped_refused_to_the_peer_and_unacknowledged()
-> Result<()> {
let clock = Clock::new(48_000);
let sink = Sink::fail_once();
let source = Source::fail_once(vec![document("snapshot", vec![4, 5, 6])]);
let mut server = server(
Default::default(),
clock.clone(),
sink.clone(),
source.clone(),
)?;
let target = server.local_addr()?;
let mut publisher = client(target, 101)?;
let mut snapshot_client = client(target, 102)?;
let mut survivor = client(target, 103)?;
connect(
&mut server,
&mut [&mut publisher, &mut snapshot_client, &mut survivor],
)?;
let publish_receipt = send_reliable(
&mut publisher,
&CultNetMessage::DocumentPutRaw {
message_id: "rejected-put".into(),
document: document("rejected", vec![0, 255, 1]),
},
)?;
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else {
panic!("sink rejection must be returned as a nonfatal poll outcome");
};
assert_eq!(rejection.session.connection_id, 101);
assert_eq!(
rejection.operation,
CultMeshRudpApplicationOperation::DocumentPutRaw
);
assert_eq!(rejection.message_id, "rejected-put");
assert_eq!(
rejection.reason,
CultMeshRudpRejectionReason::SinkRefused("injected sink failure CANARY-7f3a".into())
);
assert_eq!(server.session_count(), 2);
// The caller keeps the sink's text; the peer is sent fixed text, never it.
assert_eq!(
refusal_seen_by(&mut publisher, &publish_receipt)?,
"the catalog refused the document"
);
drain_until_idle(&mut server)?;
let snapshot_receipt = send_reliable(
&mut snapshot_client,
&CultNetMessage::SnapshotRequest {
message_id: "rejected-snapshot".into(),
schema_ids: None,
record_keys: None,
},
)?;
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else {
panic!("source rejection must be returned as a nonfatal poll outcome");
};
assert_eq!(rejection.session.connection_id, 102);
assert_eq!(
rejection.operation,
CultMeshRudpApplicationOperation::SnapshotRequest
);
assert_eq!(rejection.message_id, "rejected-snapshot");
assert_eq!(
rejection.reason,
CultMeshRudpRejectionReason::SnapshotSourceFailed(
"injected source failure CANARY-7f3a".into()
)
);
assert_eq!(server.session_count(), 1);
assert_eq!(
refusal_seen_by(&mut snapshot_client, &snapshot_receipt)?,
"the snapshot source failed"
);
drain_until_idle(&mut server)?;
let survivor_receipt = send_reliable(
&mut survivor,
&CultNetMessage::DocumentPutRaw {
message_id: "accepted-after-rejections".into(),
document: document("survivor", vec![9, 8, 7]),
},
)?;
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
for _ in 0..20 {
survivor.receive_once()?;
if survivor.reliable_send_status(&survivor_receipt)
== CultNetRudpReliableSendStatus::Acknowledged
{
break;
}
}
assert_eq!(
survivor.reliable_send_status(&survivor_receipt),
CultNetRudpReliableSendStatus::Acknowledged
);
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
assert_eq!(source.0.lock().unwrap().calls, 1);
Ok(())
}
#[test]
fn snapshot_response_resends_until_acknowledged() -> Result<()> {
let clock = Clock::new(51_000);
let expected = document("catalog", vec![0x81, 0xa1, 0x78, 0x2a]);
let source = Source::documents(vec![expected.clone()]);
let options = CultMeshRudpDocumentServerOptions {
resend_delay: Duration::from_millis(10),
..Default::default()
};
let mut server = server(options, clock.clone(), Sink::default(), source)?;
let mut client = client(server.local_addr()?, 88)?;
connect(&mut server, &mut [&mut client])?;
send(
&mut client,
&CultNetMessage::SnapshotRequest {
message_id: "snapshot".into(),
schema_ids: Some(vec!["test.raw.v1".into()]),
record_keys: Some(vec!["catalog".into()]),
},
)?;
server.poll_once()?;
clock.set(51_011);
assert_eq!(server.maintain()?.packets_resent, 1);
let response = loop {
if let Some(frame) = client.receive_once()? {
break decode_cultnet_message_from_slice(
&frame.payload,
CultNetWireContract::CultNetSchemaV0,
)?;
}
};
assert_eq!(
response,
CultNetMessage::SnapshotResponseRaw {
message_id: "snapshot".into(),
documents: vec![expected],
}
);
for _ in 0..20 {
server.poll_once()?;
}
clock.set(51_022);
assert_eq!(server.maintain()?.packets_resent, 0);
Ok(())
}
#[test]
fn payload_and_snapshot_output_budgets_fail_closed() -> Result<()> {
let clock = Clock::new(55_000);
let sink = Sink::default();
// The message id is long enough that the put's frame is at least its
// served-alone response, so the snapshot limit below admits it.
let message = CultNetMessage::DocumentPutRaw {
message_id: "budget-exact".into(),
document: document("budget", vec![7; 64]),
};
let encoded = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?;
// The put's frame is exactly both budgets, so the first put is admitted only
// if a charge equal to a limit fits it.
let budget = encoded.len();
assert!(
served_alone_bytes(&document("budget", vec![7; 64]))? <= budget,
"fixture: the snapshot limit must admit the put"
);
let options = CultMeshRudpDocumentServerOptions {
max_admitted_payload_bytes: budget,
max_admitted_payload_bytes_per_session: budget,
max_snapshot_response_bytes: budget,
..Default::default()
};
let mut budget_server = server(options, clock, sink.clone(), Source::default())?;
let target = budget_server.local_addr()?;
let mut first = client(target, 111)?;
let mut second = client(target, 112)?;
connect(&mut budget_server, &mut [&mut first, &mut second])?;
send(&mut first, &message)?;
budget_server.poll_once()?;
send(&mut second, &message)?;
budget_server.poll_once()?;
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
let oversized = Source::documents(vec![document("oversized", vec![8; 512])]);
let options = CultMeshRudpDocumentServerOptions {
max_snapshot_response_bytes: 128,
..Default::default()
};
let mut snapshot_server = server(options, Clock::new(56_000), Sink::default(), oversized)?;
let mut snapshot_client = client(snapshot_server.local_addr()?, 113)?;
connect(&mut snapshot_server, &mut [&mut snapshot_client])?;
let receipt = send_reliable(
&mut snapshot_client,
&CultNetMessage::SnapshotRequest {
message_id: "too-large".into(),
schema_ids: None,
record_keys: None,
},
)?;
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = snapshot_server.poll_once()?
else {
panic!("oversized snapshot must reject only its session");
};
assert_eq!(
rejection.operation,
CultMeshRudpApplicationOperation::SnapshotRequest
);
assert_eq!(rejection.message_id, "too-large");
assert!(matches!(
rejection.reason,
CultMeshRudpRejectionReason::SnapshotResponseTooLarge {
response_bytes,
max_snapshot_response_bytes: 128,
} if response_bytes > 128
));
assert_eq!(snapshot_server.session_count(), 0);
assert_eq!(
refusal_seen_by(&mut snapshot_client, &receipt)?,
"the snapshot response is too large"
);
Ok(())
}
/// A put is admitted only if some snapshot request could return it. One byte
/// over the served bound is refused before the sink sees it; exactly at the
/// bound is admitted, and a snapshot then serves it at exactly that size. The
/// source keeps the default `served_record`, so the record is sized as received.
#[test]
fn a_put_the_server_could_never_serve_is_refused_and_one_at_the_bound_is_served() -> Result<()> {
let at_bound = document("fit", vec![7; 100]);
let over_bound = document("fit", vec![7; 101]);
let limit = served_alone_bytes(&at_bound)?;
assert_eq!(
served_alone_bytes(&over_bound)?,
limit + 1,
"fixture: the two documents must straddle the bound by one byte"
);
let options = CultMeshRudpDocumentServerOptions {
max_snapshot_response_bytes: limit,
..Default::default()
};
let sink = Sink::default();
let source = Source::default();
let mut server = server(options, Clock::new(60_000), sink.clone(), source.clone())?;
let target = server.local_addr()?;
let mut refused = client(target, 121)?;
let mut writer = client(target, 122)?;
connect(&mut server, &mut [&mut refused, &mut writer])?;
let refused_receipt = send_reliable(
&mut refused,
&CultNetMessage::DocumentPutRaw {
message_id: "put-over".into(),
document: over_bound,
},
)?;
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else {
panic!("an unservable put must be refused as an application rejection");
};
assert_eq!(rejection.session.connection_id, 121);
assert_eq!(
rejection.operation,
CultMeshRudpApplicationOperation::DocumentPutRaw
);
assert_eq!(rejection.message_id, "put-over");
assert_eq!(
rejection.reason,
CultMeshRudpRejectionReason::DocumentUnservable {
response_bytes: limit + 1,
max_snapshot_response_bytes: limit,
fragment_count: 1,
max_fragment_count: 1024,
}
);
let sentence = rejection.reason.to_string();
assert!(sentence.contains(&(limit + 1).to_string()) && sentence.contains(&limit.to_string()));
assert!(sink.0.lock().unwrap().receipts.is_empty());
assert_eq!(server.session_count(), 1);
// The peer hears the refusal and why, rather than waiting on a put that
// will never be acknowledged.
assert_eq!(
refusal_seen_by(&mut refused, &refused_receipt)?,
"the document can never be served"
);
send(
&mut writer,
&CultNetMessage::DocumentPutRaw {
message_id: "put-at".into(),
document: at_bound.clone(),
},
)?;
for _ in 0..500 {
let outcome = server.poll_once()?;
assert!(
!matches!(outcome, CultMeshRudpPollOutcome::ApplicationRejected(_)),
"{outcome:?}"
);
if !sink.0.lock().unwrap().receipts.is_empty() {
break;
}
thread::sleep(Duration::from_millis(1));
}
let stored: Vec<_> = sink
.0
.lock()
.unwrap()
.receipts
.iter()
.map(|receipt| receipt.document.clone())
.collect();
assert_eq!(stored, vec![at_bound.clone()]);
source.0.lock().unwrap().documents = stored;
send(
&mut writer,
&CultNetMessage::SnapshotRequest {
message_id: "r".into(),
schema_ids: None,
record_keys: None,
},
)?;
let mut served = None;
for _ in 0..500 {
let outcome = server.poll_once()?;
assert!(
!matches!(outcome, CultMeshRudpPollOutcome::ApplicationRejected(_)),
"{outcome:?}"
);
if let Some(frame) = writer.receive_once()? {
served = Some(frame.payload);
break;
}
thread::sleep(Duration::from_millis(1));
}
let served = served.expect("the admitted document must be served");
assert_eq!(served.len(), limit);
assert_eq!(
decode_cultnet_message_from_slice(&served, CultNetWireContract::CultNetSchemaV0)?,
CultNetMessage::SnapshotResponseRaw {
message_id: "r".into(),
documents: vec![at_bound],
}
);
Ok(())
}
/// A source that serves records without provenance says so through
/// `served_record`, and admission sizes that: a put over the bound only in its
/// received form is admitted and served at exactly the bound.
#[test]
fn a_put_is_sized_as_its_source_would_serve_it() -> Result<()> {
let provenance = |mut document: CultNetRawDocumentRecord| {
document.source_agent_id = Some("provenance-agent-that-is-not-served".into());
document.source_role = Some("provenance-role".into());
document.tags = Some(vec!["provenance".into(), "tags".into()]);
document
};
let at_bound = provenance(document("fit", vec![7; 100]));
let over_bound = provenance(document("fit", vec![7; 101]));
let limit = served_alone_bytes(&stripped(&at_bound))?;
assert_eq!(
served_alone_bytes(&stripped(&over_bound))?,
limit + 1,
"fixture: the two documents must straddle the bound by one byte as served"
);
assert!(
served_alone_bytes(&at_bound)? > limit + 1,
"fixture: the at-bound put is over the bound as received"
);
let options = CultMeshRudpDocumentServerOptions {
max_snapshot_response_bytes: limit,
..Default::default()
};
let sink = Sink::default();
let source = StrippingSource::default();
let mut server = stripping_server(options, sink.clone(), source.clone())?;
let target = server.local_addr()?;
let mut refused = client(target, 151)?;
connect(&mut server, &mut [&mut refused])?;
send(
&mut refused,
&CultNetMessage::DocumentPutRaw {
message_id: "put-over".into(),
document: over_bound,
},
)?;
assert_eq!(
poll_until_rejected_or_stored(&mut server, &mut refused, &sink, 1)?,
Some(CultMeshRudpRejectionReason::DocumentUnservable {
response_bytes: limit + 1,
max_snapshot_response_bytes: limit,
fragment_count: 1,
max_fragment_count: 1024,
})
);
let mut writer = client(target, 152)?;
connect(&mut server, &mut [&mut writer])?;
send(
&mut writer,
&CultNetMessage::DocumentPutRaw {
message_id: "put-at".into(),
document: at_bound.clone(),
},
)?;
assert_eq!(
poll_until_rejected_or_stored(&mut server, &mut writer, &sink, 1)?,
None
);
assert_eq!(
sink.0.lock().unwrap().receipts[0].document,
at_bound,
"the sink receives the record as it was sent"
);
source.inner.0.lock().unwrap().documents = vec![at_bound.clone()];
send(
&mut writer,
&CultNetMessage::SnapshotRequest {
message_id: "r".into(),
schema_ids: None,
record_keys: None,
},
)?;
let served = serve_until_frame(&mut server, &mut writer)?;
assert_eq!(served.len(), limit);
assert_eq!(
decode_cultnet_message_from_slice(&served, CultNetWireContract::CultNetSchemaV0)?,
CultNetMessage::SnapshotResponseRaw {
message_id: "r".into(),
documents: vec![stripped(&at_bound)],
}
);
Ok(())
}
/// A source that cannot say what it would serve refuses the put.
#[test]
fn a_put_whose_served_record_the_source_cannot_give_is_refused() -> Result<()> {
let sink = Sink::default();
let source = StrippingSource {
served_record_fails: true,
..Default::default()
};
let mut server = stripping_server(Default::default(), sink.clone(), source)?;
let mut refused = client(server.local_addr()?, 161)?;
connect(&mut server, &mut [&mut refused])?;
send(
&mut refused,
&CultNetMessage::DocumentPutRaw {
message_id: "put".into(),
document: document("unsized", vec![1, 2, 3]),
},
)?;
assert_eq!(
poll_until_rejected_or_stored(&mut server, &mut refused, &sink, 1)?,
Some(CultMeshRudpRejectionReason::SnapshotSourceFailed(
"injected served-record failure".into()
))
);
assert!(sink.0.lock().unwrap().receipts.is_empty());
Ok(())
}
/// A put is admitted only if its served response fits the fragments one
/// response may take: here eight of 100 bytes, the reliable queue, although the
/// byte limit is far larger. Eight fragments are admitted and served; nine are
/// refused, and so is Soul's 2000-byte probe, each naming its sizes.
#[test]
fn a_put_whose_response_would_overflow_the_reliable_queue_is_refused() -> Result<()> {
let options = CultMeshRudpDocumentServerOptions {
max_fragment_bytes: 100,
max_pending_reliable_packets_per_session: 8,
max_snapshot_response_bytes: 8192,
..Default::default()
};
let sink = Sink::default();
let source = Source::default();
let mut server = server(options, Clock::new(61_000), sink.clone(), source.clone())?;
let target = server.local_addr()?;
let unservable = |response_bytes, fragment_count| {
Some(CultMeshRudpRejectionReason::DocumentUnservable {
response_bytes,
max_snapshot_response_bytes: 8192,
fragment_count,
max_fragment_count: 8,
})
};
for (id, bytes, fragments) in [(131, 2000, 20), (132, 801, 9)] {
let mut refused = client(target, id)?;
connect(&mut server, &mut [&mut refused])?;
send(
&mut refused,
&CultNetMessage::DocumentPutRaw {
message_id: "put-over".into(),
document: document_served_at("queue", bytes)?,
},
)?;
let reason = poll_until_rejected_or_stored(&mut server, &mut refused, &sink, 1)?;
assert_eq!(reason, unservable(bytes, fragments));
let sentence = reason.unwrap().to_string();
assert!(
sentence.contains(&format!("{bytes} bytes in {fragments} fragments"))
&& sentence.contains("8 fragments"),
"{sentence}"
);
}
assert!(sink.0.lock().unwrap().receipts.is_empty());
let at_bound = document_served_at("queue", 800)?;
let mut writer = client(target, 133)?;
connect(&mut server, &mut [&mut writer])?;
send(
&mut writer,
&CultNetMessage::DocumentPutRaw {
message_id: "put-at".into(),
document: at_bound.clone(),
},
)?;
assert_eq!(
poll_until_rejected_or_stored(&mut server, &mut writer, &sink, 1)?,
None
);
source.0.lock().unwrap().documents = vec![at_bound.clone()];
send(
&mut writer,
&CultNetMessage::SnapshotRequest {
message_id: "r".into(),
schema_ids: None,
record_keys: None,
},
)?;
let served = serve_until_frame(&mut server, &mut writer)?;
assert_eq!(served.len(), 800);
assert_eq!(
decode_cultnet_message_from_slice(&served, CultNetWireContract::CultNetSchemaV0)?,
CultNetMessage::SnapshotResponseRaw {
message_id: "r".into(),
documents: vec![at_bound],
}
);
Ok(())
}
/// One message carries at most 65535 fragments, however deep the reliable
/// queue: a response of 65536 one-byte fragments is refused, one of 65535 is not.
#[test]
fn a_put_whose_response_needs_more_than_65535_fragments_is_refused() -> Result<()> {
let options = CultMeshRudpDocumentServerOptions {
max_fragment_bytes: 1,
max_pending_reliable_packets_per_session: 100_000,
max_snapshot_response_bytes: 70_000,
..Default::default()
};
let sink = Sink::default();
let mut server = server(options, Clock::new(62_000), sink.clone(), Source::default())?;
let target = server.local_addr()?;
let mut refused = client(target, 141)?;
connect(&mut server, &mut [&mut refused])?;
send(
&mut refused,
&CultNetMessage::DocumentPutRaw {
message_id: "put-over".into(),
document: document_served_at("wide", 65_536)?,
},
)?;
assert_eq!(
poll_until_rejected_or_stored(&mut server, &mut refused, &sink, 1)?,
Some(CultMeshRudpRejectionReason::DocumentUnservable {
response_bytes: 65_536,
max_snapshot_response_bytes: 70_000,
fragment_count: 65_536,
max_fragment_count: 65_535,
})
);
let mut writer = client(target, 142)?;
connect(&mut server, &mut [&mut writer])?;
send(
&mut writer,
&CultNetMessage::DocumentPutRaw {
message_id: "put-at".into(),
document: document_served_at("wide", 65_535)?,
},
)?;
assert_eq!(
poll_until_rejected_or_stored(&mut server, &mut writer, &sink, 1)?,
None
);
Ok(())
}
/// The limits must leave room for an empty snapshot response, the smallest the
/// server sends; exactly enough room is accepted.
#[test]
fn options_that_cannot_carry_an_empty_snapshot_response_are_refused() -> Result<()> {
let empty = encode_cultnet_message_to_vec(
&CultNetMessage::SnapshotResponseRaw {
message_id: "r".into(),
documents: Vec::new(),
},
CultNetWireContract::CultNetSchemaV0,
)?
.len();
let build = |options: CultMeshRudpDocumentServerOptions| {
server(options, Clock::new(63_000), Sink::default(), Source::default()).map(|_| ())
};
let bytes = |max_snapshot_response_bytes| CultMeshRudpDocumentServerOptions {
max_snapshot_response_bytes,
..Default::default()
};
let fragments = |max_pending_reliable_packets_per_session| CultMeshRudpDocumentServerOptions {
max_fragment_bytes: 1,
max_pending_reliable_packets_per_session,
..Default::default()
};
build(bytes(empty))?;
let error = build(bytes(empty - 1)).unwrap_err().to_string();
assert!(error.contains("max_snapshot_response_bytes"), "{error}");
build(fragments(empty))?;
let error = build(fragments(empty - 1)).unwrap_err().to_string();
assert!(
error.contains("max_pending_reliable_packets_per_session"),
"{error}"
);
Ok(())
}
#[test]
fn session_cap_and_expiry_use_monotonic_time() -> Result<()> {
let clock = Clock::new(60_000);
let options = CultMeshRudpDocumentServerOptions {
max_sessions: 1,
session_idle_timeout: Duration::from_secs(1),
session_max_lifetime: Duration::from_millis(100),
..Default::default()
};
let mut server = server(options, clock.clone(), Sink::default(), Source::default())?;
let target = server.local_addr()?;
let mut admitted = client(target, 1)?;
connect(&mut server, &mut [&mut admitted])?;
let mut rejected = client(target, 1)?;
rejected.connect(Vec::new())?;
server.poll_once()?;
assert_eq!(server.session_count(), 1);
assert!(!rejected.connected());
clock.set_unix(1);
clock.set_monotonic(60_101);
assert_eq!(server.maintain()?.sessions_expired, 1);
assert_eq!(server.session_count(), 0);
Ok(())
}
#[test]
fn a_connect_storm_and_stray_frames_are_dropped_not_fatal() -> Result<()> {
use cultnet_rs::{
CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, encode_rudp_packet,
};
let clock = Clock::new(60_000);
let sink = Sink::default();
let options = CultMeshRudpDocumentServerOptions {
max_pending_reliable_packets_per_session: 4,
..Default::default()
};
let mut server = server(options, clock.clone(), sink.clone(), Source::default())?;
let target = server.local_addr()?;
let mut real = client(target, 101)?;
connect(&mut server, &mut [&mut real])?;
assert_eq!(server.packets_dropped(), 0);
let raw = UdpSocket::bind("127.0.0.1:0")?;
let session = |id| {
CultNetRudpSession::new(CultNetRudpSessionOptions {
connection_id: id,
initial_sequence: Some(1),
resend_delay_ms: 10,
max_pending_reliable_packets: None,
})
};
let send_raw = |server: &mut Server, packet: &cultnet_rs::CultNetRudpPacket| -> Result<()> {
raw.send_to(&encode_rudp_packet(packet)?, target)?;
server.poll_once()?;
Ok(())
};
// A malformed frame.
raw.send_to(b"not a rudp packet", target)?;
server.poll_once()?;
assert_eq!(server.packets_dropped(), 1);
// Data for a session no Connect admitted.
let mut unadmitted = session(300);
unadmitted.assume_connected(0);
let reliable = CultNetRudpSendOptions {
reliable: true,
..Default::default()
};
send_raw(
&mut server,
&unadmitted.send("schema", vec![1], reliable.clone())?,
)?;
assert_eq!(server.packets_dropped(), 2);
// A moved flow re-sends Connect from a socket that never hears the Accept.
// Each repeat re-sends the Accept still awaiting acknowledgement and queues
// nothing, so the storm neither errors nor grows the session's queue.
let mut stormer = session(102);
let connect_packet = stormer.create_connect(0, Vec::new())?;
for _ in 0..200 {
send_raw(&mut server, &connect_packet)?;
}
assert_eq!(server.packets_dropped(), 2);
assert_eq!(server.session_count(), 2);
clock.set(60_000 + 1_000);
assert_eq!(
server.maintain()?.packets_resent,
1,
"one Accept awaits acknowledgement, however many Connects repeated"
);
// A packet its own admitted session refuses ends that session, not the loop.
stormer.assume_connected(0);
let mut poison = stormer.send("schema", vec![1], reliable)?;
poison.fragment_count = 2;
poison.fragment_id = 0;
send_raw(&mut server, &poison)?;
assert_eq!(server.packets_dropped(), 3);
assert_eq!(server.session_count(), 1);
// ...and the refused client is told, after the Accepts its storm drew.
raw.set_read_timeout(Some(Duration::from_millis(200)))?;
let mut buffer = vec![0_u8; 65_535];
let mut told = false;
while let Ok(received) = raw.recv(&mut buffer) {
told |= cultnet_rs::decode_rudp_packet(&buffer[..received])?.packet_type
== cultnet_rs::CultNetRudpPacketType::Disconnect;
}
assert!(told, "the refused client must be sent a goodbye");
send(
&mut real,
&CultNetMessage::DocumentPutRaw {
message_id: "after-storm".into(),
document: document("real", vec![9]),
},
)?;
for _ in 0..20 {
server.poll_once()?;
if sink.0.lock().unwrap().receipts.len() == 1 {
break;
}
}
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
Ok(())
}
/// A peer driven packet by packet, so a test decides what it receives and what
/// it acknowledges.
struct RawPeer {
socket: UdpSocket,
target: SocketAddr,
session: cultnet_rs::CultNetRudpSession,
}
impl RawPeer {
fn connect<S: CultMeshRudpRawDocumentSink, Q: CultMeshRudpSnapshotSource>(
server: &mut CultMeshRudpDocumentServer<S, Q, Clock>,
connection_id: u32,
) -> Result<Self> {
let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.set_read_timeout(Some(Duration::from_millis(50)))?;
let mut peer = Self {
socket,
target: server.local_addr()?,
session: cultnet_rs::CultNetRudpSession::new(cultnet_rs::CultNetRudpSessionOptions {
connection_id,
initial_sequence: Some(100),
resend_delay_ms: 10_000,
max_pending_reliable_packets: Some(64),
}),
};
let connect = peer.session.create_connect(0, Vec::new())?;
peer.send_packet(&connect)?;
server.poll_once()?;
let accept = peer.receive().expect("the server accepts the connect");
assert_eq!(
accept.packet_type,
cultnet_rs::CultNetRudpPacketType::Accept
);
peer.session.receive(&accept, 1)?;
Ok(peer)
}
fn send_packet(&self, packet: &cultnet_rs::CultNetRudpPacket) -> Result<()> {
self.socket
.send_to(&cultnet_rs::encode_rudp_packet(packet)?, self.target)?;
Ok(())
}
/// Sends `message` reliably and in order; returns its sequences.
fn send(&mut self, message: &CultNetMessage) -> Result<Vec<u32>> {
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
let options = cultnet_rs::CultNetRudpSendOptions {
reliable: true,
ordered: true,
sequenced: false,
now_ms: 2,
reliable_expire_after_ms: None,
};
let packets = self
.session
.send_many("schema", payload, options, Some(1200))?;
for packet in &packets {
self.send_packet(packet)?;
}
Ok(packets.iter().map(|packet| packet.sequence).collect())
}
fn receive(&self) -> Option<cultnet_rs::CultNetRudpPacket> {
let mut wire = vec![0_u8; 65_535];
let (bytes, _) = self.socket.recv_from(&mut wire).ok()?;
cultnet_rs::decode_rudp_packet(&wire[..bytes]).ok()
}
/// Every packet waiting, none of them processed or acknowledged.
fn drain(&self) -> Vec<cultnet_rs::CultNetRudpPacket> {
std::iter::from_fn(|| self.receive()).collect()
}
}
/// Whether `packet`'s acknowledgement fields cover `sequence`.
fn acknowledges(packet: &cultnet_rs::CultNetRudpPacket, sequence: u32) -> bool {
packet.ack == sequence
|| (0..32).any(|bit| {
packet.ack_mask & (1 << bit) != 0
&& packet.ack > bit
&& packet.ack - bit - 1 == sequence
})
}
/// The refusal is unreliable and unordered: a peer missing an earlier reliable
/// packet from the server still receives it. An ordered refusal would wait
/// behind the gap for a resend that never comes, because the session ends with it.
#[test]
fn a_refusal_reaches_a_peer_missing_an_earlier_packet() -> Result<()> {
let mut server = server(
Default::default(),
Clock::new(71_000),
Sink::fail_once(),
Source::documents(vec![document("held", vec![1, 2, 3])]),
)?;
let mut peer = RawPeer::connect(&mut server, 78)?;
// The snapshot reply is lost: the peer never sees it, so its ordered
// stream from the server has a gap.
peer.send(&CultNetMessage::SnapshotRequest {
message_id: "lost".into(),
schema_ids: None,
record_keys: None,
})?;
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
assert!(
!peer.drain().is_empty(),
"fixture: the server sent the reply that is lost"
);
peer.send(&CultNetMessage::DocumentPutRaw {
message_id: "refused".into(),
document: document("refused", vec![4, 5, 6]),
})?;
assert!(matches!(
server.poll_once()?,
CultMeshRudpPollOutcome::ApplicationRejected(_)
));
let mut delivered = Vec::new();
let mut disconnected = false;
for packet in peer.drain() {
let result = peer.session.receive(&packet, 3)?;
delivered.extend(result.delivered);
disconnected |= result.disconnected;
}
let refusals: Vec<_> = delivered
.iter()
.map(|frame| {
decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)
})
.collect::<Result<_>>()?;
assert_eq!(
refusals,
vec![CultNetMessage::Error {
error: "the catalog refused the document".into(),
code: None,
details: None,
}]
);
assert!(disconnected, "the refusal is followed by a goodbye");
Ok(())
}
/// The peer's reliable queue is full to the brim: an 8-fragment reply the peer
/// never acknowledges fills a queue of 8 exactly. The next reply cannot be
/// queued, and its refusal still reaches the peer, because it takes no room in
/// that queue; it acknowledges nothing the peer sent.
#[test]
fn a_refusal_reaches_a_peer_whose_queue_is_full_to_the_brim() -> Result<()> {
let options = CultMeshRudpDocumentServerOptions {
max_fragment_bytes: 100,
max_pending_reliable_packets_per_session: 8,
max_snapshot_response_bytes: 8192,
..Default::default()
};
let source = Source::documents(vec![document_served_at("q1", 800)?]);
let mut server = server(options, Clock::new(74_000), Sink::default(), source)?;
let mut peer = RawPeer::connect(&mut server, 79)?;
peer.send(&CultNetMessage::SnapshotRequest {
message_id: "a".into(),
schema_ids: None,
record_keys: None,
})?;
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
let unacknowledged = peer
.drain()
.into_iter()
.filter(|packet| packet.packet_type == cultnet_rs::CultNetRudpPacketType::Data)
.count();
assert_eq!(
unacknowledged, 8,
"fixture: the first reply fills the queue exactly"
);
let refused = peer.send(&CultNetMessage::SnapshotRequest {
message_id: "b".into(),
schema_ids: None,
record_keys: None,
})?;
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else {
panic!("the second reply cannot be queued");
};
assert!(
matches!(
rejection.reason,
CultMeshRudpRejectionReason::ResponseQueueFailed(_)
),
"{:?}",
rejection.reason
);
let arrived = peer.drain();
let mut parts: Vec<_> = arrived
.iter()
.filter(|packet| {
packet.packet_type == cultnet_rs::CultNetRudpPacketType::Data
&& packet.channel_id == "schema"
&& !packet.reliable
})
.collect();
assert!(!parts.is_empty(), "the refusal reaches the peer");
for part in &parts {
assert!(
refused
.iter()
.all(|sequence| !acknowledges(part, *sequence)),
"the refusal acknowledges the refused request: ack {} mask {:#x}",
part.ack,
part.ack_mask
);
}
parts.sort_by_key(|part| part.fragment_index);
let payload: Vec<u8> = parts.iter().flat_map(|part| part.payload.clone()).collect();
assert_eq!(
decode_cultnet_message_from_slice(&payload, CultNetWireContract::CultNetSchemaV0)?,
CultNetMessage::Error {
error: "the snapshot response could not be queued".into(),
code: None,
details: None,
}
);
assert!(
arrived
.iter()
.any(|packet| packet.packet_type == cultnet_rs::CultNetRudpPacketType::Disconnect),
"the refusal is followed by a goodbye"
);
Ok(())
}
/// A snapshot the reliable queue cannot take is answered with a refusal, not
/// left to time out: the refusal needs no room in that queue.
#[test]
fn a_snapshot_the_queue_cannot_take_is_answered() -> Result<()> {
let options = CultMeshRudpDocumentServerOptions {
max_fragment_bytes: 100,
max_pending_reliable_packets_per_session: 8,
max_snapshot_response_bytes: 8192,
..Default::default()
};
// Each document alone is served in 800 bytes, eight fragments; both need sixteen.
let source = Source::documents(vec![
document_served_at("q1", 800)?,
document_served_at("q2", 800)?,
]);
let mut server = server(options, Clock::new(72_000), Sink::default(), source)?;
let mut client = client(server.local_addr()?, 151)?;
connect(&mut server, &mut [&mut client])?;
let receipt = send_reliable(
&mut client,
&CultNetMessage::SnapshotRequest {
message_id: "both".into(),
schema_ids: None,
record_keys: None,
},
)?;
let mut rejection = None;
for _ in 0..100 {
if let CultMeshRudpPollOutcome::ApplicationRejected(found) = server.poll_once()? {
rejection = Some(found);
break;
}
client.receive_once()?;
}
let rejection = rejection.expect("the response cannot be queued");
assert!(
matches!(
rejection.reason,
CultMeshRudpRejectionReason::ResponseQueueFailed(_)
),
"{:?}",
rejection.reason
);
assert_eq!(
refusal_seen_by(&mut client, &receipt)?,
"the snapshot response could not be queued"
);
Ok(())
}
/// A response too large for any datagram fails with the kernel's EMSGSIZE. The
/// caller's rejection and the peer's goodbye both carry its fixed name, never
/// the platform's text, and the peer is sent no refusal.
#[test]
fn a_response_that_is_too_large_to_send_is_named_emsgsize() -> Result<()> {
let options = CultMeshRudpDocumentServerOptions {
max_fragment_bytes: 70_000,
..Default::default()
};
let source = Source::documents(vec![document("big", vec![7; 66_000])]);
let mut server = server(options, Clock::new(73_000), Sink::default(), source)?;
let mut client = client(server.local_addr()?, 152)?;
connect(&mut server, &mut [&mut client])?;
send_reliable(
&mut client,
&CultNetMessage::SnapshotRequest {
message_id: "big".into(),
schema_ids: None,
record_keys: None,
},
)?;
let mut rejection = None;
for _ in 0..100 {
if let CultMeshRudpPollOutcome::ApplicationRejected(found) = server.poll_once()? {
rejection = Some(found);
break;
}
}
assert_eq!(
rejection.expect("the response can never be sent").reason,
CultMeshRudpRejectionReason::ResponseSendFailed("EMSGSIZE")
);
let mut frames = 0;
for _ in 0..500 {
if client.receive_once()?.is_some() {
frames += 1;
}
if !client.connected() {
break;
}
thread::sleep(Duration::from_millis(1));
}
assert_eq!(frames, 0, "no refusal is sent to an unreachable peer");
assert_eq!(
client.disconnect_reason(),
Some(&b"packet could not be sent: EMSGSIZE"[..])
);
Ok(())
}