cultmesh-rs: send the capped frame's answer reliably and end the session once it is acknowledged #48

Merged
libby merged 2 commits from libby/rs-capped-answer-reliable into main 2026-11-08 11:21:28 +00:00
2 changed files with 256 additions and 12 deletions

View file

@ -25,6 +25,10 @@ const SHORTEST_MESSAGE_ID: &str = "0";
/// is answered too, then the session ends. The same number is the cap in every runtime.
pub const MAX_CONSECUTIVE_REFUSED_FRAMES: u32 = 8;
/// How long a session whose capped frame was answered waits for the peer to acknowledge the answer
/// before it ends anyway.
const CAPPED_ANSWER_GRACE_MILLIS: u64 = 2_000;
/// The transport identity of one remote CultNet RUDP session.
///
/// Connection identifiers are scoped to a remote socket address. Two clients
@ -424,6 +428,9 @@ struct SessionEntry {
admitted_payload_bytes: usize,
undecodable_frames_in_a_row: u32,
refused_deletes_in_a_row: u32,
/// Set once the peer's capped frame has been answered: the session ends when the answer is acknowledged,
/// or at this time.
ending_deadline_monotonic_millis: Option<u64>,
}
/// A synchronous, multi-session CultNet RUDP raw-document server.
@ -598,7 +605,18 @@ where
};
let mut unsendable = match result.reply {
Some(reply) => self.send_packet(key.remote_addr, &reply)?,
Some(mut reply) => {
// A reply (a Pong) in the grace after the capped frame acknowledges nothing either.
if self
.sessions
.get(&key)
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some())
{
reply.ack = 0;
reply.ack_mask = 0;
}
self.send_packet(key.remote_addr, &reply)?
}
None => None,
};
@ -612,6 +630,14 @@ where
if frame.channel_id != "schema" {
continue;
}
// The capped frame was answered: nothing after it is served while the answer is delivered.
if self
.sessions
.get(&key)
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some())
{
break;
}
let decoded = decode_cultnet_message_from_slice(
&frame.payload,
CultNetWireContract::CultNetSchemaV0,
@ -664,7 +690,7 @@ where
let refusal = rejection.refusal_message();
let packets = match self.sessions.remove(&key) {
Some(mut entry) => {
let packets = self.refusal_packets(&mut entry.session, &refusal);
let packets = self.refusal_packets(&mut entry.session, &refusal, false);
self.sessions.insert(key, entry);
packets
}
@ -679,6 +705,45 @@ where
kept_rejection = Some(rejection);
continue;
}
// The capped frame is answered reliably, so a lost datagram is resent; the session ends once
// the peer has acknowledged the answer or the grace runs out (`end_answered_sessions`). When
// the answer cannot be queued, the session ends at once with the refusal as before.
if (is_unreadable || is_delete)
&& run.is_some()
&& !matches!(
rejection.reason,
CultMeshRudpRejectionReason::ResponseSendFailed(_)
)
{
let refusal = rejection.refusal_message();
let packets = match self.sessions.remove(&key) {
Some(mut entry) => {
let packets = self.refusal_packets(&mut entry.session, &refusal, true);
if !packets.is_empty() {
entry.ending_deadline_monotonic_millis =
Some(now_monotonic.saturating_add(CAPPED_ANSWER_GRACE_MILLIS));
}
self.sessions.insert(key, entry);
packets
}
None => Vec::new(),
};
if packets.is_empty() {
self.end_session(key, SessionEnd::Rejected(refusal))?;
return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection));
}
for mut packet in packets {
// Acknowledges nothing, as every refusal: the refused frame was not admitted.
packet.ack = 0;
packet.ack_mask = 0;
if let Some(error) = self.send_packet(key.remote_addr, &packet)? {
unsendable = Some(error);
break;
}
}
kept_rejection = Some(rejection);
break;
}
// The only reply `receive` returns is a Pong, which delivers no
// frame, so no earlier send of this poll can precede a rejection.
// A response that could not be sent ended its session where the
@ -693,7 +758,11 @@ where
}
}
if packet.reliable && unsendable.is_none() {
let answering_capped_frame = self
.sessions
.get(&key)
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some());
if packet.reliable && unsendable.is_none() && !answering_capped_frame {
let ack = self
.sessions
.get_mut(&key)
@ -705,6 +774,7 @@ where
if let Some(error) = unsendable {
self.end_session(key, SessionEnd::Unsendable(error))?;
}
self.end_answered_sessions()?;
Ok(match kept_rejection {
Some(rejection) => CultMeshRudpPollOutcome::ApplicationRejected(rejection),
None => CultMeshRudpPollOutcome::Handled,
@ -722,10 +792,16 @@ where
&& now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms
});
let expired = before - self.sessions.len();
self.end_answered_sessions()?;
let mut resends = Vec::new();
for (key, entry) in &mut self.sessions {
for packet in entry.session.due_resends(now) {
for mut packet in entry.session.due_resends(now) {
if entry.ending_deadline_monotonic_millis.is_some() {
// The resent answer to a capped frame acknowledges nothing either.
packet.ack = 0;
packet.ack_mask = 0;
}
resends.push((*key, packet));
}
}
@ -744,6 +820,26 @@ where
})
}
/// Ends each session whose capped frame was answered once the peer has acknowledged every answer (a lost
/// answer is resent first), or when the grace has run out.
fn end_answered_sessions(&mut self) -> Result<()> {
let now = self.clock.now_monotonic_millis();
let due: Vec<CultMeshRudpSessionKey> = self
.sessions
.iter()
.filter(|(_, entry)| {
entry.ending_deadline_monotonic_millis.is_some_and(|deadline| {
entry.session.outstanding_reliable_packet_count() == 0 || now >= deadline
})
})
.map(|(key, _)| *key)
.collect();
for key in due {
self.end_session(key, SessionEnd::Refused)?;
}
Ok(())
}
/// `Ok(false)` when the Connect was refused because the session cap is full;
/// only a socket failure is an `Err`.
fn accept_connection(
@ -795,6 +891,7 @@ where
admitted_payload_bytes: 0,
undecodable_frames_in_a_row: 0,
refused_deletes_in_a_row: 0,
ending_deadline_monotonic_millis: None,
},
);
accept
@ -1034,7 +1131,7 @@ where
};
let (refusal, mut unsendable) = match end {
SessionEnd::Refused => (Vec::new(), None),
SessionEnd::Rejected(refusal) => (self.refusal_packets(&mut entry.session, &refusal), None),
SessionEnd::Rejected(refusal) => (self.refusal_packets(&mut entry.session, &refusal, false), None),
SessionEnd::Unsendable(error) => (Vec::new(), Some(error)),
};
entry.session.reset_peer_state();
@ -1057,11 +1154,12 @@ where
Ok(())
}
/// The refusal a rejected peer is sent, as unreliable, unordered packets.
/// The refusal a rejected peer is sent: unreliable and unordered, or (`reliable`) queued like any response.
fn refusal_packets(
&self,
session: &mut CultNetRudpSession,
refusal: &CultNetMessage,
reliable: bool,
) -> Vec<CultNetRudpPacket> {
encode_cultnet_message_to_vec(refusal, CultNetWireContract::CultNetSchemaV0)
.and_then(|payload| {
@ -1069,8 +1167,8 @@ where
"schema",
payload,
CultNetRudpSendOptions {
reliable: false,
ordered: false,
reliable,
ordered: reliable,
sequenced: false,
now_ms: self.clock.now_monotonic_millis(),
reliable_expire_after_ms: None,

View file

@ -343,14 +343,17 @@ fn refusal_seen_by(
client: &mut CultNetRudpSocketTransportConnection,
receipt: &CultNetRudpReliableSendReceipt,
) -> Result<String> {
let (error, code, details) = typed_refusal_seen_by(client, receipt)?;
let (error, code, details) = typed_refusal_seen_by(client, receipt, None)?;
assert_eq!((code, details), (None, None));
Ok(error)
}
/// `server` is polled while the goodbye is awaited when the refusal ends its session only once the peer has
/// acknowledged it (the capped frame's answer).
fn typed_refusal_seen_by(
client: &mut CultNetRudpSocketTransportConnection,
receipt: &CultNetRudpReliableSendReceipt,
mut server: Option<&mut Server>,
) -> Result<(String, Option<CultNetErrorCode>, Option<CultNetErrorDetails>)> {
let mut refusal = None;
for _ in 0..2_000 {
@ -381,6 +384,9 @@ fn typed_refusal_seen_by(
"a refusal must not acknowledge the refused message"
);
for _ in 0..2_000 {
if let Some(server) = server.as_deref_mut() {
server.poll_once()?;
}
client.receive_once()?;
if !client.connected() {
break;
@ -681,7 +687,7 @@ fn application_rejection_is_nonfatal_peer_scoped_refused_to_the_peer_and_unackno
// The caller keeps the sink's text; the peer is sent fixed text, never it.
// A refused put is typed write_not_authorized and names the record; nothing of it reached the sink.
assert_eq!(
typed_refusal_seen_by(&mut publisher, &publish_receipt)?,
typed_refusal_seen_by(&mut publisher, &publish_receipt, None)?,
(
"write_not_authorized: the catalog refused the document".to_string(),
Some(CultNetErrorCode::WriteNotAuthorized),
@ -863,7 +869,7 @@ fn an_unreadable_frame_is_refused_typed_and_the_session_kept_until_eight_in_a_ro
panic!("the eighth consecutive unreadable frame must be refused");
};
assert_eq!(rejection.operation, CultMeshRudpApplicationOperation::Unreadable);
let (error, code, _) = typed_refusal_seen_by(&mut garbler, &receipt)?;
let (error, code, _) = typed_refusal_seen_by(&mut garbler, &receipt, Some(&mut server))?;
assert_eq!((error.as_str(), code), (message_invalid.2.as_str(), message_invalid.0));
drain_until_idle(&mut server)?;
assert_eq!(server.session_count(), 1, "only the capped peer's session ended");
@ -905,7 +911,7 @@ fn a_refused_delete_keeps_the_session_until_eight_in_a_row() -> Result<()> {
};
assert_eq!(rejection.operation, CultMeshRudpApplicationOperation::DocumentDelete);
assert_eq!(rejection.reason, CultMeshRudpRejectionReason::DeleteNotSupported);
let (error, code, details) = typed_refusal_seen_by(&mut deleter, &receipt)?;
let (error, code, details) = typed_refusal_seen_by(&mut deleter, &receipt, Some(&mut server))?;
assert_eq!(
(error, code, details),
{
@ -1623,6 +1629,11 @@ impl RawPeer {
/// 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)?;
self.send_payload(payload)
}
/// Sends `payload` as one reliable, ordered schema frame; returns its sequences.
fn send_payload(&mut self, payload: Vec<u8>) -> Result<Vec<u32>> {
let options = cultnet_rs::CultNetRudpSendOptions {
reliable: true,
ordered: true,
@ -1910,3 +1921,138 @@ fn a_response_that_is_too_large_to_send_is_named_emsgsize() -> Result<()> {
);
Ok(())
}
/// A peer on `server` that has sent seven unreadable frames, each answered and processed, and then the
/// eighth, whose answer is waiting at its socket. Returns the peer and the eighth frame's sequence.
fn peer_at_the_cap(server: &mut Server, connection_id: u32) -> Result<(RawPeer, u32)> {
let mut peer = RawPeer::connect(server, connection_id)?;
for _ in 0..7 {
peer.send_payload(garbage())?;
assert!(matches!(
server.poll_once()?,
CultMeshRudpPollOutcome::ApplicationRejected(_)
));
for packet in peer.drain() {
peer.session.receive(&packet, 3)?;
}
}
let capped = peer.send_payload(garbage())?[0];
assert!(matches!(
server.poll_once()?,
CultMeshRudpPollOutcome::ApplicationRejected(_)
));
Ok((peer, capped))
}
fn is_goodbye(packet: &cultnet_rs::CultNetRudpPacket) -> bool {
packet.packet_type == cultnet_rs::CultNetRudpPacketType::Disconnect
}
fn is_answer(packet: &cultnet_rs::CultNetRudpPacket) -> bool {
packet.packet_type == cultnet_rs::CultNetRudpPacketType::Data && packet.reliable
}
/// The capped frame's answer is reliable: its datagram is lost, the server resends it, and only once the
/// peer has acknowledged it does the goodbye follow. Neither transmission acknowledges the capped frame.
#[test]
fn a_lost_capped_answer_is_resent_and_the_goodbye_waits_for_its_acknowledgement() -> Result<()> {
let clock = Clock::new(81_000);
let mut server = server(Default::default(), clock.clone(), Sink::default(), Source::default())?;
let (mut peer, capped) = peer_at_the_cap(&mut server, 81)?;
let first = peer.drain();
assert!(first.iter().any(is_answer), "fixture: the answer was sent, and is lost here");
assert!(!first.iter().any(is_goodbye), "no goodbye before the answer is acknowledged");
assert!(
!first.iter().any(|packet| acknowledges(packet, capped)),
"nothing sent for the capped frame acknowledges it"
);
assert_eq!(server.session_count(), 1);
clock.set_monotonic(81_000 + 60);
server.maintain()?;
let resent = peer.drain();
let answer = resent.iter().find(|packet| is_answer(packet)).expect("the lost answer is resent");
assert!(!acknowledges(answer, capped), "the resent answer acknowledges nothing the peer sent");
assert!(!resent.iter().any(is_goodbye), "still no goodbye");
let delivered = peer.session.receive(answer, 4)?.delivered;
let Ok(CultNetMessage::Error { code, .. }) =
decode_cultnet_message_from_slice(&delivered[0].payload, CultNetWireContract::CultNetSchemaV0)
else {
panic!("the resent answer is a typed refusal");
};
assert_eq!(code, Some(CultNetErrorCode::MessageInvalid));
peer.send_packet(&peer.session.create_ack_for(answer.sequence))?;
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
assert!(peer.drain().iter().any(is_goodbye), "acknowledged: the goodbye follows");
assert_eq!(server.session_count(), 0);
Ok(())
}
/// A peer that never acknowledges the answer is not kept for ever: the session ends after the grace.
#[test]
fn a_capped_session_whose_answer_is_never_acknowledged_ends_after_the_grace() -> Result<()> {
let clock = Clock::new(82_000);
let mut server = server(Default::default(), clock.clone(), Sink::default(), Source::default())?;
let (peer, _) = peer_at_the_cap(&mut server, 82)?;
peer.drain();
clock.set_monotonic(82_000 + 1_999);
server.maintain()?;
assert!(!peer.drain().iter().any(is_goodbye), "inside the grace");
assert_eq!(server.session_count(), 1);
clock.set_monotonic(82_000 + 2_000);
server.maintain()?;
assert!(peer.drain().iter().any(is_goodbye), "the grace ran out");
assert_eq!(server.session_count(), 0);
Ok(())
}
/// Nothing behind the capped frame is served while its answer is delivered.
#[test]
fn nothing_behind_the_capped_frame_is_served() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(83_000), sink.clone(), Source::default())?;
let (mut peer, _) = peer_at_the_cap(&mut server, 83)?;
peer.send_payload(valid_put("behind-the-cap"))?;
for _ in 0..3 {
server.poll_once()?;
}
assert!(sink.0.lock().unwrap().receipts.is_empty(), "the put behind the cap was not stored");
Ok(())
}
/// A Ping inside the grace is answered with a Pong that acknowledges nothing: its ack fields are built from
/// what the session received, which includes the capped frame.
#[test]
fn a_pong_in_the_grace_after_the_cap_acknowledges_nothing() -> Result<()> {
let mut server = server(Default::default(), Clock::new(84_000), Sink::default(), Source::default())?;
let (mut peer, capped) = peer_at_the_cap(&mut server, 84)?;
peer.drain();
let ping = peer.session.create_ping(b"alive".to_vec());
peer.send_packet(&ping)?;
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
let pong = peer
.drain()
.into_iter()
.find(|packet| packet.packet_type == cultnet_rs::CultNetRudpPacketType::Pong)
.expect("the ping is answered");
assert_eq!(pong.payload, b"alive");
assert!(!acknowledges(&pong, capped), "the Pong acknowledges the capped frame");
assert_eq!((pong.ack, pong.ack_mask), (0, 0));
Ok(())
}
/// The grace deadline saturates: at the end of the clock the answer is not given up on at once.
#[test]
fn the_grace_deadline_does_not_wrap_at_the_end_of_the_clock() -> Result<()> {
let mut server = server(Default::default(), Clock::new(u64::MAX - 10), Sink::default(), Source::default())?;
let (peer, _) = peer_at_the_cap(&mut server, 85)?;
peer.drain();
server.maintain()?;
assert!(!peer.drain().iter().any(is_goodbye), "the deadline wrapped and the session ended before its grace");
assert_eq!(server.session_count(), 1);
Ok(())
}