cultmesh-rs: no permanent send failure is dropped when a rejection ends the session

A reply sent earlier in the same poll could fail permanently and then be
dropped when a rejection returned first; and a refusal fragment that can
never be sent must end the session naming its error. Both now reach
end_session as the unsendable end. The test makes the refusal itself
unsendable: the peer's goodbye names the error, nothing is counted as a
lost datagram, and the other peer is served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 20:50:43 +02:00
commit 2ee707d05e
2 changed files with 44 additions and 4 deletions

View file

@ -546,11 +546,14 @@ where
now_unix,
now_monotonic,
)? {
let end = match rejection.reason {
CultMeshRudpRejectionReason::ResponseSendFailed(kind) => {
SessionEnd::Unsendable(std::io::Error::from(kind))
// A reply that already failed permanently this poll is not dropped:
// the session ends over it.
let end = match (unsendable.take(), &rejection.reason) {
(Some(error), _) => SessionEnd::Unsendable(error),
(None, CultMeshRudpRejectionReason::ResponseSendFailed(kind)) => {
SessionEnd::Unsendable(std::io::Error::from(*kind))
}
ref reason => SessionEnd::Rejected(reason.refusal_text()),
(None, reason) => SessionEnd::Rejected(reason.refusal_text()),
};
self.end_session(key, end)?;
return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection));

View file

@ -456,3 +456,40 @@ fn a_reset_reported_by_a_receive_is_idle_not_an_error() {
}
assert_eq!(received.lock().unwrap().as_slice(), ["y-put"]);
}
#[test]
fn a_refusal_that_can_never_be_sent_ends_the_session_naming_the_error() {
let (mut server, _clock, received, (mut x, x_addr), (mut y, _)) = two_peers();
// Any document is now unservable, so X's put is refused; the refusal is
// the first datagram X is owed, and it can never be sent as built.
server.options.max_snapshot_response_bytes = 16;
server.unsendable_after.insert(x_addr, 0);
send(&mut x, &put("x-put"));
let mut rejected = false;
for _ in 0..10 {
rejected |= matches!(
server.poll_once().unwrap(),
CultMeshRudpPollOutcome::ApplicationRejected(_)
);
}
assert!(rejected);
assert!(received.lock().unwrap().is_empty());
assert_eq!(server.session_count(), 1);
// The failure is not dropped: the goodbye names it, where a refusal's
// goodbye would say the session refused a packet.
assert!(is_unsendable_reason(&goodbye_reason(&mut x)));
assert_eq!(server.send_failures(), 0);
server.options.max_snapshot_response_bytes =
CultMeshRudpDocumentServerOptions::default().max_snapshot_response_bytes;
send(&mut y, &snapshot("y-snapshot"));
let mut served = false;
for _ in 0..50 {
server.poll_once().unwrap();
if y.receive_once().unwrap().is_some() {
served = true;
break;
}
thread::sleep(Duration::from_millis(1));
}
assert!(served, "the other peer is still served");
}