diff --git a/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json index c0313569..f575398d 100644 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ b/contracts/cultnet/interop/write-receipt-vectors.json @@ -75,23 +75,6 @@ "messageId": null } }, - { - "name": "error_v1_empty_schema_id_is_unreadable_and_names_the_write", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9216D6573736167655F696E76616C69643A20756E7265616461626C65206672616D65AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D31", - "message": { - "schemaVersion": "cultnet.error.v1", - "error": "message_invalid: unreadable frame", - "routingHint": null, - "code": "message_invalid", - "details": { - "field": "message", - "value": null, - "asOf": null, - "current": null - }, - "messageId": "put-1" - } - }, { "name": "error_v1_limit_reached", "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235", diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index 8581cc53..1971b3b9 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -36,7 +36,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A `schemaId` or `recordKey` that is empty or only whitespace (present but names nothing) is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"` and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. + - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. - **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`. - **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. - Past the session (or a server's longer retention, if it keeps one) a resend is a new write. diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index f97b2578..647589e0 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -3,14 +3,13 @@ use cultnet_rs::{ CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, - encode_rudp_packet, is_permanent_send_error, readable_message_id, message_id_digest, missing_required_write_field, send_error_code, write_content_hash, + encode_rudp_packet, is_permanent_send_error, send_error_code, }; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; #[cfg(test)] use std::collections::BTreeSet; use std::io::ErrorKind; use std::net::{SocketAddr, UdpSocket}; -use std::ops::ControlFlow; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const MAX_UDP_DATAGRAM_BYTES: usize = 65_535; @@ -67,16 +66,6 @@ pub struct CultMeshRudpSnapshotQuery { /// Caller-owned admission/persistence port for received raw documents. pub trait CultMeshRudpRawDocumentSink { - /// Whether this put may be written, decided without writing it. A receipt-capable session asks it only - /// the first time it sees a message id; a resend is answered from what the session remembers - /// (write-receipt.md, rule 5). The default admits everything and leaves the decision to - /// `accept_raw_document`. - fn authorize_raw_document(&mut self, _receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { - Ok(()) - } - - /// Commits the document. `Ok` means committed and visible to readers: a receipt-capable peer is told - /// `write_admitted` on the strength of it, so a sink that has only queued the write must not return `Ok`. fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()>; } @@ -189,15 +178,6 @@ pub struct CultMeshRudpDocumentServerOptions { pub resend_delay: Duration, pub max_pending_reliable_packets_per_session: usize, pub max_fragment_bytes: usize, - /// Server-wide memory budget for the remembered writes of all receipt sessions - /// (write-receipt.md, "Remembered-write limits"): at most this many bytes, counted as - /// `REMEMBERED_ENTRY_BYTES` per entry. A new write over it is refused `limit_reached`, the session kept. - pub max_remembered_write_bytes: usize, -} - -impl CultMeshRudpDocumentServerOptions { - /// What one remembered write is charged: the digests of its id and content, and its answer. - pub const REMEMBERED_ENTRY_BYTES: usize = 32 + 32 + 1; } impl Default for CultMeshRudpDocumentServerOptions { @@ -213,7 +193,6 @@ impl Default for CultMeshRudpDocumentServerOptions { resend_delay: Duration::from_millis(50), max_pending_reliable_packets_per_session: 1024, max_fragment_bytes: 1200, - max_remembered_write_bytes: 32 * 1024 * 1024, } } } @@ -279,20 +258,6 @@ pub enum CultMeshRudpRejectionReason { DeleteNotSupported, /// A schema frame did not decode as a CultNet message. Carries the decoder's error text. MessageUnreadable(String), - /// A receipt-capable session sent a message id it already used for a write with other content - /// (write-receipt.md, rule 5). Nothing reached the sink; the first write's state is unchanged. - MessageIdReused, - /// A receipt-capable session already remembers `MAX_REMEMBERED_WRITES_PER_SESSION` writes and takes no new - /// id. Nothing reached the sink. - WriteLimitReached, - /// A receipt-capable session sent a message id over `MAX_MESSAGE_ID_BYTES`. Nothing was remembered. - MessageIdTooLong, - /// A receipt-capable session sent a write on an unreliable channel, where no answer could be owed once. - ChannelNotReliable, - /// A receipt-capable session's put or delete has a null or missing `document`. - DocumentMissing, - /// A receipt-capable session's put or delete has a null or missing `schemaId`. - SchemaIdMissing, } impl std::fmt::Display for CultMeshRudpRejectionReason { @@ -301,12 +266,6 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { Self::SinkRefused(error) | Self::SnapshotSourceFailed(error) => f.write_str(error), Self::DeleteNotSupported => f.write_str("the server does not take deletes"), Self::MessageUnreadable(error) => write!(f, "message could not be read: {error}"), - Self::MessageIdReused => f.write_str("the message id was already used for different content"), - Self::WriteLimitReached => f.write_str("the session remembers as many writes as it will"), - Self::MessageIdTooLong => f.write_str("the message id is longer than 256 bytes"), - Self::ChannelNotReliable => f.write_str("a receipt session's write arrived on an unreliable channel"), - Self::DocumentMissing => f.write_str("the write has no document"), - Self::SchemaIdMissing => f.write_str("the write has no schemaId"), Self::DocumentUnservable { response_bytes, max_snapshot_response_bytes, @@ -347,7 +306,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason { /// The texts a refused peer is sent, one per rejection variant. They are fixed: /// a sink's or source's error text can quote what a peer sent, so it stays with /// the caller that receives the rejection and never reaches the wire. -pub(crate) const REFUSAL_TEXTS: [&str; 17] = [ +pub(crate) const REFUSAL_TEXTS: [&str; 11] = [ "the catalog refused the document", "the document can never be served", "the snapshot source failed", @@ -359,46 +318,12 @@ pub(crate) const REFUSAL_TEXTS: [&str; 17] = [ "the snapshot response could not be sent", "the server does not take deletes", "the message could not be read", - "the message id was already used for different content", - "the session's remembered-write limit is full", - "messageId exceeds 256 bytes", - "receipt sessions require the reliable channel", - "document is required", - "schemaId is required", ]; impl CultMeshRudpRejectionReason { /// The fixed text the refused peer is sent for this variant. fn refusal_text(&self) -> &'static str { - REFUSAL_TEXTS[usize::from(self.refusal_index())] - } - - /// The reason a remembered refusal replays as. Its payload (a sink's text, a size) is not remembered: the - /// peer is only ever sent the variant's fixed text, and the caller's rejection is reported once, at first sight. - fn from_refusal_index(index: u8) -> Self { - match index { - 0 => Self::SinkRefused(String::new()), - 1 => Self::DocumentUnservable { response_bytes: 0, max_snapshot_response_bytes: 0, fragment_count: 0, max_fragment_count: 0 }, - 2 => Self::SnapshotSourceFailed(String::new()), - 3 => Self::SnapshotTooManyDocuments { documents: 0, max_snapshot_documents: 0 }, - 4 => Self::SnapshotResponseTooLarge { response_bytes: 0, max_snapshot_response_bytes: 0 }, - 5 => Self::ResponseEncodingFailed(String::new()), - 6 => Self::PayloadBudgetFull, - 7 => Self::ResponseQueueFailed(String::new()), - 8 => Self::ResponseSendFailed(""), - 9 => Self::DeleteNotSupported, - 10 => Self::MessageUnreadable(String::new()), - 11 => Self::MessageIdReused, - 12 => Self::WriteLimitReached, - 13 => Self::MessageIdTooLong, - 14 => Self::ChannelNotReliable, - 15 => Self::DocumentMissing, - _ => Self::SchemaIdMissing, - } - } - - fn refusal_index(&self) -> u8 { - match self { + REFUSAL_TEXTS[match self { Self::SinkRefused(_) => 0, Self::DocumentUnservable { .. } => 1, Self::SnapshotSourceFailed(_) => 2, @@ -410,13 +335,7 @@ impl CultMeshRudpRejectionReason { Self::ResponseSendFailed(_) => 8, Self::DeleteNotSupported => 9, Self::MessageUnreadable(_) => 10, - Self::MessageIdReused => 11, - Self::WriteLimitReached => 12, - Self::MessageIdTooLong => 13, - Self::ChannelNotReliable => 14, - Self::DocumentMissing => 15, - Self::SchemaIdMissing => 16, - } + }] } } @@ -465,23 +384,7 @@ impl CultMeshRudpApplicationRejection { /// What the refused peer is sent: a refused put or delete is a typed `write_not_authorized` naming /// the record, an unreadable frame a typed `message_invalid` naming `message`; every other rejection /// is the variant's fixed text, untyped. None carries the sink's, source's or decoder's own text. - /// - /// A receipt-capable session is sent `cultnet.error.v1` naming the refused message's id; any other - /// session the v0 error it has always been sent. - fn refusal_message(&self, receipts: bool) -> CultNetMessage { - let refusal = self.v0_refusal_message(); - match refusal { - CultNetMessage::Error { error, code, details } if receipts => CultNetMessage::ErrorV1 { - error, - code, - details, - message_id: Some(self.message_id.clone()).filter(|id| !id.is_empty()), - }, - other => other, - } - } - - fn v0_refusal_message(&self) -> CultNetMessage { + fn refusal_message(&self) -> CultNetMessage { let text = self.reason.refusal_text(); match (&self.reason, &self.record_key) { ( @@ -492,18 +395,6 @@ impl CultMeshRudpApplicationRejection { (CultMeshRudpRejectionReason::MessageUnreadable(_), _) => { message_invalid_message("message", text) } - (CultMeshRudpRejectionReason::MessageIdReused, _) => { - message_invalid_message("messageId", text) - } - (CultMeshRudpRejectionReason::MessageIdTooLong, _) => message_invalid_message("messageId", text), - (CultMeshRudpRejectionReason::DocumentMissing, _) => message_invalid_message("document", text), - (CultMeshRudpRejectionReason::SchemaIdMissing, _) => message_invalid_message("schemaId", text), - (CultMeshRudpRejectionReason::ChannelNotReliable, _) => message_invalid_message("channel", text), - (CultMeshRudpRejectionReason::WriteLimitReached, _) => CultNetMessage::Error { - error: format!("limit_reached: {text}"), - code: Some(CultNetErrorCode::LimitReached), - details: Some(CultNetErrorDetails::default()), - }, _ => CultNetMessage::Error { error: text.into(), code: None, @@ -520,15 +411,6 @@ pub enum CultMeshRudpPollOutcome { ApplicationRejected(CultMeshRudpApplicationRejection), } -/// How a refusal sent on a live session fared. -enum Answered { - Sent, - /// The refusal can never be sent as built (a permanent send failure). - Unsendable(std::io::Error), - /// The session could not encode or queue the refusal: it was not sent at all. - Unqueueable, -} - /// Why a session ends before its peer is done with it. enum SessionEnd { /// The session refused a packet. @@ -537,24 +419,6 @@ enum SessionEnd { Rejected(CultNetMessage), /// The session owes its peer a packet that can never be sent as built. Unsendable(std::io::Error), - /// The server could not answer a write it owed an answer to. Not a refusal of anything the peer sent. - LocalFailure, -} - -fn write_rejection( - session: CultMeshRudpSessionKey, - operation: CultMeshRudpApplicationOperation, - message_id: &str, - record_key: &str, - reason: CultMeshRudpRejectionReason, -) -> CultMeshRudpApplicationRejection { - CultMeshRudpApplicationRejection { - session, - operation, - message_id: message_id.to_owned(), - record_key: Some(record_key.to_owned()), - reason, - } } struct SessionEntry { @@ -567,51 +431,8 @@ struct SessionEntry { /// 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, - /// The peer's hello listed `cultnet.write_admitted.v0` and `cultnet.error.v1`: its writes are answered - /// with a receipt or an `error.v1` naming the write's id (write-receipt.md). Framing only; it grants nothing. - receipts: bool, - /// The capability is decided once, by the session's first hello or first write, whichever comes first; - /// a later hello changes nothing. - capability_settled: bool, - /// Answered writes of a receipt-capable session: message id -> (content hash, the answer given), refusals - /// included, so a resend of the same content is answered again without being decided again and the same - /// id with other content is refused. - answered_writes: HashMap<[u8; 32], RememberedWrite>, - /// Writes refused `limit_reached`, kept so their resend replays the refusal. A bounded set outside the - /// entry budget (`MAX_REMEMBERED_WRITES_PER_SESSION`); past it a new write is refused unremembered. - limit_refusals: HashMap<[u8; 32], [u8; 32]>, - /// A limit refusal was answered that `limit_refusals` had no room to remember. That id's resend cannot be - /// told from a new write, so the session refuses every id it has not seen for the rest of its life, even once - /// the server-wide budget has freed: an id refused at a bound is never admitted later. - limit_overflowed: bool, } -/// What the server remembers of a write it answered: digests only, so an entry costs -/// `REMEMBERED_ENTRY_BYTES` whatever the id or payload size. The answer's body is rebuilt from the resend, -/// whose content hash has been checked equal to the original's. -#[derive(Clone, Copy)] -struct RememberedWrite { - content_hash: [u8; 32], - answer: StoredAnswer, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum StoredAnswer { - Admitted, - /// A refusal, by its `REFUSAL_TEXTS` index (`CultMeshRudpRejectionReason::refusal_index`). - Refused(u8), -} - -/// The longest `messageId` a receipt session may send, in UTF-8 bytes. -const MAX_MESSAGE_ID_BYTES: usize = 256; - -/// The most answered writes a receipt-capable session remembers; a session at the limit refuses new writes -/// (`WriteLimitReached`) rather than forget an id it promised to answer idempotently. -const WRITE_ADMITTED_VERSION: &str = "cultnet.write_admitted.v0"; -const ERROR_V1_VERSION: &str = "cultnet.error.v1"; - -const MAX_REMEMBERED_WRITES_PER_SESSION: usize = 1024; - /// A synchronous, multi-session CultNet RUDP raw-document server. /// /// The server owns exactly one UDP socket. Call `poll_once` from an existing @@ -623,9 +444,6 @@ pub struct CultMeshRudpDocumentServer { snapshot_source: Q, clock: C, options: CultMeshRudpDocumentServerOptions, - /// Entries remembered across all live sessions (`answered_writes`), kept in step by `remember` and - /// `drop_session`, so the server-wide budget is a comparison, not a sum over sessions per write. - remembered_writes: usize, packets_dropped: u64, send_failures: u64, /// Peers whose every datagram fails to send, standing in for an unroutable @@ -662,7 +480,6 @@ where snapshot_source, clock, options, - remembered_writes: 0, packets_dropped: 0, send_failures: 0, #[cfg(test)] @@ -753,30 +570,18 @@ where self.packets_dropped += 1; return Ok(CultMeshRudpPollOutcome::Handled); } - let is_schema_data = packet.packet_type == CultNetRudpPacketType::Data && packet.channel_id == "schema"; - if is_schema_data && !packet.reliable { - // An unreliable write cannot be answered once: a receipt session is refused, never applied, and - // anything else unreliable is dropped as before. Either way the peer is not idle. - if let Some(entry) = self.sessions.get_mut(&key) { - entry.last_activity_monotonic_millis = now_monotonic; - } - return self.refuse_unreliable_write(key, &packet.payload); + if packet.packet_type == CultNetRudpPacketType::Data + && packet.channel_id == "schema" + && !packet.reliable + { + return Ok(CultMeshRudpPollOutcome::Handled); } - // A receipt session's write on a sequenced channel (which drops superseded packets) is refused too, but - // only after the session has received and acknowledged it: an unacknowledged packet is resent forever - // and stalls every later ordered write. - let refuse_sequenced = is_schema_data && packet.sequenced && self.receipts(key); let payload_charge = if packet.packet_type == CultNetRudpPacketType::Data { packet.payload.len() } else { 0 }; if !self.payload_budget_allows(key, payload_charge) { - // A receipt-capable session's dropped write would go unanswered; ending the session answers it - // (the peer's writes settle as session_ended). Any other session is dropped from silently, as before. - if self.receipts(key) { - self.end_session(key, SessionEnd::Refused)?; - } return Ok(CultMeshRudpPollOutcome::Handled); } @@ -816,7 +621,7 @@ where }; if result.disconnected { - self.drop_session(key); + self.sessions.remove(&key); return Ok(CultMeshRudpPollOutcome::Handled); } @@ -825,14 +630,6 @@ where if frame.channel_id != "schema" { continue; } - if refuse_sequenced { - if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = - self.refuse_unreliable_write(key, &frame.payload)? - { - kept_rejection = Some(rejection); - } - continue; - } // The capped frame was answered: nothing after it is served while the answer is delivered. if self .sessions @@ -847,12 +644,9 @@ where ); // Runs of refusals this frame extends or breaks. A session is kept through a short run and // ended by `MAX_CONSECUTIVE_REFUSED_FRAMES` in a row. - // Only an undecodable frame is a protocol violation of a receipt-capable session: its refused - // deletes are refused writes, answered one by one like its refused puts. - let receipt_session = self.receipts(key); let (is_unreadable, is_delete) = ( decoded.is_err(), - !receipt_session && matches!(decoded, Ok(CultNetMessage::DocumentDelete { .. })), + matches!(decoded, Ok(CultNetMessage::DocumentDelete { .. })), ); let run = self.sessions.get_mut(&key).map(|entry| { entry.undecodable_frames_in_a_row = if is_unreadable { @@ -879,26 +673,13 @@ where now_unix, now_monotonic, )?, - Err(error) => { - // A receipt session's put or delete missing a required field is a refused write that - // names the field, not an unreadable frame. - let missing = if receipt_session { missing_required_write_field(&frame.payload) } else { None }; - Some(CultMeshRudpApplicationRejection { - session: key, - operation: match missing { - Some(("put", _)) => CultMeshRudpApplicationOperation::DocumentPutRaw, - Some(_) => CultMeshRudpApplicationOperation::DocumentDelete, - None => CultMeshRudpApplicationOperation::Unreadable, - }, - message_id: readable_message_id(&frame.payload).unwrap_or_default(), - record_key: None, - reason: match missing { - Some((_, "document")) => CultMeshRudpRejectionReason::DocumentMissing, - Some(_) => CultMeshRudpRejectionReason::SchemaIdMissing, - None => CultMeshRudpRejectionReason::MessageUnreadable(format!("{error:#}")), - }, - }) - } + Err(error) => Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::Unreadable, + message_id: String::new(), + record_key: None, + reason: CultMeshRudpRejectionReason::MessageUnreadable(format!("{error:#}")), + }), }; if let Some(rejection) = rejection { // A refused delete or unreadable frame below the cap is answered on the live session, @@ -906,38 +687,19 @@ where if (is_unreadable || is_delete) && run.is_some_and(|run| run < MAX_CONSECUTIVE_REFUSED_FRAMES) { - let refusal = rejection.refusal_message(self.receipts(key)); - match self.send_on_live_session(key, &refusal, self.receipts(key))? { - Answered::Sent => {} - Answered::Unsendable(error) => unsendable = Some(error), - Answered::Unqueueable => { - self.end_session(key, SessionEnd::LocalFailure)?; - return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); + let refusal = rejection.refusal_message(); + let packets = match self.sessions.remove(&key) { + Some(mut entry) => { + let packets = self.refusal_packets(&mut entry.session, &refusal, false); + self.sessions.insert(key, entry); + packets } - } - kept_rejection = Some(rejection); - continue; - } - // A refused write of a receipt-capable session is answered with its error.v1 and the session - // goes on: only a protocol violation (the cases above and below) ends a session. - if matches!( - rejection.operation, - CultMeshRudpApplicationOperation::DocumentPutRaw | CultMeshRudpApplicationOperation::DocumentDelete - ) && self.receipts(key) - && !matches!( - rejection.reason, - CultMeshRudpRejectionReason::ResponseSendFailed(_) - ) - { - let refusal = rejection.refusal_message(true); - match self.send_on_live_session(key, &refusal, true)? { - Answered::Sent => {} - Answered::Unsendable(error) => unsendable = Some(error), - // An unanswerable write is answered by ending the session: its peer settles it - // as `session_ended`, never as silence. - Answered::Unqueueable => { - self.end_session(key, SessionEnd::LocalFailure)?; - return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); + None => Vec::new(), + }; + for packet in &packets { + if let Some(error) = self.send_packet(key.remote_addr, packet)? { + unsendable = Some(error); + break; } } kept_rejection = Some(rejection); @@ -953,7 +715,7 @@ where CultMeshRudpRejectionReason::ResponseSendFailed(_) ) { - let refusal = rejection.refusal_message(self.receipts(key)); + 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); @@ -990,7 +752,7 @@ where rejection.reason, CultMeshRudpRejectionReason::ResponseSendFailed(_) ) { - self.end_session(key, SessionEnd::Rejected(rejection.refusal_message(self.receipts(key))))?; + self.end_session(key, SessionEnd::Rejected(rejection.refusal_message()))?; } return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); } @@ -1019,55 +781,16 @@ where }) } - /// Refuses a put or delete that arrived on an unreliable or sequenced channel of a receipt session with - /// `message_invalid` (`details.field` `channel`) naming its id. Nothing is remembered or applied: the id - /// stays free for the same write sent reliably. - fn refuse_unreliable_write( - &mut self, - key: CultMeshRudpSessionKey, - payload: &[u8], - ) -> Result { - if !self.receipts(key) { - return Ok(CultMeshRudpPollOutcome::Handled); - } - let Ok(message) = decode_cultnet_message_from_slice(payload, CultNetWireContract::CultNetSchemaV0) else { - return Ok(CultMeshRudpPollOutcome::Handled); - }; - let (operation, message_id, record_key) = match message { - CultNetMessage::DocumentPutRaw { message_id, document } => { - (CultMeshRudpApplicationOperation::DocumentPutRaw, message_id, document.record_key) - } - CultNetMessage::DocumentDelete { message_id, record_key, .. } => { - (CultMeshRudpApplicationOperation::DocumentDelete, message_id, record_key) - } - _ => return Ok(CultMeshRudpPollOutcome::Handled), - }; - let rejection = write_rejection(key, operation, &message_id, &record_key, CultMeshRudpRejectionReason::ChannelNotReliable); - let refusal = rejection.refusal_message(true); - match self.send_on_live_session(key, &refusal, true)? { - Answered::Sent => {} - Answered::Unsendable(error) => self.end_session(key, SessionEnd::Unsendable(error))?, - Answered::Unqueueable => self.end_session(key, SessionEnd::LocalFailure)?, - } - Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)) - } - /// Expire idle sessions and resend reliable packets whose deadline passed. pub fn maintain(&mut self) -> Result { let now = self.clock.now_monotonic_millis(); let idle_timeout_ms = duration_millis(self.options.session_idle_timeout); let lifetime_ms = duration_millis(self.options.session_max_lifetime); let before = self.sessions.len(); - let mut freed = 0; self.sessions.retain(|_, entry| { - let live = now.saturating_sub(entry.last_activity_monotonic_millis) <= idle_timeout_ms - && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms; - if !live { - freed += entry.answered_writes.len(); - } - live + now.saturating_sub(entry.last_activity_monotonic_millis) <= idle_timeout_ms + && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms }); - self.remembered_writes -= freed; let expired = before - self.sessions.len(); self.end_answered_sessions()?; @@ -1133,7 +856,7 @@ where .get(&key) .is_some_and(|entry| !entry.session.connect_repeats(packet)) { - self.drop_session(key); + self.sessions.remove(&key); } let reply = match self.sessions.get_mut(&key) { Some(entry) => { @@ -1169,11 +892,6 @@ where undecodable_frames_in_a_row: 0, refused_deletes_in_a_row: 0, ending_deadline_monotonic_millis: None, - receipts: false, - capability_settled: false, - answered_writes: HashMap::new(), - limit_refusals: HashMap::new(), - limit_overflowed: false, }, ); accept @@ -1199,21 +917,51 @@ where message_id, document, } => { - // The capability is fixed by the first hello or, absent one, the first write. - if let Some(entry) = self.sessions.get_mut(&key) { - entry.capability_settled = true; - } - let receipts = self.receipts(key); let record_key = document.record_key.clone(); - let rejection = |reason| { - write_rejection( - key, - CultMeshRudpApplicationOperation::DocumentPutRaw, - &message_id, - &record_key, + let reject = |reason| { + Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::DocumentPutRaw, + message_id: message_id.clone(), + record_key: Some(record_key.clone()), reason, - ) + })) }; + // Admit only what some snapshot request can return: the smallest + // response that could carry this document is the record the snapshot + // source would serve for it, alone, under the shortest message id + // CultNet encodes, sized by the snapshot path's encoder and fragmented + // as the snapshot path sends it. + let served = match self.snapshot_source.served_record(&document) { + Ok(served) => served, + Err(error) => { + return reject(CultMeshRudpRejectionReason::SnapshotSourceFailed( + format!("{error:#}"), + )); + } + }; + let alone = CultNetMessage::SnapshotResponseRaw { + message_id: SHORTEST_MESSAGE_ID.into(), + documents: vec![served], + }; + let response_bytes = match encode_snapshot_response(&alone) { + Ok(payload) => payload.len(), + Err(error) => { + return reject(CultMeshRudpRejectionReason::ResponseEncodingFailed(error)); + } + }; + let fragment_count = self.fragments_for(response_bytes); + let max_fragment_count = self.max_fragments_per_response(); + if response_bytes > self.options.max_snapshot_response_bytes + || fragment_count > max_fragment_count + { + return reject(CultMeshRudpRejectionReason::DocumentUnservable { + response_bytes, + max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, + fragment_count, + max_fragment_count, + }); + } let receipt = CultMeshRudpRawDocumentReceipt { session: key, message_id: message_id.clone(), @@ -1221,86 +969,10 @@ where received_at_unix_millis: now_unix, document, }; - if !receipts { - return Ok(self.admit_put(receipt, false).err().map(rejection)); - } - let admitted = CultNetMessage::WriteAdmitted { - message_id: message_id.clone(), - operation: "put".into(), - schema_id: receipt.document.schema_id.clone(), - record_key: receipt.document.record_key.clone(), - }; - if message_id.len() > MAX_MESSAGE_ID_BYTES { - return Ok(Some(rejection(CultMeshRudpRejectionReason::MessageIdTooLong))); - } - let content_hash = match write_content_hash( - "put", - &receipt.document.schema_id, - &receipt.document.record_key, - Some(&receipt.document.payload), - ) { - Ok(hash) => hash, - Err(error) => { - return Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseEncodingFailed( - format!("{error:#}"), - )))); - } - }; - // The id is looked up first: a resend is answered with what the session already said, and - // the sink is asked only about an id the session has not seen. - let id_digest = message_id_digest(&message_id); - if let ControlFlow::Break(outcome) = - self.seen_write(key, now, id_digest, content_hash, &admitted, &rejection)? - { - return Ok(outcome); - } - match self.admit_put(receipt, true) { - Ok(()) => { - // Admitted: the sink holds it. Only now is a receipt owed, never before the commit point. - self.remember(key, id_digest, content_hash, StoredAnswer::Admitted); - return self.answer_sent(key, now, &admitted, &rejection); - } - Err(reason) => { - let refused = rejection(reason); - self.remember(key, id_digest, content_hash, StoredAnswer::Refused(refused.reason.refusal_index())); - return Ok(Some(refused)); - } - } - } - CultNetMessage::Hello { - supported_message_versions, - .. - } => { - let listed = |version: &str| { - supported_message_versions - .iter() - .flatten() - .any(|candidate| candidate == version) - }; - let Some(entry) = self.sessions.get_mut(&key).filter(|entry| !entry.capability_settled) else { - return Ok(None); - }; - entry.capability_settled = true; - entry.receipts = listed(WRITE_ADMITTED_VERSION) && listed(ERROR_V1_VERSION); - if entry.receipts { - // The server's own hello tells the client this is a receipt server, not a legacy one - // that has not answered yet. Best effort: the client's window settles a missing hello. - let hello = CultNetMessage::Hello { - runtime_id: "cultmesh-rs-rudp-document-server".into(), - runtime_kind: "cultmesh-rs".into(), - agent_id: None, - role: None, - display_name: None, - supported_document_types: None, - supported_mutation_contracts: None, - supported_message_versions: Some(vec![ - WRITE_ADMITTED_VERSION.into(), - ERROR_V1_VERSION.into(), - ]), - transport_profiles: None, - supports_schema_catalog: None, - }; - self.send_reliable_schema(key, now, &hello); + if let Err(error) = self.sink.accept_raw_document(receipt) { + return reject(CultMeshRudpRejectionReason::SinkRefused(format!( + "{error:#}" + ))); } } CultNetMessage::SnapshotRequest { @@ -1426,246 +1098,22 @@ where } CultNetMessage::DocumentDelete { message_id, - schema_id, record_key, + .. } => { - // A delete settles the connection's capability exactly as a put does. - if let Some(entry) = self.sessions.get_mut(&key) { - entry.capability_settled = true; - } - let rejection = |reason| { - write_rejection( - key, - CultMeshRudpApplicationOperation::DocumentDelete, - &message_id, - &record_key, - reason, - ) - }; - if !self.receipts(key) { - return Ok(Some(rejection(CultMeshRudpRejectionReason::DeleteNotSupported))); - } - if message_id.len() > MAX_MESSAGE_ID_BYTES { - return Ok(Some(rejection(CultMeshRudpRejectionReason::MessageIdTooLong))); - } - let content_hash = match write_content_hash("delete", &schema_id, &record_key, None) { - Ok(hash) => hash, - Err(error) => { - return Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseEncodingFailed( - format!("{error:#}"), - )))); - } - }; - let id_digest = message_id_digest(&message_id); - // A delete is never admitted; the body is the receipt it would be answered with. - let would_admit = CultNetMessage::WriteAdmitted { - message_id: message_id.clone(), - operation: "delete".into(), - schema_id: schema_id.clone(), - record_key: record_key.clone(), - }; - if let ControlFlow::Break(outcome) = - self.seen_write(key, now, id_digest, content_hash, &would_admit, &rejection)? - { - return Ok(outcome); - } - let refused = rejection(CultMeshRudpRejectionReason::DeleteNotSupported); - self.remember(key, id_digest, content_hash, StoredAnswer::Refused(refused.reason.refusal_index())); - return Ok(Some(refused)); + return Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::DocumentDelete, + message_id, + record_key: Some(record_key), + reason: CultMeshRudpRejectionReason::DeleteNotSupported, + })); } _ => {} } Ok(None) } - /// What a receipt-capable session already knows of a write, by the digest of its id. `Continue` is a first - /// sighting that fits both bounds. A repeat with the same content is answered with its remembered answer - /// (rebuilt from this resend, whose content hash is the original's), a repeat with other content is - /// `MessageIdReused`. A first sighting past the session table or the server-wide budget is refused - /// `limit_reached` for that write alone, and that refusal is remembered (`limit_refusals`) so its resend - /// replays it; no slot is freed, so the id is never admitted later in the session. A refusal the bounded set had no - /// room for sets `limit_overflowed`, after which the session refuses every unseen id, budget or no budget. - fn seen_write( - &mut self, - key: CultMeshRudpSessionKey, - now: u64, - id_digest: [u8; 32], - content_hash: [u8; 32], - admitted: &CultNetMessage, - rejection: &impl Fn(CultMeshRudpRejectionReason) -> CultMeshRudpApplicationRejection, - ) -> Result>> { - let Some(entry) = self.sessions.get(&key) else { - return Ok(ControlFlow::Continue(())); - }; - let prior = entry.answered_writes.get(&id_digest).copied().or_else(|| { - entry.limit_refusals.get(&id_digest).map(|&content_hash| RememberedWrite { - content_hash, - answer: StoredAnswer::Refused(CultMeshRudpRejectionReason::WriteLimitReached.refusal_index()), - }) - }); - let session_full = entry.answered_writes.len() >= MAX_REMEMBERED_WRITES_PER_SESSION; - let refusal_room = entry.limit_refusals.len() < MAX_REMEMBERED_WRITES_PER_SESSION; - let overflowed = entry.limit_overflowed; - let budget_full = self.remembered_writes - .checked_add(1) - .and_then(|entries| entries.checked_mul(CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES)) - .is_none_or(|bytes| bytes > self.options.max_remembered_write_bytes); - Ok(ControlFlow::Break(match prior { - Some(remembered) if remembered.content_hash == content_hash => { - let answer = match remembered.answer { - StoredAnswer::Admitted => admitted.clone(), - StoredAnswer::Refused(index) => { - rejection(CultMeshRudpRejectionReason::from_refusal_index(index)).refusal_message(true) - } - }; - self.answer_sent(key, now, &answer, rejection)? - } - Some(_) => Some(rejection(CultMeshRudpRejectionReason::MessageIdReused)), - None if session_full || budget_full || overflowed => { - if let Some(entry) = self.sessions.get_mut(&key) { - if refusal_room { - entry.limit_refusals.insert(id_digest, content_hash); - } else { - entry.limit_overflowed = true; - } - } - Some(rejection(CultMeshRudpRejectionReason::WriteLimitReached)) - } - None => return Ok(ControlFlow::Continue(())), - })) - } - - fn remember(&mut self, key: CultMeshRudpSessionKey, id_digest: [u8; 32], content_hash: [u8; 32], answer: StoredAnswer) { - if let Some(entry) = self.sessions.get_mut(&key) { - if entry.answered_writes.insert(id_digest, RememberedWrite { content_hash, answer }).is_none() { - self.remembered_writes += 1; - } - } - } - - /// Removes a session for good and gives its remembered writes back to the server-wide budget. A session taken - /// out and put back within one call is not dropped. - fn drop_session(&mut self, key: CultMeshRudpSessionKey) -> Option { - let entry = self.sessions.remove(&key)?; - self.remembered_writes -= entry.answered_writes.len(); - Some(entry) - } - - /// Sends the answer owed a write. An answer that cannot be queued or sent would leave the write - /// unanswered, and a different answer would contradict what the sink did; so the session ends instead, - /// and the peer's write settles as unconfirmed (`session_ended`). The answer stays remembered. - fn answer_sent( - &mut self, - key: CultMeshRudpSessionKey, - now: u64, - answer: &CultNetMessage, - rejection: &impl Fn(CultMeshRudpRejectionReason) -> CultMeshRudpApplicationRejection, - ) -> Result> { - if self.send_reliable_schema(key, now, answer) { - return Ok(None); - } - self.end_session(key, SessionEnd::LocalFailure)?; - Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseSendFailed( - "the write answer could not be sent".into(), - )))) - } - - /// Whether a put may be committed, committing it. `authorize` asks the sink first, as a receipt-capable - /// session does on an id's first sighting. - fn admit_put( - &mut self, - receipt: CultMeshRudpRawDocumentReceipt, - authorize: bool, - ) -> std::result::Result<(), CultMeshRudpRejectionReason> { - if authorize { - if let Err(error) = self.sink.authorize_raw_document(&receipt) { - return Err(CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))); - } - } - let document = &receipt.document; - // Admit only what some snapshot request can return: the smallest - // response that could carry this document is the record the snapshot - // source would serve for it, alone, under the shortest message id - // CultNet encodes, sized by the snapshot path's encoder and fragmented - // as the snapshot path sends it. - let served = self - .snapshot_source - .served_record(document) - .map_err(|error| CultMeshRudpRejectionReason::SnapshotSourceFailed(format!("{error:#}")))?; - let alone = CultNetMessage::SnapshotResponseRaw { - message_id: SHORTEST_MESSAGE_ID.into(), - documents: vec![served], - }; - let response_bytes = encode_snapshot_response(&alone) - .map_err(CultMeshRudpRejectionReason::ResponseEncodingFailed)? - .len(); - let fragment_count = self.fragments_for(response_bytes); - let max_fragment_count = self.max_fragments_per_response(); - if response_bytes > self.options.max_snapshot_response_bytes || fragment_count > max_fragment_count { - return Err(CultMeshRudpRejectionReason::DocumentUnservable { - response_bytes, - max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, - fragment_count, - max_fragment_count, - }); - } - self.sink - .accept_raw_document(receipt) - .map_err(|error| CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))) - } - - /// Queues `message` reliably and in order on the schema channel and sends it; false when it could not be - /// encoded, queued or sent. - fn send_reliable_schema(&mut self, key: CultMeshRudpSessionKey, now: u64, message: &CultNetMessage) -> bool { - let Ok(payload) = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0) else { - return false; - }; - let max_fragment_bytes = self.options.max_fragment_bytes; - let Some(entry) = self.sessions.get_mut(&key) else { - return false; - }; - let Ok(packets) = entry.session.send_many( - "schema", - payload, - CultNetRudpSendOptions { - reliable: true, - ordered: true, - sequenced: false, - now_ms: now, - reliable_expire_after_ms: None, - }, - Some(max_fragment_bytes), - ) else { - return false; - }; - packets - .iter() - .all(|packet| matches!(self.send_packet(key.remote_addr, packet), Ok(None))) - } - - /// Answers `refusal` on the session while it stays live. - fn send_on_live_session( - &mut self, - key: CultMeshRudpSessionKey, - refusal: &CultNetMessage, - reliable: bool, - ) -> Result { - let Some(mut entry) = self.sessions.remove(&key) else { - return Ok(Answered::Sent); - }; - let packets = self.refusal_packets(&mut entry.session, refusal, reliable); - self.sessions.insert(key, entry); - if packets.is_empty() { - return Ok(Answered::Unqueueable); - } - for packet in &packets { - if let Some(error) = self.send_packet(key.remote_addr, packet)? { - return Ok(Answered::Unsendable(error)); - } - } - Ok(Answered::Sent) - } - /// Ends a session before its peer is done with it, and says goodbye. It is /// the one way a session ends early: every end removes the session, sends /// what the peer is owed, then the goodbye. @@ -1678,15 +1126,13 @@ where /// that can never be sent as built is not dropped: the goodbye names its /// error instead. A refusal that cannot be encoded is not sent. fn end_session(&mut self, key: CultMeshRudpSessionKey, end: SessionEnd) -> Result<()> { - let Some(mut entry) = self.drop_session(key) else { + let Some(mut entry) = self.sessions.remove(&key) else { return Ok(()); }; - let local_failure = matches!(end, SessionEnd::LocalFailure); let (refusal, mut unsendable) = match end { SessionEnd::Refused => (Vec::new(), None), SessionEnd::Rejected(refusal) => (self.refusal_packets(&mut entry.session, &refusal, false), None), SessionEnd::Unsendable(error) => (Vec::new(), Some(error)), - SessionEnd::LocalFailure => (Vec::new(), None), }; entry.session.reset_peer_state(); let nothing = entry.session.create_ack(); @@ -1698,10 +1144,9 @@ where break; } } - let goodbye = match (&unsendable, local_failure) { - (Some(error), _) => entry.session.end_unsendable_session(error), - (None, true) => entry.session.end_local_failure_session(), - (None, false) => entry.session.end_refused_session(), + let goodbye = match &unsendable { + Some(error) => entry.session.end_unsendable_session(error), + None => entry.session.end_refused_session(), }; // The goodbye is the session's last datagram. If it too can never be // sent, nobody is left to tell. @@ -1709,10 +1154,6 @@ where Ok(()) } - fn receipts(&self, key: CultMeshRudpSessionKey) -> bool { - self.sessions.get(&key).is_some_and(|entry| entry.receipts) - } - /// The refusal a rejected peer is sent: unreliable and unordered, or (`reliable`) queued like any response. fn refusal_packets( &self, @@ -1824,9 +1265,6 @@ fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> { if options.session_max_lifetime.is_zero() { return Err(anyhow!("session_max_lifetime must be greater than zero")); } - if options.max_remembered_write_bytes == 0 { - return Err(anyhow!("max_remembered_write_bytes must be greater than zero")); - } if options.max_admitted_payload_bytes == 0 { return Err(anyhow!( "max_admitted_payload_bytes must be greater than zero" diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index cccff92f..0e3cba66 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -22,10 +22,6 @@ use std::time::Duration; struct SinkState { receipts: Vec, failures_remaining: usize, - /// Record keys the sink refuses to authorize, before and at the write. - denied_keys: Vec, - /// Every `authorize_raw_document` call, by message id. - authorized: Vec, } #[derive(Clone, Default)] @@ -38,29 +34,11 @@ impl Sink { ..Default::default() }))) } - - fn denying(keys: &[&str]) -> Self { - Self(Arc::new(Mutex::new(SinkState { - denied_keys: keys.iter().map(|key| key.to_string()).collect(), - ..Default::default() - }))) - } } impl CultMeshRudpRawDocumentSink for Sink { - fn authorize_raw_document(&mut self, receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { - self.0.lock().unwrap().authorized.push(receipt.message_id.clone()); - if self.0.lock().unwrap().denied_keys.contains(&receipt.document.record_key) { - anyhow::bail!("not authorized"); - } - Ok(()) - } - fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { let mut state = self.0.lock().unwrap(); - if state.denied_keys.contains(&receipt.document.record_key) { - anyhow::bail!("not authorized"); - } if state.failures_remaining > 0 { state.failures_remaining -= 1; anyhow::bail!("injected sink failure CANARY-7f3a"); @@ -2078,1047 +2056,3 @@ fn the_grace_deadline_does_not_wrap_at_the_end_of_the_clock() -> Result<()> { assert_eq!(server.session_count(), 1); Ok(()) } - -// Write receipts (contracts/cultnet/interop/write-receipt.md). A connection whose hello lists both new frames is -// answered with `write_admitted.v0` or `error.v1`, each carrying the write's id; any other connection is answered -// exactly as before. - -fn receipt_hello() -> CultNetMessage { - CultNetMessage::Hello { - runtime_id: "receipt-client".into(), - runtime_kind: "test".into(), - agent_id: None, - role: None, - display_name: None, - supported_document_types: None, - supported_mutation_contracts: None, - supported_message_versions: Some(vec![ - "cultnet.write_admitted.v0".into(), - "cultnet.error.v1".into(), - ]), - transport_profiles: None, - supports_schema_catalog: None, - } -} - -fn put(id: &str, key: &str, payload: Vec) -> CultNetMessage { - CultNetMessage::DocumentPutRaw { - message_id: id.into(), - document: document(key, payload), - } -} - -/// Polls the server until `client` has `count` schema frames or the polling runs out; rejections are expected here. -fn frames_after( - server: &mut CultMeshRudpDocumentServer, - client: &mut CultNetRudpSocketTransportConnection, - count: usize, -) -> Result> { - let mut frames = Vec::new(); - for _ in 0..600 { - server.poll_once()?; - client.poll_resends()?; - while let Some(frame) = client.receive_once()? { - if frame.channel_id == "schema" { - frames.push(decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - )?); - } - } - if frames.len() >= count { - break; - } - thread::sleep(Duration::from_millis(1)); - } - Ok(frames) -} - -/// The hello a receipt server sends a receipt-capable client, ahead of any answer. -fn server_hello() -> CultNetMessage { - CultNetMessage::Hello { - runtime_id: "cultmesh-rs-rudp-document-server".into(), - runtime_kind: "cultmesh-rs".into(), - agent_id: None, - role: None, - display_name: None, - supported_document_types: None, - supported_mutation_contracts: None, - supported_message_versions: Some(vec![ - "cultnet.write_admitted.v0".into(), - "cultnet.error.v1".into(), - ]), - transport_profiles: None, - supports_schema_catalog: None, - } -} - -/// The `count` answers after the server's hello, which must come first. -fn answers_after( - server: &mut CultMeshRudpDocumentServer, - client: &mut CultNetRudpSocketTransportConnection, - count: usize, -) -> Result> { - let mut frames = frames_after(server, client, count + 1)?; - assert_eq!(frames.first(), Some(&server_hello()), "the server answers the hello first"); - Ok(frames.split_off(1)) -} - -fn admitted(id: &str, key: &str) -> CultNetMessage { - CultNetMessage::WriteAdmitted { - message_id: id.into(), - operation: "put".into(), - schema_id: "test.raw.v1".into(), - record_key: key.into(), - } -} - -#[test] -fn an_admitted_put_on_a_receipt_connection_is_answered_with_a_receipt_carrying_its_id() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 201)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - Ok(()) -} - -#[test] -fn a_connection_without_the_capability_gets_no_receipt() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 202)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![]); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the put was admitted all the same"); - Ok(()) -} - -#[test] -fn a_resend_of_an_admitted_write_is_re_receipted_without_applying_it_twice() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 203)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - assert_eq!( - answers_after(&mut server, &mut peer, 2)?, - vec![admitted("m1", "note:1"), admitted("m1", "note:1")] - ); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - Ok(()) -} - -#[test] -fn the_same_id_with_other_content_is_refused_message_invalid_never_re_receipted() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 204)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![9, 9, 9]))?; - let frames = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(frames[0], admitted("m1", "note:1")); - let CultNetMessage::ErrorV1 { code, details, message_id, .. } = &frames[1] else { - panic!("the second answer must be a refusal, got {:?}", frames[1]); - }; - assert_eq!(*code, Some(CultNetErrorCode::MessageInvalid)); - assert_eq!(message_id.as_deref(), Some("m1")); - assert_eq!(details.as_ref().and_then(|d| d.field.as_deref()), Some("messageId")); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the changed write was not applied"); - Ok(()) -} - -#[test] -fn a_refused_put_on_a_receipt_connection_is_a_typed_error_naming_its_id() -> Result<()> { - let sink = Sink::fail_once(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 205)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m7", "note:7", vec![1]))?; - let frames = answers_after(&mut server, &mut peer, 1)?; - let Some(CultNetMessage::ErrorV1 { code, message_id, .. }) = frames.first() else { - panic!("expected error.v1, got {frames:?}"); - }; - assert_eq!(*code, Some(CultNetErrorCode::WriteNotAuthorized)); - assert_eq!(message_id.as_deref(), Some("m7")); - Ok(()) -} - -#[test] -fn a_hello_listing_only_one_of_the_two_frames_does_not_opt_in() -> Result<()> { - for listed in ["cultnet.write_admitted.v0", "cultnet.error.v1"] { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 206)?; - connect(&mut server, &mut [&mut peer])?; - let mut hello = receipt_hello(); - if let CultNetMessage::Hello { supported_message_versions, .. } = &mut hello { - *supported_message_versions = Some(vec![listed.into()]); - } - send_reliable(&mut peer, &hello)?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![], "listed only {listed}"); - } - Ok(()) -} - -fn refusal_of(message: &CultNetMessage) -> (Option, Option) { - let CultNetMessage::ErrorV1 { code, message_id, .. } = message else { - panic!("expected error.v1, got {message:?}"); - }; - (code.clone(), message_id.clone()) -} - -#[test] -fn a_refused_put_leaves_the_receipt_session_open_for_the_next_write() -> Result<()> { - let sink = Sink::fail_once(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 207)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m2", "note:2", vec![2]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::WriteNotAuthorized), Some("m1".into()))); - assert_eq!(answers[1], admitted("m2", "note:2")); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_reused_id_refuses_the_write_not_the_session() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 208)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![2]))?; - send_reliable(&mut peer, &put("m3", "note:3", vec![3]))?; - let answers = answers_after(&mut server, &mut peer, 3)?; - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("m1".into()))); - assert_eq!(answers[2], admitted("m3", "note:3")); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_full_dedupe_table_refuses_new_writes_and_keeps_the_session_and_its_receipts() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 209)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..1024 { - send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1]))?; - if index % 16 == 15 { - for _ in 0..40 { - server.poll_once()?; - peer.poll_resends()?; - while peer.receive_once()?.is_some() {} - } - } - } - send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; - send_reliable(&mut peer, &put("w0", "note:0", vec![1]))?; - let answers = frames_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); - let CultNetMessage::ErrorV1 { error, details, .. } = &answers[0] else { unreachable!() }; - assert_eq!(error, "limit_reached: the session's remembered-write limit is full", "the cap has its own fixed text"); - assert_eq!(details, &Some(CultNetErrorDetails::default()), "limit_reached names no field: details all null"); - assert_eq!(answers[1], admitted("w0", "note:0"), "a remembered id is still answered idempotently"); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_readable_id_with_a_bad_field_is_refused_with_that_id() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 210)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - // A put with no document: it does not decode, but its id is there to read. - let frame = rmp_serde::to_vec_named(&serde_json::json!({ - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "bad-1", - }))?; - peer.send_reliable("schema", frame)?; - peer.send_reliable("schema", garbage())?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("bad-1".into()))); - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), None), "no id can be read"); - Ok(()) -} - -#[test] -fn a_second_hello_does_not_change_the_capability() -> Result<()> { - let mut downgrade = receipt_hello(); - if let CultNetMessage::Hello { supported_message_versions, .. } = &mut downgrade { - *supported_message_versions = None; - } - // Capable, then a hello listing nothing: still capable. - let sink = Sink::default(); - let mut server_a = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server_a.local_addr()?, 212)?; - connect(&mut server_a, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &downgrade)?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(answers_after(&mut server_a, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - // Legacy by its first write, then a capable hello: still legacy. - let mut server_b = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; - let mut late = client(server_b.local_addr()?, 213)?; - connect(&mut server_b, &mut [&mut late])?; - send_reliable(&mut late, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut late, &receipt_hello())?; - send_reliable(&mut late, &put("m2", "note:2", vec![1]))?; - assert_eq!(frames_after(&mut server_b, &mut late, 1)?, vec![]); - Ok(()) -} - -#[test] -fn a_receipt_session_that_runs_out_of_payload_budget_is_ended_not_left_silent() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_admitted_payload_bytes_per_session: 400, - max_snapshot_response_bytes: 200, - ..Default::default() - }; - let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 214)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..20 { - send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1; 64]))?; - } - frames_after(&mut server, &mut peer, usize::MAX)?; - assert_eq!(server.session_count(), 0, "the writes the budget dropped were answered by ending the session"); - Ok(()) -} - -#[test] -fn a_receipt_that_cannot_be_queued_ends_the_session_instead_of_leaving_the_write_unanswered() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_pending_reliable_packets_per_session: 3, - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 215)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - // The peer never reads, so it acknowledges nothing and the server's reliable queue fills. - for index in 0..8 { - send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1]))?; - for _ in 0..20 { - server.poll_once()?; - } - } - assert_eq!(server.session_count(), 0, "a receipt the queue could not take ended the session"); - // The goodbye names a local failure: it is not the "session refused a packet" goodbye of a protocol refusal. - let mut reason = None; - for _ in 0..200 { - while peer.receive_once()?.is_some() {} - if let Some(found) = peer.disconnect_reason() { - reason = Some(found.to_vec()); - break; - } - thread::sleep(Duration::from_millis(1)); - } - assert_eq!(reason.expect("the peer was told the session ended"), b"server could not answer a write".to_vec()); - Ok(()) -} - -fn delete(id: &str, key: &str) -> CultNetMessage { - CultNetMessage::DocumentDelete { - message_id: id.into(), - schema_id: "test.raw.v1".into(), - record_key: key.into(), - } -} - -#[test] -fn a_resend_is_answered_from_memory_and_the_sink_is_not_asked_again() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 216)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - // The peer's authority over the key ends; the answer already given is still the answer. - sink.0.lock().unwrap().denied_keys.push("note:1".into()); - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - Ok(()) -} - -#[test] -fn a_used_id_with_other_content_is_message_invalid_even_for_a_key_the_peer_may_not_write() -> Result<()> { - let sink = Sink::denying(&["secret"]); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 211)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m1", "secret", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("m1".into()))); - Ok(()) -} - -#[test] -fn a_refused_write_is_remembered_and_its_resend_replays_the_refusal() -> Result<()> { - let sink = Sink::fail_once(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 217)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::WriteNotAuthorized), Some("m1".into()))); - assert_eq!(answers[1], answers[0], "the same refusal again, not a fresh attempt that would now succeed"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); - Ok(()) -} - -/// The sink is asked once, on an id's first sighting: not again for a resend (admitted or refused), and -/// not at all on a session that never declared the receipt capability. The sink here only counts, so a -/// flag that stopped or inverted the ask cannot hide behind the sink refusing at the write. -#[test] -fn authorize_is_called_once_per_first_sighting_and_only_for_receipt_sessions() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 219)?; - let mut legacy = client(server.local_addr()?, 220)?; - connect(&mut server, &mut [&mut peer, &mut legacy])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - assert_eq!(sink.0.lock().unwrap().authorized, vec!["m1".to_string()], "first sighting asks once"); - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - assert_eq!(sink.0.lock().unwrap().authorized, vec!["m1".to_string()], "the resend does not ask again"); - send_reliable(&mut legacy, &put("l1", "note:9", vec![1]))?; - for _ in 0..40 { - server.poll_once()?; - legacy.poll_resends()?; - while legacy.receive_once()?.is_some() {} - } - let state = sink.0.lock().unwrap(); - assert_eq!(state.receipts.len(), 2, "the legacy put was committed"); - assert_eq!(state.authorized, vec!["m1".to_string()], "a legacy session is never asked"); - Ok(()) -} - -#[test] -fn a_delete_that_reuses_a_puts_id_is_message_invalid() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 218)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &delete("m1", "note:1"))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(answers[0], admitted("m1", "note:1")); - let CultNetMessage::ErrorV1 { code, details, message_id, .. } = &answers[1] else { - panic!("expected error.v1, got {:?}", answers[1]); - }; - assert_eq!(*code, Some(CultNetErrorCode::MessageInvalid)); - assert_eq!(message_id.as_deref(), Some("m1")); - assert_eq!(details.as_ref().and_then(|d| d.field.as_deref()), Some("messageId")); - Ok(()) -} - -#[test] -fn deletes_on_a_receipt_session_are_refused_one_by_one_and_never_end_it() -> Result<()> { - let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 219)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..12 { - send_reliable(&mut peer, &delete(&format!("d{index}"), "note:1"))?; - } - send_reliable(&mut peer, &delete("d3", "note:1"))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 14)?; - assert_eq!(answers.len(), 14); - assert_eq!(refusal_of(&answers[12]), refusal_of(&answers[3]), "a resent delete replays its refusal"); - assert_eq!(answers[13], admitted("m1", "note:1")); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_delete_as_the_first_frame_settles_the_connection_as_legacy() -> Result<()> { - let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 220)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &delete("d1", "note:1"))?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - let frames = frames_after(&mut server, &mut peer, 3)?; - assert_eq!(frames.len(), 1, "only the legacy delete refusal: {frames:?}"); - assert!(matches!(frames[0], CultNetMessage::Error { .. }), "{:?}", frames[0]); - Ok(()) -} - -#[test] -fn a_refusal_that_cannot_be_queued_ends_the_session_instead_of_vanishing() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_pending_reliable_packets_per_session: 3, - ..Default::default() - }; - let mut server = server(options, Clock::new(70_000), Sink::denying(&["x"]), Source::default())?; - let mut peer = client(server.local_addr()?, 221)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..8 { - send_reliable(&mut peer, &put(&format!("w{index}"), "x", vec![1]))?; - for _ in 0..20 { - server.poll_once()?; - } - } - assert_eq!(server.session_count(), 0, "a refusal the queue could not take ended the session"); - Ok(()) -} - -#[test] -fn refusal_frames_from_a_peer_are_not_protocol_violations() -> Result<()> { - let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 222)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for _ in 0..12 { - send_reliable( - &mut peer, - &CultNetMessage::ErrorV1 { - error: "message_invalid: nope".into(), - code: Some(CultNetErrorCode::MessageInvalid), - details: None, - message_id: Some("x".into()), - }, - )?; - send_reliable( - &mut peer, - &CultNetMessage::Error { error: "nope".into(), code: None, details: None }, - )?; - } - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_put_that_reuses_a_deletes_id_is_message_invalid() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 223)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &delete("d1", "note:1"))?; - send_reliable(&mut peer, &put("d1", "note:1", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("d1".into()))); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "the reusing put was not applied"); - Ok(()) -} - -fn error_text_and_field(message: &CultNetMessage) -> (String, Option) { - let CultNetMessage::ErrorV1 { error, details, .. } = message else { panic!("expected error.v1, got {message:?}") }; - (error.clone(), details.as_ref().and_then(|d| d.field.clone())) -} - -/// The server-wide budget counts fixed-size entries across sessions; a write over it is refused for that write -/// alone, the refusal is remembered (a resend replays it, no slot is freed), and every session is kept. -#[test] -fn a_server_wide_memory_budget_refuses_the_write_over_it_and_keeps_the_sessions() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 2 * 65, - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; - let mut a = client(server.local_addr()?, 230)?; - let mut b = client(server.local_addr()?, 231)?; - connect(&mut server, &mut [&mut a, &mut b])?; - send_reliable(&mut a, &receipt_hello())?; - send_reliable(&mut b, &receipt_hello())?; - send_reliable(&mut a, &put("a1", "note:a1", vec![1]))?; - send_reliable(&mut b, &put("b1", "note:b1", vec![1]))?; - for _ in 0..200 { - server.poll_once()?; - a.poll_resends()?; - b.poll_resends()?; - while a.receive_once()?.is_some() {} - while b.receive_once()?.is_some() {} - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); - send_reliable(&mut a, &put("a2", "note:a2", vec![1]))?; - send_reliable(&mut a, &put("a2", "note:a2", vec![1]))?; - let answers = frames_after(&mut server, &mut a, 2)?; - for answer in &answers { - assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::LimitReached), Some("a2".into())), "the resend replays the refusal"); - let CultNetMessage::ErrorV1 { details, .. } = answer else { unreachable!() }; - assert_eq!(details, &Some(CultNetErrorDetails::default())); - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 2, "the refused write was not applied"); - assert_eq!(server.session_count(), 2, "both sessions are kept"); - send_reliable(&mut a, &put("a1", "note:a1", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut a, 1)?, vec![admitted("a1", "note:a1")], "a remembered id is still answered"); - Ok(()) -} - -/// A message id over 256 bytes is refused before anything is remembered: no entry, no budget charge. -#[test] -fn a_message_id_over_256_bytes_is_refused_before_anything_is_remembered() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 232)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - let long = "x".repeat(257); - send_reliable(&mut peer, &put(&long, "note:1", vec![1]))?; - send_reliable(&mut peer, &put(&long, "note:1", vec![1]))?; - send_reliable(&mut peer, &put(&"y".repeat(256), "note:2", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 3)?; - for answer in &answers[..2] { - assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::MessageInvalid), Some(long.clone()))); - assert_eq!(error_text_and_field(answer), ("message_invalid: messageId exceeds 256 bytes".into(), Some("messageId".into()))); - } - assert_eq!(answers[2], admitted(&"y".repeat(256), "note:2"), "256 bytes is allowed, and the long id charged no budget"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the long-id writes were never applied"); - Ok(()) -} - -/// A write on an unreliable channel of a receipt session is refused, not dropped and not accepted. -#[test] -fn an_unreliable_write_on_a_receipt_session_is_refused_message_invalid_naming_the_channel() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 233)?; - peer.send(&receipt_hello())?; - server.poll_once()?; - let payload = encode_cultnet_message_to_vec(&put("u1", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - let mut answers = Vec::new(); - for packet in peer.drain() { - for frame in peer.session.receive(&packet, 4)?.delivered { - answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?); - } - } - let refusals: Vec<_> = answers.iter().filter(|answer| matches!(answer, CultNetMessage::ErrorV1 { .. })).collect(); - assert_eq!(refusals.len(), 1, "the unreliable write is answered, not dropped: {answers:?}"); - assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("u1".into()))); - assert_eq!( - error_text_and_field(refusals[0]), - ("message_invalid: receipt sessions require the reliable channel".into(), Some("channel".into())) - ); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "nothing was applied"); - assert_eq!(server.session_count(), 1); - // The id was not consumed: the same write on the reliable channel is admitted. - peer.send(&put("u1", "note:1", vec![1]))?; - server.poll_once()?; - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - Ok(()) -} - -/// A limit refusal is remembered like any refusal: the same id with other content is a reused id, not a fresh -/// limit refusal; and the remembered refusals are a bounded set (1024), past which a refusal is not remembered. -#[test] -fn limit_refusals_are_remembered_in_a_bounded_set() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - ..Default::default() - }; - let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 234)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("full", "note:full", vec![1]))?; - for index in 0..1024 { - send_reliable(&mut peer, &put(&format!("r{index}"), "note:r", vec![1]))?; - if index % 16 == 15 { - for _ in 0..40 { - server.poll_once()?; - peer.poll_resends()?; - while peer.receive_once()?.is_some() {} - } - } - } - // r0 was remembered as refused: other content under its id is a reused id. - send_reliable(&mut peer, &put("r0", "note:other", vec![2]))?; - let answers = frames_after(&mut server, &mut peer, 1)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("r0".into()))); - // The 1025th refusal found the set full: it was refused but not remembered. - send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; - send_reliable(&mut peer, &put("over", "note:over", vec![2]))?; - let answers = frames_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::LimitReached), Some("over".into())), "not remembered: refused afresh"); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -/// A refusal the bounded set could not hold is still the session's first answer for that id: when the budget -/// frees (another session ends), the id is not admitted later in the session, and neither is any id the session -/// has not been seen to use, because the refusal that was not remembered cannot be told from a new write. -#[test] -fn a_session_whose_limit_refusals_overflowed_refuses_new_ids_after_the_budget_frees() -> Result<()> { - let clock = Clock::new(70_000); - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - session_max_lifetime: Duration::from_millis(100), - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, clock.clone(), sink.clone(), Source::default())?; - let mut holder = client(server.local_addr()?, 240)?; - connect(&mut server, &mut [&mut holder])?; - send_reliable(&mut holder, &receipt_hello())?; - send_reliable(&mut holder, &put("held", "note:held", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut holder, 2)?[1], admitted("held", "note:held"), "after the server's hello"); - - clock.set_monotonic(70_050); - let mut peer = client(server.local_addr()?, 241)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..1024 { - send_reliable(&mut peer, &put(&format!("r{index}"), "note:r", vec![1]))?; - if index % 16 == 15 { - for _ in 0..40 { - server.poll_once()?; - peer.poll_resends()?; - while peer.receive_once()?.is_some() {} - } - } - } - send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; - let answers = frames_after(&mut server, &mut peer, 1)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); - - clock.set_monotonic(70_101); - assert_eq!(server.maintain()?.sessions_expired, 1, "the holder ended and freed the budget"); - assert_eq!(server.session_count(), 1); - send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; - send_reliable(&mut peer, &put("fresh", "note:fresh", vec![1]))?; - let answers = frames_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into())), "never admitted later"); - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::LimitReached), Some("fresh".into()))); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "only the holder's write was applied"); - Ok(()) -} - -/// A session that ends gives its remembered entries back to the server-wide budget, whichever way it ends. -#[test] -fn a_session_that_ends_frees_its_remembered_writes_from_the_budget() -> Result<()> { - let clock = Clock::new(70_000); - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - session_max_lifetime: Duration::from_millis(100), - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, clock.clone(), sink.clone(), Source::default())?; - let mut first = client(server.local_addr()?, 242)?; - connect(&mut server, &mut [&mut first])?; - send_reliable(&mut first, &receipt_hello())?; - send_reliable(&mut first, &put("a", "note:a", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("a", "note:a"), "after the server's hello"); - - clock.set_monotonic(70_050); - let mut second = client(server.local_addr()?, 243)?; - connect(&mut server, &mut [&mut second])?; - send_reliable(&mut second, &receipt_hello())?; - send_reliable(&mut second, &put("b", "note:b", vec![1]))?; - assert_eq!(refusal_of(&frames_after(&mut server, &mut second, 2)?[1]).0, Some(CultNetErrorCode::LimitReached)); - - clock.set_monotonic(70_101); - assert_eq!(server.maintain()?.sessions_expired, 1); - send_reliable(&mut second, &put("c", "note:c", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut second, 1)?, vec![admitted("c", "note:c")], "the ended session's entry was freed"); - clock.set_monotonic(70_152); - assert_eq!(server.maintain()?.sessions_expired, 1); - assert_eq!(server.session_count(), 0); - let mut third = client(server.local_addr()?, 244)?; - connect(&mut server, &mut [&mut third])?; - send_reliable(&mut third, &receipt_hello())?; - send_reliable(&mut third, &put("d", "note:d", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut third, 2)?[1], admitted("d", "note:d"), "and so was the second's"); - Ok(()) -} - -/// Whichever way a session ends, its remembered writes go back to the budget: here the peer says goodbye, and -/// here the server ends it itself (an unreliable write whose refusal the reliable queue cannot take). -#[test] -fn a_disconnected_or_server_ended_session_frees_its_remembered_writes() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - max_pending_reliable_packets_per_session: 3, - ..Default::default() - }; - let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; - let mut first = client(server.local_addr()?, 245)?; - connect(&mut server, &mut [&mut first])?; - send_reliable(&mut first, &receipt_hello())?; - send_reliable(&mut first, &put("a", "note:a", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("a", "note:a")); - first.disconnect(Vec::new())?; - for _ in 0..20 { - server.poll_once()?; - } - assert_eq!(server.session_count(), 0, "the goodbye ended the session"); - - // The budget holds one entry. A raw peer is admitted into it, then ended by the server. - let mut second = RawPeer::connect(&mut server, 246)?; - second.send(&receipt_hello())?; - second.send(&put("b", "note:b", vec![1]))?; - server.poll_once()?; - for index in 0..8 { - let payload = encode_cultnet_message_to_vec(&put(&format!("u{index}"), "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = second.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - second.send_packet(packet)?; - } - server.poll_once()?; - } - assert_eq!(server.session_count(), 0, "the server ended the session"); - - let mut third = client(server.local_addr()?, 247)?; - connect(&mut server, &mut [&mut third])?; - send_reliable(&mut third, &receipt_hello())?; - send_reliable(&mut third, &put("c", "note:c", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut third, 2)?[1], admitted("c", "note:c"), "both ended sessions' entries were freed"); - Ok(()) -} - -/// Receipts need a channel that is reliable and not sequenced: a reliable-sequenced write is refused like an -/// unreliable one, a reliable-unordered write is admitted, and a legacy session's sequenced write is left alone. -#[test] -fn only_reliable_non_sequenced_writes_are_accepted_on_a_receipt_session() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 248)?; - peer.send(&receipt_hello())?; - server.poll_once()?; - let send_with = |peer: &mut RawPeer, server: &mut Server, id: &str, ordered: bool, sequenced: bool| -> Result> { - let payload = encode_cultnet_message_to_vec(&put(id, "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - let mut answers = Vec::new(); - for packet in peer.drain() { - for frame in peer.session.receive(&packet, 4)?.delivered { - answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?); - } - } - Ok(answers) - }; - let sequenced = send_with(&mut peer, &mut server, "s1", false, true)?; - let refusals: Vec<_> = sequenced.iter().filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. })).collect(); - assert_eq!(refusals.len(), 1, "the sequenced write is answered: {sequenced:?}"); - assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("s1".into()))); - assert_eq!( - error_text_and_field(refusals[0]), - ("message_invalid: receipt sessions require the reliable channel".into(), Some("channel".into())) - ); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "nothing was applied"); - - let unordered = send_with(&mut peer, &mut server, "s1", false, false)?; - assert!(unordered.contains(&admitted("s1", "note:1")), "reliable unordered is accepted, and the id was not consumed: {unordered:?}"); - // The same peer: the refused packet was acknowledged, so an ordered stream does not wait on its sequence number. - let ordered = send_with(&mut peer, &mut server, "o1", true, false)?; - assert!(ordered.contains(&admitted("o1", "note:1")), "reliable ordered is accepted: {ordered:?}"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); - - let legacy_sink = Sink::default(); - let mut legacy_server: Server = CultMeshRudpDocumentServer::new( - UdpSocket::bind("127.0.0.1:0")?, - legacy_sink.clone(), - Source::default(), - Clock::new(70_000), - Default::default(), - )?; - let mut legacy = RawPeer::connect(&mut legacy_server, 249)?; - let none = send_with(&mut legacy, &mut legacy_server, "l1", false, true)?; - assert!(none.is_empty(), "a legacy session is sent nothing: {none:?}"); - assert_eq!(legacy_sink.0.lock().unwrap().receipts.len(), 1, "and its reliable-sequenced write is applied as before"); - Ok(()) -} - -/// A refused write is traffic: the peer that sends it is not idle, so its session outlives the idle timeout measured -/// from the refusal and not from the packet before it. -#[test] -fn a_refused_write_keeps_the_session_from_going_idle() -> Result<()> { - let clock = Clock::new(70_000); - let options = CultMeshRudpDocumentServerOptions { session_idle_timeout: Duration::from_secs(1), ..Default::default() }; - let mut server = server(options, clock.clone(), Sink::default(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 252)?; - peer.send(&receipt_hello())?; - server.poll_once()?; - for (at, reliable, sequenced) in [(70_700, false, false), (71_400, true, true)] { - clock.set(at); - let payload = encode_cultnet_message_to_vec(&put("idle", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable, ordered: false, sequenced, now_ms: at, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - assert_eq!(server.maintain()?.sessions_expired, 0, "the refused write at {at} is activity"); - } - assert_eq!(server.session_count(), 1); - Ok(()) -} - -/// A Connect that does not repeat the accepted one is a new client session: it starts with nothing remembered, so -/// the id a receipt session already used is a new write on it, and the old session's entries leave the budget. -#[test] -fn a_reconnect_is_a_new_receipt_session_that_remembers_nothing_of_the_old() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { max_remembered_write_bytes: 65, ..Default::default() }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(70_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, 250, 50_000)?; - connect(&mut server, &mut [&mut first])?; - send_reliable(&mut first, &receipt_hello())?; - send_reliable(&mut first, &put("m1", "note:a", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("m1", "note:a")); - - let mut restarted = client_on(twin, target, 250, 7)?; - connect(&mut server, &mut [&mut restarted])?; - assert_eq!(server.session_count(), 1); - send_reliable(&mut restarted, &receipt_hello())?; - send_reliable(&mut restarted, &put("m1", "note:b", vec![2]))?; - let answers = frames_after(&mut server, &mut restarted, 2)?; - assert_eq!(answers[1], admitted("m1", "note:b"), "same id, other content: a new write on a new session, within a one-entry budget"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); - Ok(()) -} - -/// A session that never declared the capability gets no refusal for an unreliable write: it is dropped as before. -#[test] -fn an_unreliable_write_on_a_legacy_session_is_dropped_silently() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 235)?; - let payload = encode_cultnet_message_to_vec(&put("u1", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - assert!(peer.drain().is_empty(), "a legacy session is sent nothing"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); - Ok(()) -} - -/// A put or delete whose document or schemaId is null or missing is refused `message_invalid` naming that -/// field (document first), with the spec's fixed text and the write's id; the session goes on. -#[test] -fn a_write_missing_its_document_or_schema_id_is_refused_naming_the_field() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 236)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - let frames = [ - ("p-missing", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-missing"}), "document"), - ("p-null", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-null", "document": null}), "document"), - ("p-noschema", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-noschema", - "document": {"recordKey": "k", "storedAt": "t", "payloadEncoding": "messagepack", "payload": [1]}}), "schemaId"), - ("d-null", serde_json::json!({"schemaVersion": "cultnet.document_delete.v0", "messageId": "d-null", "schemaId": null, "recordKey": "k"}), "schemaId"), - ("d-missing", serde_json::json!({"schemaVersion": "cultnet.document_delete.v0", "messageId": "d-missing", "recordKey": "k"}), "schemaId"), - ]; - for (_, frame, _) in &frames { - peer.send_reliable("schema", rmp_serde::to_vec_named(frame)?)?; - } - let answers = answers_after(&mut server, &mut peer, frames.len())?; - for ((id, _, field), answer) in frames.iter().zip(&answers) { - assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::MessageInvalid), Some((*id).into()))); - assert_eq!( - error_text_and_field(answer), - (format!("message_invalid: {field} is required"), Some((*field).into())) - ); - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -/// An unreliable write whose refusal the full reliable queue cannot take ends the session as a local failure -/// (the peer's write is then unconfirmed, never silent and never "refused a packet"). -#[test] -fn an_unreliable_refusal_that_cannot_be_queued_ends_the_session_as_a_local_failure() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { max_pending_reliable_packets_per_session: 3, ..Default::default() }; - let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 237)?; - peer.send(&receipt_hello())?; - server.poll_once()?; - // The peer acknowledges nothing, so each refusal stays in the server's reliable queue. - for index in 0..8 { - let payload = encode_cultnet_message_to_vec(&put(&format!("u{index}"), "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - } - assert_eq!(server.session_count(), 0, "the refusal the queue could not take ended the session"); - let goodbye = peer.drain().into_iter().find(is_goodbye).expect("the peer is told the session ended"); - assert_eq!(goodbye.payload, b"server could not answer a write".to_vec()); - Ok(()) -} diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index 0ea2aa53..ebc32afa 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -249,9 +249,6 @@ pub enum CultNetErrorCode { WriteNotAuthorized, UnsupportedSchemaVersion, MessageInvalid, - /// `cultnet.error.v1` only: the session's remembered-write limit is full. `cultnet.error.v0` neither - /// encodes nor decodes it, so its enum stays the schema's. - LimitReached, } impl CultNetErrorCode { @@ -266,7 +263,6 @@ impl CultNetErrorCode { Self::WriteNotAuthorized => "write_not_authorized", Self::UnsupportedSchemaVersion => "unsupported_schema_version", Self::MessageInvalid => "message_invalid", - Self::LimitReached => "limit_reached", } } @@ -281,7 +277,6 @@ impl CultNetErrorCode { "write_not_authorized" => Ok(Self::WriteNotAuthorized), "unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion), "message_invalid" => Ok(Self::MessageInvalid), - "limit_reached" => Ok(Self::LimitReached), _ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")), } } @@ -364,26 +359,6 @@ pub enum CultNetMessage { #[serde(default, skip_serializing_if = "Option::is_none")] details: Option, }, - /// `cultnet.error.v1` (contracts/cultnet/interop/write-receipt.md): `cultnet.error.v0` plus the id of the - /// request it answers. Sent only to a connection that declared the write-receipt capability. - #[serde(skip_deserializing)] - #[serde(rename = "cultnet.error.v1", rename_all = "camelCase")] - ErrorV1 { - error: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - details: Option, - message_id: Option, - }, - /// `cultnet.write_admitted.v0`: the receipt for one admitted put or delete, sent after its commit point. - #[serde(rename = "cultnet.write_admitted.v0", rename_all = "camelCase")] - WriteAdmitted { - message_id: String, - operation: String, - schema_id: String, - record_key: String, - }, #[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")] SampleChangeName { name: String }, #[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")] @@ -568,9 +543,7 @@ pub fn encode_cultnet_message_for_wire( | CultNetMessage::SnapshotResponseRawV1 { .. } | CultNetMessage::ContentChunkRequest { .. } | CultNetMessage::ContentChunkResponse { .. } - | CultNetMessage::Error { .. } - | CultNetMessage::ErrorV1 { .. } - | CultNetMessage::WriteAdmitted { .. } => encode_raw_cultnet_schema_message(message), + | CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message), _ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec( &serde_json::to_value(message)?, )?)?), @@ -600,8 +573,7 @@ pub fn decode_cultnet_message_from_slice( | "cultnet.snapshot_response_raw.v0" | "cultmesh.content_chunk_request.v1" | "cultmesh.content_chunk_response.v1" - | "cultnet.error.v0" - | "cultnet.error.v1", + | "cultnet.error.v0", ) => parse_raw_cultnet_schema_message(&wire_value) .with_context(|| received_version_label(&wire_value))?, _ => cultcache_rs::decode_payload::(bytes) @@ -673,20 +645,6 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { require_non_empty(session, "session")?; } CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?, - CultNetMessage::ErrorV1 { error, message_id, .. } => { - require_non_empty(error, "error")?; - if let Some(message_id) = message_id { - require_non_empty(message_id, "messageId")?; - } - } - CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => { - require_non_empty(message_id, "messageId")?; - if operation != "put" && operation != "delete" { - return Err(anyhow!("operation must be put or delete")); - } - require_non_empty(schema_id, "schemaId")?; - require_non_empty(record_key, "recordKey")?; - } CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?, CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?, CultNetMessage::DocumentDelete { @@ -1063,7 +1021,7 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result { + "cultnet.error.v0" => { let code = get("code") .filter(|value| !value.is_nil()) .map(|value| { @@ -1073,23 +1031,12 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result None, - other => Some(require_legacy_string(other, "messageId")?), - }, - }); - } - Ok(CultNetMessage::Error { error, code, details }) + Ok(CultNetMessage::Error { + error: require_legacy_string(get("error"), "ErrorMessage.Error")?, + code, + details, + }) } _ => Err(anyhow!( "Unsupported raw cultnet.schema.v0 message" @@ -1143,18 +1090,7 @@ fn require_legacy_optional_u64( } fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result { - if let CultNetMessage::Error { code: Some(CultNetErrorCode::LimitReached), .. } = message { - return Err(anyhow!("limit_reached is a cultnet.error.v1 code; cultnet.error.v0 cannot carry it")); - } Ok(rmpv::Value::Map(match message { - // Spec field order (write-receipt.md), not serde_json's sorted map. - CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => vec![ - (rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.write_admitted.v0")), - (rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())), - (rmpv::Value::from("operation"), rmpv::Value::from(operation.as_str())), - (rmpv::Value::from("schemaId"), rmpv::Value::from(schema_id.as_str())), - (rmpv::Value::from("recordKey"), rmpv::Value::from(record_key.as_str())), - ], CultNetMessage::DocumentPutRaw { message_id, document, @@ -1319,36 +1255,6 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result vec![ - (rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.error.v1")), - (rmpv::Value::from("error"), rmpv::Value::from(error.as_str())), - (rmpv::Value::from("routingHint"), rmpv::Value::Nil), - ( - rmpv::Value::from("code"), - code.as_ref() - .map(|code| rmpv::Value::from(code.as_wire_str())) - .unwrap_or(rmpv::Value::Nil), - ), - ( - rmpv::Value::from("details"), - details - .as_ref() - .map(encode_error_details) - .unwrap_or(rmpv::Value::Nil), - ), - ( - rmpv::Value::from("messageId"), - message_id - .as_deref() - .map(rmpv::Value::from) - .unwrap_or(rmpv::Value::Nil), - ), - ], _ => { return Err(anyhow!( "Message is not a raw cultnet.schema.v0 binary replication message" @@ -1978,71 +1884,3 @@ fn legacy_schema_descriptor(schema: &CultNetSchemaDescriptor) -> Result, -) -> Result<[u8; 32]> { - use sha2::{Digest, Sha256}; - let value = rmpv::Value::Array(vec![ - rmpv::Value::from(operation), - rmpv::Value::from(schema_id), - rmpv::Value::from(record_key), - payload.map_or(rmpv::Value::Nil, |payload| rmpv::Value::Binary(payload.to_vec())), - ]); - let mut bytes = Vec::new(); - rmpv::encode::write_value(&mut bytes, &value)?; - Ok(Sha256::digest(&bytes).into()) -} - -/// SHA-256 of a write's `messageId`: what a receipt server remembers in place of the id itself, so an -/// entry's memory does not depend on the id's length (write-receipt.md, "What is remembered"). -pub fn message_id_digest(message_id: &str) -> [u8; 32] { - use sha2::{Digest, Sha256}; - Sha256::digest(message_id.as_bytes()).into() -} - -/// The `messageId` of a schema frame that does not decode as a message (write-receipt.md, rule 3: a refusal -/// carries the write's id whenever the frame is readable enough to find it). `None` when the bytes are not -/// a MessagePack map or hold no non-empty string `messageId`. -pub fn readable_message_id(bytes: &[u8]) -> Option { - let value = rmpv::decode::read_value(&mut &*bytes).ok()?; - let rmpv::Value::Map(entries) = value else { - return None; - }; - entries.into_iter().find_map(|(key, value)| match (key.as_str(), value) { - (Some("messageId"), rmpv::Value::String(id)) => id.into_str().filter(|id| !id.is_empty()), - _ => None, - }) -} - -/// The first required field (`document`, then `schemaId`) that a put or delete frame lacks or holds as nil -/// (write-receipt.md, "Required fields"), with the write's operation (`"put"` or `"delete"`). `None` when the -/// bytes are not such a frame, or both fields are present. A put's `schemaId` is its document's. -pub fn missing_required_write_field(bytes: &[u8]) -> Option<(&'static str, &'static str)> { - let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &*bytes).ok()? else { - return None; - }; - let field = |entries: &[(rmpv::Value, rmpv::Value)], name: &str| { - entries - .iter() - .find(|(key, _)| key.as_str() == Some(name)) - .map(|(_, value)| value.clone()) - .filter(|value| !value.is_nil()) - }; - match field(&entries, "schemaVersion")?.as_str()? { - "cultnet.document_put_raw.v0" => match field(&entries, "document") { - Some(rmpv::Value::Map(document)) => { - field(&document, "schemaId").is_none().then_some(("put", "schemaId")) - } - _ => Some(("put", "document")), - }, - "cultnet.document_delete.v0" => field(&entries, "schemaId").is_none().then_some(("delete", "schemaId")), - _ => None, - } -} diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index eae7a2ee..6a2aa20b 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -468,14 +468,6 @@ impl CultNetRudpSession { self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec()) } - /// Ends a session because the server itself could not answer a write it owes an answer to (its answer - /// could not be encoded, queued or sent). The peer did nothing wrong: this goodbye is not the - /// "session refused a packet" of a protocol refusal. - pub fn end_local_failure_session(&mut self) -> CultNetRudpPacket { - self.reset_peer_state(); - self.create_disconnect(RUDP_LOCAL_FAILURE_REASON.to_vec()) - } - /// Ends a session that owes its peer a packet that can never be sent as /// built (see `is_permanent_send_error`). Resending it would fail forever, /// and dropping it would leave a reliable sequence the peer waits on for @@ -2617,8 +2609,6 @@ where /// Sent to a peer whose session refused one of its packets, and reported to /// the local caller as the disconnect reason. const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet"; -/// Goodbye reason of a session whose server could not answer a write (`end_local_failure_session`). -const RUDP_LOCAL_FAILURE_REASON: &[u8] = b"server could not answer a write"; const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out"; /// Prefix of the goodbye reason for a session that owed a packet which can /// never be sent; the failure's fixed name (`send_error_code`) follows it. diff --git a/packages/cultnet-rs/tests/write_receipt.rs b/packages/cultnet-rs/tests/write_receipt.rs deleted file mode 100644 index 6d06a68c..00000000 --- a/packages/cultnet-rs/tests/write_receipt.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Wire parity with contracts/cultnet/interop/write-receipt-vectors.json: the content hash, and the two new frames. - -use cultnet_rs::{ - CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, - encode_cultnet_message_to_vec, write_content_hash, -}; - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -#[test] -fn the_content_hash_matches_the_spec_vectors() { - let a = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x31]; - let b = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x32]; - let cases = [ - (write_content_hash("put", "note.v0", "note:1", Some(&a)), "7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d"), - (write_content_hash("put", "note.v0", "note:1", Some(&b)), "58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f"), - (write_content_hash("put", "note.v0", "note:2", Some(&a)), "a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8"), - (write_content_hash("delete", "note.v0", "note:1", None), "232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37"), - ]; - for (hash, expected) in cases { - assert_eq!(hex(&hash.unwrap()), expected); - } -} - -#[test] -fn the_new_frames_decode_back_equal() { - let admitted = CultNetMessage::WriteAdmitted { - message_id: "put-1".into(), - operation: "put".into(), - schema_id: "note.v0".into(), - record_key: "note:1".into(), - }; - let bytes = encode_cultnet_message_to_vec(&admitted, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!( - decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(), - admitted - ); - // error.v1 with a nil id round-trips as nil, and the code enum is v0's. - let error = CultNetMessage::ErrorV1 { - error: "message_invalid: unreadable frame".into(), - code: Some(CultNetErrorCode::MessageInvalid), - details: None, - message_id: None, - }; - let bytes = encode_cultnet_message_to_vec(&error, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!( - decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(), - error - ); -} - -fn unhex(text: &str) -> Vec { - (0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()).collect() -} - -/// The spec's wire vectors (write-receipt-vectors.json `wire`): the bytes decode to the vector's fields, not -/// merely to something that encodes back to itself. -#[test] -fn the_spec_wire_vectors_decode_to_their_expected_fields() { - let admitted = decode_cultnet_message_from_slice( - &unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), - CultNetWireContract::CultNetSchemaV0, - ) - .unwrap(); - assert_eq!( - admitted, - CultNetMessage::WriteAdmitted { - message_id: "put-1".into(), - operation: "put".into(), - schema_id: "note.v0".into(), - record_key: "note:1".into(), - } - ); - let refused = decode_cultnet_message_from_slice( - &unhex("86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32"), - CultNetWireContract::CultNetSchemaV0, - ) - .unwrap(); - let CultNetMessage::ErrorV1 { error, code, details, message_id } = refused else { - panic!("expected error.v1"); - }; - assert_eq!(error, "write_not_authorized: the catalog refused the document"); - assert_eq!(code, Some(CultNetErrorCode::WriteNotAuthorized)); - assert_eq!(message_id.as_deref(), Some("put-2")); - let details = details.unwrap(); - assert_eq!(details.field.as_deref(), Some("recordKey")); - assert_eq!(details.value.as_deref(), Some("note:2")); - assert_eq!((details.as_of, details.current), (None, None)); -} - -/// Spec vector `error_v1_limit_reached`: the bytes the server must send, and what they decode to. -#[test] -fn error_v1_limit_reached_is_the_spec_vector() { - let vector = "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235"; - let message = CultNetMessage::ErrorV1 { - error: "limit_reached: the session's remembered-write limit is full".into(), - code: Some(CultNetErrorCode::LimitReached), - details: Some(CultNetErrorDetails::default()), - message_id: Some("put-1025".into()), - }; - let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!(bytes, unhex(vector), "encoded bytes must equal the spec vector"); - let CultNetMessage::ErrorV1 { error, code, details, message_id } = - decode_cultnet_message_from_slice(&unhex(vector), CultNetWireContract::CultNetSchemaV0).unwrap() - else { - panic!("expected error.v1"); - }; - assert_eq!(error, "limit_reached: the session's remembered-write limit is full"); - assert_eq!(code, Some(CultNetErrorCode::LimitReached)); - assert_eq!(message_id.as_deref(), Some("put-1025")); - assert_eq!(details, Some(CultNetErrorDetails::default())); -} - -/// Spec vectors `write_admitted_put` and `write_admitted_delete`: the encoder emits the spec's field order. -#[test] -fn write_admitted_encodes_to_the_spec_vector_bytes() { - let cases = [ - ("put-1", "put", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), - ("del-1", "delete", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A564656C2D31A96F7065726174696F6EA664656C657465A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), - ]; - for (id, operation, vector) in cases { - let message = CultNetMessage::WriteAdmitted { - message_id: id.into(), - operation: operation.into(), - schema_id: "note.v0".into(), - record_key: "note:1".into(), - }; - let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!(bytes, unhex(vector)); - } -} - -/// `limit_reached` is in the error.v1 enum only: a v0 error neither encodes nor decodes it. -#[test] -fn error_v0_refuses_the_v1_only_limit_reached_code() { - let v0 = CultNetMessage::Error { - error: "x".into(), - code: Some(CultNetErrorCode::LimitReached), - details: None, - }; - assert!(encode_cultnet_message_to_vec(&v0, CultNetWireContract::CultNetSchemaV0).is_err()); - let v1 = CultNetMessage::ErrorV1 { - error: "x".into(), - code: Some(CultNetErrorCode::LimitReached), - details: None, - message_id: None, - }; - let v1_bytes = encode_cultnet_message_to_vec(&v1, CultNetWireContract::CultNetSchemaV0).unwrap(); - let at = v1_bytes.windows(16).position(|w| w == b"cultnet.error.v1").unwrap(); - let mut as_v0 = v1_bytes.clone(); - as_v0[at + 15] = b'0'; - assert!(decode_cultnet_message_from_slice(&as_v0, CultNetWireContract::CultNetSchemaV0).is_err()); -} diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index af561ea5..d9e7ec09 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -1017,8 +1017,6 @@ namespace GameCult.Networking /// /// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's): /// it asks no remote write authority. A peer's put goes through , which does. - /// Returning normally means the put is committed and visible to readers: a receipt-capable peer is told - /// write_admitted on the strength of it, so a write that has only been queued must not return. /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. @@ -1049,7 +1047,6 @@ namespace GameCult.Networking /// /// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote /// write authority. A peer's delete goes through , which does. - /// Returning normally means the delete is committed and visible to readers (see ). /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 1746391e..6433c6c3 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -22,7 +22,6 @@ namespace GameCult.Networking private readonly Func _snapshotHandlerV1; private readonly Func _putHandler; private readonly Func _deleteHandler; - private readonly Func _helloHandler; private readonly Func _shardCatalogHandler; private readonly Func _shardLogHandler; private readonly Func _subscribeHandler; @@ -46,7 +45,6 @@ namespace GameCult.Networking _snapshotHandlerV1 = HandleSnapshotRequestV1Async; _putHandler = HandlePutAsync; _deleteHandler = HandleDeleteAsync; - _helloHandler = HandleHelloAsync; _shardCatalogHandler = HandleShardCatalogRequestAsync; _shardLogHandler = HandleShardLogRequestAsync; _subscribeHandler = HandleSubscribeAsync; @@ -57,7 +55,6 @@ namespace GameCult.Networking _server.OnCultNet(_snapshotHandlerV1); _server.OnCultNet(_putHandler); _server.OnCultNet(_deleteHandler); - _server.OnCultNet(_helloHandler); _server.OnCultNet(_shardCatalogHandler); _server.OnCultNet(_shardLogHandler); _server.OnCultNet(_subscribeHandler); @@ -220,7 +217,6 @@ namespace GameCult.Networking _server.RemoveCultNetMessageListener(_snapshotHandlerV1); _server.RemoveCultNetMessageListener(_putHandler); _server.RemoveCultNetMessageListener(_deleteHandler); - _server.RemoveCultNetMessageListener(_helloHandler); _server.RemoveCultNetMessageListener(_shardCatalogHandler); _server.RemoveCultNetMessageListener(_shardLogHandler); _server.RemoveCultNetMessageListener(_subscribeHandler); @@ -266,7 +262,7 @@ namespace GameCult.Networking catch (Exception ex) { _server.Logger.LogError($"CultNet snapshot v1 request failed: {ex.Message}"); - peer.SendCultNet(new CultNetErrorMessage { Error = "the snapshot request could not be answered" }); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } return Task.CompletedTask; @@ -286,8 +282,7 @@ namespace GameCult.Networking } catch (Exception ex) { - _server.Logger.LogError($"CultNet shard log request failed: {ex.Message}"); - peer.SendCultNet(new CultNetErrorMessage { Error = "the shard log request could not be answered" }); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } return Task.CompletedTask; @@ -328,256 +323,74 @@ namespace GameCult.Networking }; } - // A hello decides the connection's write framing if it is the first thing to (write-receipt.md, "Capability"). The - // server answers a receipt-capable hello with its own, so the client can tell a receipt server from a legacy one. - private Task HandleHelloAsync(CultNetHelloMessage hello, CultNetServerPeer peer) - { - if (peer.Transport != null && peer.Transport.SettleWriteFraming(hello)) - { - Answer(peer, new CultNetHelloMessage - { - RuntimeId = "gamecult-networking-database-server", - RuntimeKind = "dotnet", - SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] - }); - } - - return Task.CompletedTask; - } - - // The write's receipt state, or null when its connection did not opt in (it is then answered exactly as before). - private CultNetReceiptedWrite? BeginReceipted( - CultNetServerPeer peer, - string operation, - string? messageId, - string? schemaId, - string? recordKey, - byte[]? payload) - { - var transport = peer.Transport; - if (transport == null) return null; - transport.SettleWriteFraming(null); - var session = transport.WriteReceipts; - return session == null - ? null - : new CultNetReceiptedWrite(session, message => Answer(peer, message), operation, messageId, schemaId, recordKey, payload); - } - - // Every frame a write can be answered with. Mock point for tests: the answer leaves through the peer's transport. An - // answer that cannot be queued ends the connection: the peer settles its write as session_ended, never as silence. - private void Answer(CultNetServerPeer peer, ICultNetSchemaMessage message) - { - try - { - Send(peer, message); - } - catch (Exception ex) - { - _server.Logger.LogWarning($"CultNet write answer could not be sent; ending the connection: {ex.Message}"); - peer.Transport?.End(); - } - } - - private static void Send(CultNetServerPeer peer, ICultNetSchemaMessage message) - { - switch (message) - { - case CultNetWriteAdmittedMessage admitted: peer.SendCultNet(admitted); break; - case CultNetErrorV1Message error: peer.SendCultNet(error); break; - case CultNetErrorMessage error: peer.SendCultNet(error); break; - case CultNetHelloMessage hello: peer.SendCultNet(hello); break; - default: throw new ArgumentException($"{message.GetType().Name} is not a write answer.", nameof(message)); - } - } - - // A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other. - // An empty or blank schemaId or recordKey names no record: refused as a frame that does not read, exactly as the Rust - // server's decoder refuses it (write-receipt.md, "Required fields"), never handed to the store. - private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) => - string.IsNullOrWhiteSpace(schemaId) || string.IsNullOrWhiteSpace(recordKey); - - private static CultNetErrorMessage UnreadableWriteTarget() => - CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"); - - private static void Refuse(CultNetServerPeer peer, CultNetReceiptedWrite? write, CultNetErrorMessage error) - { - if (write != null) write.Refused(error); - else peer.SendCultNet(error); - } - - // Rule 6: a forward is answered with what the primary answered, under the original id. A refusal the primary sent is - // relayed; silence or any other failure relays nothing, so the client settles it unconfirmed. - private void RelayForwardFailure(CultNetReceiptedWrite write, Exception failure) - { - if (failure is CultNetWriteRefusedException refused) - { - write.Refused(refused.Refusal); - return; - } - - _server.Logger.LogWarning($"CultNet forwarded write was not answered by its primary: {failure.Message}"); - // Only a forward that provably never left (not sent) may be forwarded again. Every other failure, silence or not, is - // unconfirmed: the primary may have applied it, so the id is never forwarded again on this session. - if (failure is CultNetWriteNotSentException) write.NotSent(); - else write.LeftSilent(); - } - private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer) { - var write = BeginReceipted(peer, "put", message.MessageId, message.Document?.SchemaId, message.Document?.RecordKey, message.Document?.Payload); - var idRefusal = write?.IdRefusal; - if (idRefusal != null) - { - write!.Refused(idRefusal); - return; - } - - var missing = message.Document == null ? "document" : message.Document.SchemaId == null ? "schemaId" : null; - if (missing != null) - { - Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid(missing, $"{missing} is required")); - return; - } - - if (IsUnreadableWriteTarget(message.Document!.SchemaId, message.Document.RecordKey)) - { - Refuse(peer, write, UnreadableWriteTarget()); - return; - } - - if (write != null && !write.Begin()) return; - - var authorize = Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch); try { - await _database.ApplyPutAsync(message, authorize).ConfigureAwait(false); - write?.Admitted(); + await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch)) + .ConfigureAwait(false); } catch (CultNetRemoteWriteRefusedException ex) { _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); - Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); + peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { - bool forwarded; - try - { - forwarded = await TryForwardPutAsync(ex, message).ConfigureAwait(false); - } - catch (CultNetWriteUnconfirmedException) when (write == null) - { - // A peer that did not opt in is told nothing of a forward its primary left unanswered. - return; - } - catch (Exception failure) when (write != null) - { - RelayForwardFailure(write, failure); - return; - } - - if (forwarded) - { - write?.Admitted(); - } - else + if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false)) { _server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}"); - Refuse(peer, write, CreateRoutingError(ex)); + peer.SendCultNet(CreateRoutingError(ex)); } } catch (CultNetUnownedSchemaException ex) { _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); - Refuse(peer, write, CultNetErrorMessage.ForUnownedSchema(ex)); + peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex)); } catch (Exception ex) when (CommittedWithLogFailure(ex)) { // The write committed and was published; only its shard log failed, so the peer is not told it was refused. _server.Logger.LogWarning($"CultNet raw put committed but its shard log failed: {ex.Message}"); - write?.Admitted(); } catch (Exception ex) { _server.Logger.LogError($"CultNet raw put failed: {ex.Message}"); - Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText }); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } } private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, CultNetServerPeer peer) { - var write = BeginReceipted(peer, "delete", message.MessageId, message.SchemaId, message.RecordKey, null); - var idRefusal = write?.IdRefusal; - if (idRefusal != null) - { - write!.Refused(idRefusal); - return; - } - - if (message.SchemaId == null) - { - Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid("schemaId", "schemaId is required")); - return; - } - - if (IsUnreadableWriteTarget(message.SchemaId, message.RecordKey)) - { - Refuse(peer, write, UnreadableWriteTarget()); - return; - } - - if (write != null && !write.Begin()) return; - - var authorize = Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch); try { - await _database.ApplyDeleteAsync(message, authorize).ConfigureAwait(false); - write?.Admitted(); + await _database.ApplyDeleteAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch)) + .ConfigureAwait(false); } catch (CultNetRemoteWriteRefusedException ex) { _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); - Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); + peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { - bool forwarded; - try + if (!await TryForwardDeleteAsync(ex, message).ConfigureAwait(false)) { - forwarded = await TryForwardDeleteAsync(ex, message).ConfigureAwait(false); - } - catch (CultNetWriteUnconfirmedException) when (write == null) - { - return; - } - catch (Exception failure) when (write != null) - { - RelayForwardFailure(write, failure); - return; - } - - if (forwarded) - { - write?.Admitted(); - } - else - { - Refuse(peer, write, CreateRoutingError(ex)); + peer.SendCultNet(CreateRoutingError(ex)); } } catch (CultNetUnownedSchemaException ex) { _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); - Refuse(peer, write, CultNetErrorMessage.ForUnownedSchema(ex)); + peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex)); } catch (Exception ex) when (CommittedWithLogFailure(ex)) { _server.Logger.LogWarning($"CultNet raw delete committed but its shard log failed: {ex.Message}"); - write?.Admitted(); } catch (Exception ex) { - Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText }); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } } @@ -775,7 +588,7 @@ namespace GameCult.Networking var refusedKey = change.GetType().GetProperty("Key")?.GetValue(change) is CultRecordKey changed ? changed.Value : string.Empty; lock (refused) refused.Add(refusedKey); - sendError(new CultNetErrorMessage { Error = "the record is a variant CultNet cannot carry" }); + sendError(new CultNetErrorMessage { Error = refusal.Message }); return null; } @@ -853,14 +666,11 @@ namespace GameCult.Networking exception is CultNetShardLogException || exception is AggregateException aggregate && aggregate.Flatten().InnerExceptions.Any(inner => inner is CultNetShardLogException); - // Fixed text: the routing refusal never carries an exception's own message (the shard and reason travel in the hint). - internal const string RoutingRefusalText = "the write belongs to another shard primary"; - private static CultNetErrorMessage CreateRoutingError(CultNetShardAuthorityException exception) { return new CultNetErrorMessage { - Error = RoutingRefusalText, + Error = exception.Message, RoutingHint = new CultNetShardRoutingHint { Reason = exception.Reason, diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs index 79f0ce20..83865e31 100644 --- a/src/GameCult.Networking/CultNetSchemaClient.cs +++ b/src/GameCult.Networking/CultNetSchemaClient.cs @@ -27,8 +27,7 @@ namespace GameCult.Networking /// /// Sends one schema-v0 message through the transport. Fire-and-forget: an ack or a return is not admission; - /// read the result frame (). Throws only - /// when it can prove no byte was written; any other exception leaves the send ambiguous and a forwarded write unconfirmed. + /// read the result frame (). /// void SendCultNet(T message) where T : ICultNetSchemaMessage; diff --git a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs index cdcca912..d098a923 100644 --- a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs +++ b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs @@ -53,8 +53,6 @@ namespace GameCult.Networking CultNetSchemaVersions.Verify => Deserialize(payload), CultNetSchemaVersions.LoginSuccess => Deserialize(payload), CultNetSchemaVersions.Error => Deserialize(payload), - CultNetSchemaVersions.ErrorV1 => Deserialize(payload), - CultNetSchemaVersions.WriteAdmitted => Deserialize(payload), CultNetSchemaVersions.SampleChangeName => Deserialize(payload), CultNetSchemaVersions.SampleChat => Deserialize(payload), CultNetSchemaVersions.OperationRequest => Deserialize(payload), diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index 090c6a45..5f37f150 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -48,14 +48,6 @@ namespace GameCult.Networking /// public const string Error = "cultnet.error.v0"; /// - /// error v1 contract identifier: error v0 plus the id of the request it answers (write-receipt.md). - /// - public const string ErrorV1 = "cultnet.error.v1"; - /// - /// write admitted receipt contract identifier (write-receipt.md). - /// - public const string WriteAdmitted = "cultnet.write_admitted.v0"; - /// /// sample change name contract identifier. /// public const string SampleChangeName = "cultnet.sample.change_name.v0"; @@ -546,7 +538,7 @@ namespace GameCult.Networking public static CultNetErrorMessage ForUnownedSchema(CultNetUnownedSchemaException ex) => new CultNetErrorMessage { - Error = "unowned_schema: no shard owns the schema and key", + Error = $"unowned_schema: {ex.Message}", Code = "unowned_schema", Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId } }; @@ -629,80 +621,6 @@ namespace GameCult.Networking }; } - /// - /// cultnet.error.v1 (contracts/cultnet/interop/write-receipt.md): the v0 error plus , - /// the id of the request it answers. Sent only to a connection that declared the write-receipt capability. - /// - [MessagePackObject] - public class CultNetErrorV1Message : ICultNetSchemaMessage - { - /// Gets or sets the schema version. - [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.ErrorV1; - /// Gets or sets the error. - [Key("error")] public string Error { get; set; } = string.Empty; - /// Gets or sets optional shard routing details for authority failures. - [Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; } - /// Gets or sets the machine-readable refusal code (the v0 enum). - [Key("code")] public string? Code { get; set; } - /// Gets or sets the code-specific structured detail. - [Key("details")] public CultNetErrorDetails? Details { get; set; } - /// Gets or sets the id of the request this answers; null only when no id could be read. - [Key("messageId")] public string? MessageId { get; set; } - - /// Builds the v1 form of answering . - public static CultNetErrorV1Message From(CultNetErrorMessage error, string? messageId) - { - if (error == null) throw new ArgumentNullException(nameof(error)); - return new CultNetErrorV1Message - { - Error = error.Error, - RoutingHint = error.RoutingHint, - Code = error.Code, - Details = error.Details, - MessageId = string.IsNullOrEmpty(messageId) ? null : messageId - }; - } - - /// - /// The refusal of a write a session has no room to remember (write-receipt.md, rule 5): code limit_reached, - /// details present and all nil. Exists only as error.v1; v0 has no such code. - /// - public static CultNetErrorV1Message LimitReached(string? messageId) => new CultNetErrorV1Message - { - Error = "limit_reached: the session's remembered-write limit is full", - Code = "limit_reached", - Details = new CultNetErrorDetails(), - MessageId = string.IsNullOrEmpty(messageId) ? null : messageId - }; - - /// Gets the v0 form, dropping the id. - public CultNetErrorMessage ToV0() => new CultNetErrorMessage - { - Error = Error, - RoutingHint = RoutingHint, - Code = Code, - Details = Details - }; - } - - /// - /// cultnet.write_admitted.v0: the receipt for one admitted put or delete, sent after its commit point. - /// - [MessagePackObject] - public class CultNetWriteAdmittedMessage : ICultNetSchemaMessage - { - /// Gets or sets the schema version. - [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.WriteAdmitted; - /// Gets or sets the messageId of the admitted write, echoed. - [Key("messageId")] public string MessageId { get; set; } = string.Empty; - /// Gets or sets put or delete. - [Key("operation")] public string Operation { get; set; } = string.Empty; - /// Gets or sets the schemaId of the admitted write, echoed. - [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; - /// Gets or sets the recordKey of the admitted write, echoed. - [Key("recordKey")] public string RecordKey { get; set; } = string.Empty; - } - /// /// Code-specific structured detail carried on (R-N). Fields are /// populated only for the code that defines them; every field is otherwise omitted (nil on the wire). diff --git a/src/GameCult.Networking/CultNetShardWriteForwarding.cs b/src/GameCult.Networking/CultNetShardWriteForwarding.cs index e4e66298..0fa6c4f7 100644 --- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs +++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs @@ -10,16 +10,12 @@ namespace GameCult.Networking public interface ICultNetShardWriteForwarder { /// - /// Forwards a raw document put to the shard owner. Completes only when the owner answered that it admitted the write; - /// throws when it refused, when - /// nothing could have reached it (the only failure after which the id may be forwarded again), and any other exception - /// when the frame may have gone out and was not answered (unconfirmed, including ). The server relays to the original peer exactly what the owner answered. + /// Forwards a raw document put to the shard owner. /// Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message); /// - /// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as - /// does. + /// Forwards a raw document delete to the shard owner. /// Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message); } @@ -74,12 +70,10 @@ namespace GameCult.Networking public Func? CreateClient { get; set; } /// - /// Gets or sets how long the primary has to answer a forwarded write (from the send). Its receipt completes the forward, - /// its refusal fails it with , and silence fails it with - /// . Defaults to ; a - /// primary that predates write receipts never answers, so forwarding to one is unconfirmed after this window. + /// Gets or sets how long after a forwarded write the primary's refusal may still arrive; a refusal fails the + /// forward with . Defaults to . /// - public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow; + public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow; } /// @@ -98,29 +92,29 @@ namespace GameCult.Networking } /// - /// Forwards a put under its original message id and completes on the primary's result frame: a receipt returns, a refusal - /// throws and silence throws . + /// Forwards a put and completes on the primary's result frame: a refusal throws + /// , the same failure path as an unreachable primary. /// public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); - if (shard == null) throw new ArgumentNullException(nameof(shard)); + var endpoint = ResolvePrimaryEndpoint(shard); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; - await SendAsync(shard, message, message.MessageId).ConfigureAwait(false); + await SendAsync(endpoint, message).ConfigureAwait(false); } /// - /// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as - /// does. + /// Forwards a delete and completes on the primary's result frame: a refusal throws + /// . /// public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); - if (shard == null) throw new ArgumentNullException(nameof(shard)); + var endpoint = ResolvePrimaryEndpoint(shard); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; - await SendAsync(shard, message, message.MessageId).ConfigureAwait(false); + await SendAsync(endpoint, message).ConfigureAwait(false); } /// @@ -144,50 +138,19 @@ namespace GameCult.Networking return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port); } - // The write frame has not been handed to the transport until this returns: any failure here is CultNetWriteNotSentException, - // the one failure the caller may retry under the same id. - private async Task ConnectBeforeSendAsync(CultNetShardDescriptor shard, string messageId) + private async Task SendAsync(string endpoint, T message) where T : ICultNetSchemaMessage { - ICultNetSchemaClient? client = null; - try - { - var endpoint = ResolvePrimaryEndpoint(shard); - var (host, port) = ParseEndpoint(endpoint); - client = CreateClient(endpoint); - client.Connect(host, port); - await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); - // The hello comes first on the ordered schema channel: it opts this connection in to the primary's receipts. - client.SendCultNet(new CultNetHelloMessage + var (host, port) = ParseEndpoint(endpoint); + using var client = CreateClient(endpoint); + client.Connect(host, port); + await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); + await CultNetWriteCompletion.SendAndAwaitResultAsync( + refuse => { - RuntimeId = "gamecult-networking-shard-forwarder", - RuntimeKind = "dotnet", - SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] - }); - return client; - } - catch (Exception failure) - { - client?.Dispose(); - if (failure is CultNetWriteNotSentException) throw; - throw new CultNetWriteNotSentException(messageId, failure); - } - } - - private async Task SendAsync(CultNetShardDescriptor shard, T message, string messageId) where T : ICultNetSchemaMessage - { - using var client = await ConnectBeforeSendAsync(shard, messageId).ConfigureAwait(false); - // From the write's send on, every failure (timeout, reset, silence, an exception from the send itself) propagates as it - // is: the frame may have gone out, so the caller treats it as unconfirmed. - await CultNetWriteCompletion.SendAndAwaitReceiptAsync( - (admitted, refuse, refuseV1) => - { - client.OnCultNet(admitted); client.OnCultNet(refuse); - client.OnCultNet(refuseV1); return null; }, () => client.SendCultNet(message), - messageId, _options.ResultWindow).ConfigureAwait(false); } diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index fc41b2f4..da465326 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -3533,8 +3533,7 @@ namespace GameCult.Networking /// public sealed class LiteNetLibTransportConnection { - private readonly Action? _deliver; - private readonly Action? _end; + private readonly NetPeer? _peer; private readonly CultNetTransportStats _stats = new CultNetTransportStats(); /// @@ -3542,99 +3541,10 @@ namespace GameCult.Networking /// public LiteNetLibTransportConnection(NetPeer peer, CultNetTransportProfile? profile = null) { - if (peer == null) throw new ArgumentNullException(nameof(peer)); - _deliver = payload => peer.Send(payload, DeliveryMethod.ReliableOrdered); - _end = peer.Disconnect; + _peer = peer ?? throw new ArgumentNullException(nameof(peer)); Profile = profile ?? CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer"); } - // The mock point for what leaves the connection: the peer's reliable ordered send. - internal LiteNetLibTransportConnection(Action deliver, Action? end = null) - { - _deliver = deliver ?? throw new ArgumentNullException(nameof(deliver)); - _end = end; - Profile = CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer"); - } - - /// - /// Ends the connection. A write that can be neither answered nor refused is answered by this goodbye: the peer sees - /// its session end and settles the write as unconfirmed (session_ended), never as silence. - /// - internal void End() => _end?.Invoke(); - - /// - /// Answers a schema frame that did not decode with a message_invalid error.v1 naming its id when it holds one; - /// when that answer cannot be sent the connection ends instead. - /// - internal void AnswerUnreadableSchemaFrame(byte[] payload) - { - try - { - SendSchema(CultNetErrorV1Message.From( - CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"), - CultNetReadableMessageId.From(payload))); - } - catch (Exception) - { - End(); - } - } - - /// - /// A put or delete that arrived on an unreliable or sequenced channel of a receipt session (write-receipt.md, Reliable channel only): - /// answered message_invalid, details channel, fixed text, carrying the write's id, and not applied. True when - /// the frame was such a write and has been answered here. - /// - internal bool RefuseUnreliableWrite(ICultNetSchemaMessage message, DeliveryMethod method) - { - if (WriteReceipts == null) return false; - if (method is DeliveryMethod.ReliableOrdered or DeliveryMethod.ReliableUnordered) return false; - var messageId = message switch - { - CultNetDocumentPutRawMessage put => put.MessageId, - CultNetDocumentDeleteMessage delete => delete.MessageId, - _ => null - }; - if (messageId == null && message is not (CultNetDocumentPutRawMessage or CultNetDocumentDeleteMessage)) return false; - try - { - SendSchema(CultNetErrorV1Message.From( - CultNetErrorMessage.ForMessageInvalid("channel", "receipt sessions require the reliable channel"), messageId)); - } - catch (Exception) - { - End(); - } - - return true; - } - - private readonly object _writeFramingGate = new(); - private bool _writeFramingSettled; - - /// - /// The write-receipt state of a connection that opted in (write-receipt.md); null for every other connection, which is - /// answered exactly as before. - /// - internal CultNetWriteReceiptSession? WriteReceipts { get; private set; } - - /// - /// Decides this connection's write framing once: by its first hello, or by its first write if no hello came first. A - /// later hello changes nothing. The capability changes framing only; it never authorizes. Returns true when this call - /// opted the connection in. - /// - internal bool SettleWriteFraming(CultNetHelloMessage? hello) - { - lock (_writeFramingGate) - { - if (_writeFramingSettled) return false; - _writeFramingSettled = true; - if (hello == null || !CultNetWriteReceiptCapability.Declares(hello)) return false; - WriteReceipts = new CultNetWriteReceiptSession(); - return true; - } - } - /// /// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification. /// @@ -3670,14 +3580,14 @@ namespace GameCult.Networking public void Send(string channelId, byte[] payload) { if (payload == null) throw new ArgumentNullException(nameof(payload)); - if (_deliver == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport."); + if (_peer == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport."); if (!string.Equals(channelId, "schema", StringComparison.Ordinal) && !string.Equals(channelId, "legacy", StringComparison.Ordinal)) { throw new InvalidOperationException($"litenetlib transport only supports schema and legacy channels, got \"{channelId}\"."); } - _deliver(payload); + _peer.Send(payload, DeliveryMethod.ReliableOrdered); _stats.BytesSent += payload.Length; _stats.FramesSent++; } diff --git a/src/GameCult.Networking/CultNetWriteCompletion.cs b/src/GameCult.Networking/CultNetWriteCompletion.cs index 8767be6e..6d5c226d 100644 --- a/src/GameCult.Networking/CultNetWriteCompletion.cs +++ b/src/GameCult.Networking/CultNetWriteCompletion.cs @@ -24,40 +24,6 @@ namespace GameCult.Networking public CultNetErrorMessage Refusal { get; } } - /// - /// A write nothing answered within its window: neither admitted nor refused (write-receipt.md, "unconfirmed"). - /// - public sealed class CultNetWriteUnconfirmedException : Exception - { - /// Creates the exception for the write . - public CultNetWriteUnconfirmedException(string messageId) - : base($"No admission or refusal arrived for write '{messageId}'.") - { - MessageId = messageId; - } - - /// Gets the id of the unconfirmed write. - public string MessageId { get; } - } - - /// - /// A forwarded write that provably never left this process: the connection failed, or the send failed before any byte was - /// written. It is the only forwarding failure after which the same id may be forwarded again. A send port that cannot prove - /// nothing was written must not throw this; every other failure of a forward is unconfirmed. - /// - public sealed class CultNetWriteNotSentException : Exception - { - /// Creates the exception for the write ; is for logs only. - public CultNetWriteNotSentException(string messageId, Exception? cause = null) - : base($"Write '{messageId}' was not sent.", cause) - { - MessageId = messageId; - } - - /// Gets the id of the write that was not sent. - public string MessageId { get; } - } - /// /// Completes a write on the peer's result frame, never on a send's return or a transport acknowledgement: /// an ack proves delivery, not admission. @@ -67,49 +33,6 @@ namespace GameCult.Networking /// How long after a send a refusal may still arrive when the caller names no window. public static readonly TimeSpan DefaultResultWindow = TimeSpan.FromMilliseconds(500); - /// How long a receipt-capable peer has to answer a write (write-receipt.md, RECEIPT_RESULT_WINDOW_MS). - public static readonly TimeSpan ReceiptResultWindow = TimeSpan.FromMilliseconds(5000); - - /// - /// Sends one write to a receipt-capable peer and waits for its answer: a - /// write_admitted naming returns, an error.v1 naming it throws - /// , and an id-less error (v0, or v1 with no id) is attributed to this write - /// because it is the only one in flight on the connection. Silence throws . - /// - public static async Task SendAndAwaitReceiptAsync( - Func, Action, Action, IDisposable?> subscribe, - Action send, - string messageId, - TimeSpan resultWindow, - CancellationToken cancellationToken = default) - { - if (subscribe == null) throw new ArgumentNullException(nameof(subscribe)); - if (send == null) throw new ArgumentNullException(nameof(send)); - var answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var registration = subscribe( - admitted => - { - if (string.Equals(admitted.MessageId, messageId, StringComparison.Ordinal)) answer.TrySetResult(null); - }, - error => answer.TrySetResult(error), - error => - { - if (error.MessageId == null || string.Equals(error.MessageId, messageId, StringComparison.Ordinal)) - answer.TrySetResult(error.ToV0()); - }); - send(); - var window = Task.Delay(resultWindow, cancellationToken); - await Task.WhenAny(answer.Task, window).ConfigureAwait(false); - if (!answer.Task.IsCompleted) - { - await window.ConfigureAwait(false); - throw new CultNetWriteUnconfirmedException(messageId); - } - - var refusal = await answer.Task.ConfigureAwait(false); - if (refusal != null) throw new CultNetWriteRefusedException(refusal); - } - /// /// Sends one write and waits for the peer's answer. A refusal frame rejects with /// whatever its order against the transport acknowledgement. A peer diff --git a/src/GameCult.Networking/CultNetWriteReceipts.cs b/src/GameCult.Networking/CultNetWriteReceipts.cs deleted file mode 100644 index 2a90f35d..00000000 --- a/src/GameCult.Networking/CultNetWriteReceipts.cs +++ /dev/null @@ -1,396 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Security.Cryptography; -using GameCult.Caching; -using MessagePack; - -namespace GameCult.Networking -{ - /// - /// The content hash a write-receipt server binds to a (peer, messageId) dedupe key - /// (contracts/cultnet/interop/write-receipt.md, rule 5). - /// - public static class CultNetWriteContentHash - { - /// - /// SHA-256 of the MessagePack array [operation, schemaId, recordKey, payload], payload a bin - /// for a put and nil for a delete. - /// - public static byte[] Compute(string operation, string schemaId, string recordKey, byte[]? payload) - { - var buffer = new System.Buffers.ArrayBufferWriter(); - var writer = new MessagePackWriter(buffer); - writer.WriteArrayHeader(4); - writer.Write(operation); - writer.Write(schemaId); - writer.Write(recordKey); - if (payload == null) writer.WriteNil(); - else writer.Write(payload); - writer.Flush(); - using var sha = SHA256.Create(); - return sha.ComputeHash(buffer.WrittenSpan.ToArray()); - } - } - - /// - /// Whether a peer's hello opted in to write receipts: it lists both cultnet.write_admitted.v0 and - /// cultnet.error.v1. The capability changes framing only; it never authorizes. - /// - internal static class CultNetWriteReceiptCapability - { - public static bool Declares(CultNetHelloMessage hello) - { - var versions = hello.SupportedMessageVersions; - return versions != null - && Array.IndexOf(versions, CultNetSchemaVersions.WriteAdmitted) >= 0 - && Array.IndexOf(versions, CultNetSchemaVersions.ErrorV1) >= 0; - } - } - - /// A SHA-256 digest held by value: the fixed-size stand-in for an id or a content hash. - internal readonly struct CultNetDigest : IEquatable - { - private readonly ulong _a, _b, _c, _d; - - private CultNetDigest(ulong a, ulong b, ulong c, ulong d) { _a = a; _b = b; _c = c; _d = d; } - - public static CultNetDigest Of(byte[] bytes) - { - using var sha = SHA256.Create(); - var hash = sha.ComputeHash(bytes); - return new CultNetDigest( - BitConverter.ToUInt64(hash, 0), BitConverter.ToUInt64(hash, 8), - BitConverter.ToUInt64(hash, 16), BitConverter.ToUInt64(hash, 24)); - } - - public bool Equals(CultNetDigest other) => _a == other._a && _b == other._b && _c == other._c && _d == other._d; - public override bool Equals(object? obj) => obj is CultNetDigest other && Equals(other); - public override int GetHashCode() => unchecked((int)_a ^ (int)(_a >> 32)); - public static bool operator ==(CultNetDigest left, CultNetDigest right) => left.Equals(right); - public static bool operator !=(CultNetDigest left, CultNetDigest right) => !left.Equals(right); - } - - /// - /// What a session keeps of a refusal (write-receipt.md, What is remembered): its code, the field its details name, and its - /// routing hint (a shard descriptor the server owns). Never the write's strings or an authority's reason: the replay is built - /// from fixed text and the fields of the resend, which carries the same content. - /// - internal readonly struct CultNetRememberedRefusal - { - public string? Code { get; } - public bool HasDetails { get; } - public string? Field { get; } - public CultNetShardRoutingHint? Hint { get; } - - public CultNetRememberedRefusal(string? code, bool hasDetails, string? field, CultNetShardRoutingHint? hint) - { - Code = code; HasDetails = hasDetails; Field = field; Hint = hint; - } - - public static CultNetRememberedRefusal Of(CultNetErrorMessage error) => - new(error.Code, error.Details != null, error.Details?.Field, error.RoutingHint); - - private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value; - - /// The refusal, in fixed text, naming the write's own record key or schema id where its details do. - public CultNetErrorMessage ToError(string schemaId, string recordKey) => new() - { - Error = Code switch - { - "unowned_schema" => "unowned_schema: no shard owns the schema and key", - null => Hint != null ? CultNetDatabaseServer.RoutingRefusalText : CultNetWriteReceiptSession.WriteFailedText, - _ => Code - }, - Code = Code, - RoutingHint = Hint, - Details = HasDetails - ? new CultNetErrorDetails - { - Field = Field, - Value = Field switch { "recordKey" => NullIfEmpty(recordKey), "schemaId" => NullIfEmpty(schemaId), _ => null } - } - : null - }; - } - - /// - /// One receipt-capable peer's writes: message id to content hash and the answer given, refusals included, so a resend of the - /// same content is answered again without being decided again and the same id with other content is refused. Ids and content - /// hashes are held as fixed-size digests; the answer is held as its kind (and, for a refusal, its code and the field its details name), - /// never the strings of the write. - /// - internal sealed class CultNetWriteReceiptSession - { - /// The most writes a peer is remembered for, refused ones included; a peer at the limit has new writes refused. - internal const int MaxRememberedWrites = 1024; - - /// The most ids refused at the limit that are remembered, so their resend is judged the same way. - internal const int MaxRememberedLimitRefusals = 1024; - - /// The longest messageId a write may carry, in UTF-8 bytes (write-receipt.md, Id size). - internal const int MaxMessageIdBytes = 256; - - /// What a write is refused with when it failed for a reason no typed refusal names. Never an exception's own text. - internal const string WriteFailedText = "the write could not be applied"; - - private readonly object _gate = new(); - private readonly Dictionary _writes = new(); - private readonly Dictionary _limitRefusals = new(); - - private sealed class Entry - { - public Entry(CultNetDigest hash) { Hash = hash; } - public CultNetDigest Hash { get; set; } - public bool Abandoned { get; set; } - public bool Admitted { get; set; } - public CultNetRememberedRefusal? Refusal { get; set; } - } - - public enum Begin { New, ReplayAdmitted, ReplayRefusal, Unanswered, DifferentContent, Full } - - private static CultNetDigest IdDigest(string messageId) => CultNetDigest.Of(System.Text.Encoding.UTF8.GetBytes(messageId)); - - /// - /// Looks up first. An id not seen is claimed for this content (); - /// otherwise the outcome says what already holds it, and is the refusal to repeat. - /// - public Begin TryBegin(string messageId, byte[] hash, out CultNetRememberedRefusal? refusal) - { - var id = IdDigest(messageId); - var content = CultNetDigest.Of(hash); - lock (_gate) - { - refusal = null; - if (_writes.TryGetValue(id, out var entry)) - { - if (entry.Abandoned) - { - // Not sent, so judged afresh in the slot it already holds; no slot is ever freed. - entry.Abandoned = false; - entry.Hash = content; - return Begin.New; - } - - if (entry.Hash != content) return Begin.DifferentContent; - if (entry.Admitted) return Begin.ReplayAdmitted; - if (entry.Refusal != null) - { - refusal = entry.Refusal; - return Begin.ReplayRefusal; - } - - // No answer yet: the first attempt is still being applied, or its primary left it unanswered for good. - return Begin.Unanswered; - } - - if (_writes.Count >= MaxRememberedWrites) - { - if (_limitRefusals.TryGetValue(id, out var refused)) return refused == content ? Begin.Full : Begin.DifferentContent; - if (_limitRefusals.Count < MaxRememberedLimitRefusals) _limitRefusals[id] = content; - return Begin.Full; - } - - _writes[id] = new Entry(content); - return Begin.New; - } - } - - /// The write was admitted: remember it so a resend repeats the receipt. - public void Admitted(string messageId) - { - lock (_gate) - { - if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Admitted = true; - } - } - - /// The write was refused: remember the refusal so a resend repeats it. - public void Refused(string messageId, CultNetRememberedRefusal refusal) - { - lock (_gate) - { - if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Refusal = refusal; - } - } - - /// - /// The write never reached anyone who could have applied it: a repeat is judged afresh. The entry keeps its slot, so - /// forgetting a write never makes room for an id that was refused at the limit. - /// - public void Abandoned(string messageId) - { - lock (_gate) - { - if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Abandoned = true; - } - } - } - - /// - /// The messageId of a schema frame that does not decode (write-receipt.md, rule 3: a refusal carries the write's id whenever - /// the frame is readable enough to find it). - /// - internal static class CultNetReadableMessageId - { - /// The frame's top-level string messageId, or null when the bytes are not a map holding one. - public static string? From(byte[] payload) - { - try - { - var reader = new MessagePackReader(payload); - var entries = reader.ReadMapHeader(); - for (var index = 0; index < entries; index++) - { - var key = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null; - if (key == "messageId" && reader.NextMessagePackType == MessagePackType.String) - { - var id = reader.ReadString(); - return string.IsNullOrEmpty(id) ? null : id; - } - - if (key == null) reader.Skip(); - reader.Skip(); - } - } - catch (Exception) - { - // Not a readable map: no id. - } - - return null; - } - } - - /// - /// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5). - /// - internal sealed class CultNetReceiptedWrite - { - private readonly CultNetWriteReceiptSession _session; - private readonly Action _send; - private readonly string _operation; - private readonly string _messageId; - private readonly string _schemaId; - private readonly string _recordKey; - private readonly byte[]? _payload; - private bool _claimed; - private bool _answered; - - public CultNetReceiptedWrite( - CultNetWriteReceiptSession session, - Action send, - string operation, - string? messageId, - string? schemaId, - string? recordKey, - byte[]? payload) - { - _session = session; - _send = send; - _operation = operation; - _messageId = messageId ?? string.Empty; - _schemaId = schemaId ?? string.Empty; - _recordKey = recordKey ?? string.Empty; - _payload = payload; - } - - /// - /// Looks the write's id up before anything else is decided (rule 5). True when the id is new and the write should go on - /// to authorization and the commit; false when it was answered here (a replay, a reused id, a full table) or is owed no - /// further answer (still in flight, or left silent by its primary). - /// - public bool Begin() - { - var outcome = _session.TryBegin(_messageId, CultNetWriteContentHash.Compute(_operation, _schemaId, _recordKey, _payload), out var refusal); - switch (outcome) - { - case CultNetWriteReceiptSession.Begin.New: - _claimed = true; - return true; - case CultNetWriteReceiptSession.Begin.ReplayAdmitted: - _answered = true; - _send(AdmittedReceipt()); - return false; - case CultNetWriteReceiptSession.Begin.ReplayRefusal: - _answered = true; - _send(CultNetErrorV1Message.From(refusal!.Value.ToError(_schemaId, _recordKey), _messageId)); - return false; - case CultNetWriteReceiptSession.Begin.Unanswered: - // The first attempt is still being applied and will answer this id, or its primary left it unanswered: - // then the claim stays, so the id is never sent again on this session, and the write stays unconfirmed. - _answered = true; - return false; - case CultNetWriteReceiptSession.Begin.DifferentContent: - Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "the message id was already used for different content")); - return false; - default: - _answered = true; - _send(CultNetErrorV1Message.LimitReached(_messageId)); - return false; - } - } - - private CultNetWriteAdmittedMessage AdmittedReceipt() => new() - { - MessageId = _messageId, - Operation = _operation, - SchemaId = _schemaId, - RecordKey = _recordKey - }; - - /// The write committed: remember the receipt and send it. - public void Admitted() - { - if (_answered) return; - _answered = true; - if (_claimed) _session.Admitted(_messageId); - _send(AdmittedReceipt()); - } - - /// The write was refused: remember the refusal (a resend repeats it) and send its one error.v1. - public void Refused(CultNetErrorMessage error) - { - if (_answered) return; - _answered = true; - if (_claimed) - { - // A remembered refusal is answered from what is remembered, so the replay is the refusal again. - var kept = CultNetRememberedRefusal.Of(error); - _session.Refused(_messageId, kept); - error = kept.ToError(_schemaId, _recordKey); - } - - _send(CultNetErrorV1Message.From(error, _messageId)); - } - - /// - /// A forward that may have reached its primary and is unconfirmed (silence, reset, timeout, an ambiguous send): no answer is - /// owed or possible, and the claim stays so the id is never forwarded again on this session. Sends nothing. - /// - public void LeftSilent() - { - if (_answered) return; - _answered = true; - } - - /// A forward that was provably not sent (connect failed, or the send failed before any byte was written): forget the claim, send nothing. - public void NotSent() - { - if (_answered) return; - _answered = true; - if (_claimed) _session.Abandoned(_messageId); - _claimed = false; - } - - /// - /// Why the write's id cannot be used at all, before anything is remembered: it is missing (refused with no id) or longer - /// than UTF-8 bytes. Null when the id is usable. - /// - public CultNetErrorMessage? IdRefusal => - _messageId.Length == 0 - ? CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId") - : System.Text.Encoding.UTF8.GetByteCount(_messageId) > CultNetWriteReceiptSession.MaxMessageIdBytes - ? CultNetErrorMessage.ForMessageInvalid("messageId", "messageId exceeds 256 bytes") - : null; - } -} diff --git a/src/GameCult.Networking/Server.cs b/src/GameCult.Networking/Server.cs index 08af0cc4..128eb47d 100644 --- a/src/GameCult.Networking/Server.cs +++ b/src/GameCult.Networking/Server.cs @@ -23,7 +23,7 @@ namespace GameCult.Networking private const string EmailPattern = @"^([0-9a-zA-Z]([\+\-_\.][0-9a-zA-Z]+)*)+@(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]*\.)+[a-zA-Z0-9]{2,17})$"; private const string UsernamePattern = @"^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$"; - private const int DefaultPort = 3075; + private const int ServerPort = 3075; private const float SessionTimeoutSeconds = 1800; // 30 minutes private const float SessionRefreshThresholdSeconds = 300; // 5 minutes private const int MaxConnectionAttemptsPerMinute = 30; @@ -63,12 +63,6 @@ namespace GameCult.Networking /// public bool LogSensitivePayloads { get; set; } - /// - /// Gets or sets the UDP port binds (default 3075; 0 asks the OS for a free one). After - /// it holds the port actually bound. - /// - public int Port { get; set; } = DefaultPort; - private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d); /// @@ -80,7 +74,7 @@ namespace GameCult.Networking { TransportId = "litenetlib-server", Host = "0.0.0.0", - Port = Port + Port = ServerPort }); /// @@ -363,8 +357,7 @@ namespace GameCult.Networking UnsyncedEvents = true, NatPunchEnabled = true }; - _netManager.Start(Port); - Port = _netManager.LocalPort; + _netManager.Start(ServerPort); listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}"); @@ -415,25 +408,11 @@ namespace GameCult.Networking { var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame); Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}"); - if (transport.RefuseUnreliableWrite(cultNetMessage, method)) return; await HandleCultNetSchemaMessageAsync(peer, user, cultNetMessage).ConfigureAwait(false); return; } - Message message; - try - { - message = LiteNetLibTransportConnection.DecodeLegacy(frame); - } - catch (Exception ex) when (transport.WriteReceipts != null) - { - // A frame that decodes on neither channel is classed legacy, so this is where an unreadable frame lands. - // A receipt-capable peer is told which frame was unreadable (by its id, if the frame still holds one). - Logger.LogWarning($"CultNet frame unreadable: {ex.Message}"); - transport.AnswerUnreadableSchemaFrame(frame.Payload); - return; - } - + var message = LiteNetLibTransportConnection.DecodeLegacy(frame); if (LogSensitivePayloads) { Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(frame.Payload))}"); @@ -528,7 +507,7 @@ namespace GameCult.Networking } }); - Logger.LogInfo($"Server started on port {Port}."); + Logger.LogInfo($"Server started on port {ServerPort}."); } private async Task HandleCultNetSchemaMessageAsync(NetPeer peer, User user, ICultNetSchemaMessage message) diff --git a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs index 5713b0da..64de1093 100644 --- a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs @@ -418,7 +418,6 @@ namespace GameCult.Networking.Tests CultNetDatabaseChangeKind.Added, VariantKey, schemaId, database.Shards[0], cache.Get(VariantKey)!, previousDocument: null); Assert.That(Deliver(databaseServer, added, selection, refused, errors), Is.Null); Assert.That(errors, Has.Count.EqualTo(1)); - Assert.That(errors[0].Error, Is.EqualTo("the record is a variant CultNet cannot carry")); var removed = new CultNetDatabaseChange( CultNetDatabaseChangeKind.Removed, VariantKey, schemaId, database.Shards[0], document: null, previousDocument: cache.Get(VariantKey)!); diff --git a/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs b/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs index 2bb59952..e7900e7c 100644 --- a/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs @@ -178,46 +178,6 @@ namespace GameCult.Networking.Tests Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1)); } - // The RUDP schema server implements no receipt rules, so it must not advertise them (write-receipt.md, Capability and the - // conformance table): whatever hello it sends lists neither frame. - [Test] - public async Task TheRudpSchemaServerNeverAdvertisesTheReceiptFrames() - { - using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions - { - RuntimeId = "csharp-rudp-no-receipts", - ConnectionId = ConnectionId - }); - var client = await Connect(server); - try - { - SendSchema(server, client, CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage - { - RuntimeId = "receipt-client", - RuntimeKind = "test", - SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] - })); - await server.PollAvailableAsync(16); - - var hellos = new List(); - foreach (var packet in Drain(client.Socket)) - foreach (var frame in client.Session.Receive(packet, 1).Delivered) - if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetHelloMessage hello) - hellos.Add(hello); - - foreach (var hello in hellos) - { - var listed = hello.SupportedMessageVersions ?? Array.Empty(); - Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.WriteAdmitted)); - Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.ErrorV1)); - } - } - finally - { - client.Socket.Dispose(); - } - } - private static List ErrorsReceivedBy(Client client) { var errors = new List(); diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptLoopbackTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptLoopbackTests.cs deleted file mode 100644 index d5e32fa0..00000000 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptLoopbackTests.cs +++ /dev/null @@ -1,290 +0,0 @@ -#nullable enable -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using GameCult.Caching; -using LiteNetLib; -using NUnit.Framework; -using static GameCult.Networking.Tests.NetworkingTests; - -namespace GameCult.Networking.Tests -{ - // The receipt server on a real socket: Server.Start on an ephemeral loopback port and real LiteNetLib clients. The - // handler-level tests (CultNetWriteReceiptServerTests) hand frames to the handlers; only a running server decides what - // a frame that does not decode is answered with. - public sealed class CultNetWriteReceiptLoopbackTests - { - private static readonly TimeSpan Cap = TimeSpan.FromSeconds(10); - private static readonly CultRecordKey KeyA = new("tests:loopback:a"); - private static readonly CultRecordKey KeyB = new("tests:loopback:b"); - private const string ShardId = "loopback-shard"; - - private sealed class Client : IDisposable - { - private readonly NetManager _manager; - private readonly NetPeer _peer; - private readonly BlockingCollection _received = new(); - public int Closed; - - public Client(int port, string key) - { - var listener = new EventBasedNetListener(); - var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var decoder = new LiteNetLibTransportConnection(); - listener.PeerConnectedEvent += _ => connected.TrySetResult(true); - listener.PeerDisconnectedEvent += (_, _) => Interlocked.Increment(ref Closed); - listener.NetworkReceiveEvent += (_, reader, _, _) => - _received.Add(LiteNetLibTransportConnection.DecodeSchema(decoder.Receive(reader.GetRemainingBytes()))); - _manager = new NetManager(listener) { UnsyncedEvents = true }; - _manager.Start(0); - _peer = _manager.Connect("127.0.0.1", port, key); - if (!connected.Task.Wait(Cap)) Assert.Fail("the client did not connect"); - } - - public void Send(T message) where T : ICultNetSchemaMessage => - _peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered); - - public void SendUnreliable(T message) where T : ICultNetSchemaMessage => - _peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.Unreliable); - - public void Send(T message, DeliveryMethod method) where T : ICultNetSchemaMessage => - _peer.Send(CultNetSchemaMessageSerialization.Serialize(message), method); - - public void SendRaw(byte[] bytes) => _peer.Send(bytes, DeliveryMethod.ReliableOrdered); - - public T Next() where T : ICultNetSchemaMessage - { - if (!_received.TryTake(out var message, Cap)) Assert.Fail($"no {typeof(T).Name} arrived within {Cap}"); - Assert.That(message, Is.InstanceOf()); - return (T)message!; - } - - public void ExpectNothingMore(string because) - { - // A reliable ordered channel: anything the server sent before the probe's answer has already arrived. - Assert.That(_received.Count, Is.Zero, because); - } - - public void Dispose() - { - _manager.Stop(); - _received.Dispose(); - } - } - - private sealed class Authority : ICultNetRemoteWriteAuthority - { - public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) => - request.RecordKey.Equals(KeyB) ? CultNetRemoteWriteDecision.Refuse("denied") : CultNetRemoteWriteDecision.Grant(); - } - - private sealed class Running : IDisposable - { - public Server Server = null!; - public CultCache Cache = null!; - public CultNetDatabase Database = null!; - public string SchemaId = string.Empty; - private CultNetDatabaseServer _databaseServer = null!; - - public static Running Start() - { - var cache = new CultCache(); - var schemaId = cache.Registry.GetRequired().SchemaId; - var database = new CultNetDatabase(cache, new CultNetDatabaseOptions - { - Shards = [new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: true, schemaIds: [schemaId])] - }); - var server = new Server(cache, ServerSecurityOptions.Development()) { Port = 0 }; - var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions - { - RemoteWriteAuthority = new Authority() - }); - server.Start(); - return new Running { Server = server, Cache = cache, Database = database, SchemaId = schemaId, _databaseServer = databaseServer }; - } - - public Client Connect() => new(Server.Port, ServerSecurityOptions.Development().ConnectionKey); - - public CultNetDocumentPutRawMessage PutOf(string id, CultRecordKey key, string text) - { - var message = Database.Documents.CreateRawDocumentPutMessage(id, new CultRecordHandle(key), - new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = text }); - message.ShardId = ShardId; - message.ShardEpoch = 1; - return message; - } - - public void Dispose() - { - _databaseServer.Dispose(); - Server.Dispose(); - } - } - - private static CultNetHelloMessage HelloOf(params string[] versions) => - new() { RuntimeId = "loopback-client", RuntimeKind = "test", SupportedMessageVersions = versions }; - - private static readonly string[] BothFrames = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]; - - [Test] - public void StartBindsTheRequestedPortAndReportsTheOneBound() - { - using var running = Running.Start(); - Assert.That(running.Server.Port, Is.Not.Zero.And.Not.EqualTo(3075)); - } - - // contracts/cultnet/interop/write-receipt-vectors.json, error_v1_unreadable_frame_has_null_message_id: code - // message_invalid, details.field "message", messageId null. - [TestCase(new byte[] { 0xC1 }, TestName = "AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId_NeverUsedByte")] - [TestCase(new byte[] { 0x92, 0x01, 0x02 }, TestName = "AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId_NotAMap")] - public void AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId(byte[] garbage) - { - using var running = Running.Start(); - using var client = running.Connect(); - client.Send(HelloOf(BothFrames)); - client.Next(); - - client.SendRaw(garbage); - - var error = client.Next(); - Assert.That(error.Code, Is.EqualTo("message_invalid")); - Assert.That(error.Details!.Field, Is.EqualTo("message")); - Assert.That(error.MessageId, Is.Null); - } - - [Test] - public async Task AReceiptPeerIsStillServedAfterAnUnreadableFrameAndALegacyPeerIsAnsweredAsBefore() - { - using var running = Running.Start(); - using var receipt = running.Connect(); - receipt.Send(HelloOf(BothFrames)); - receipt.Next(); - receipt.SendRaw(new byte[] { 0xC1 }); - receipt.Next(); - - receipt.Send(running.PutOf("r1", KeyA, "one")); - Assert.That(receipt.Next().MessageId, Is.EqualTo("r1")); - - using var legacy = running.Connect(); - legacy.Send(HelloOf()); // a hello that lists neither frame is answered with nothing - legacy.SendRaw(new byte[] { 0xC1 }); // unanswered: were it answered, error.v1 would arrive ahead of the refusal - legacy.Send(running.PutOf("l1", KeyB, "two")); - var refusal = legacy.Next(); - Assert.That(refusal, Is.TypeOf()); - Assert.That(refusal.Code, Is.EqualTo("write_not_authorized")); - legacy.Send(running.PutOf("l2", KeyA, "three")); - await WaitFor(() => running.Cache.Get(KeyA)?.Text == "three"); - legacy.ExpectNothingMore("a legacy peer is never sent a receipt or error.v1"); - Assert.That(running.Cache.Get(KeyB), Is.Null); - } - - // write-receipt.md, Reliable channel only: a write on an unreliable channel of a receipt session is refused - // message_invalid, details.field "channel", fixed text, carrying the write's id; it is not applied. - [Test] - public async Task AWriteOnAnUnreliableChannelOfAReceiptPeerIsRefusedAndNotApplied() - { - using var running = Running.Start(); - using var client = running.Connect(); - client.Send(HelloOf(BothFrames)); - client.Next(); - - client.SendUnreliable(running.PutOf("u1", KeyA, "lost")); - - var error = client.Next(); - Assert.That(error.Code, Is.EqualTo("message_invalid")); - Assert.That(error.Details!.Field, Is.EqualTo("channel")); - Assert.That(error.Error, Is.EqualTo("message_invalid: receipt sessions require the reliable channel")); - Assert.That(error.MessageId, Is.EqualTo("u1")); - - // The session goes on, and the same id on the reliable channel is a new write, not a replayed refusal. - client.Send(running.PutOf("u1", KeyA, "kept")); - Assert.That(client.Next().MessageId, Is.EqualTo("u1")); - await WaitFor(() => running.Cache.Get(KeyA)?.Text == "kept"); - } - - // write-receipt.md, Reliable channel only: reliable and not sequenced. Reliable ordered and unordered qualify; reliable - // sequenced does not, and nor does any unreliable method. - [TestCase(DeliveryMethod.ReliableSequenced, true)] - [TestCase(DeliveryMethod.Sequenced, true)] - [TestCase(DeliveryMethod.Unreliable, true)] - [TestCase(DeliveryMethod.ReliableUnordered, false)] - [TestCase(DeliveryMethod.ReliableOrdered, false)] - public async Task OnlyAReliableNonSequencedChannelCarriesAWriteOfAReceiptPeer(DeliveryMethod method, bool refused) - { - using var running = Running.Start(); - using var client = running.Connect(); - client.Send(HelloOf(BothFrames)); - client.Next(); - - client.Send(running.PutOf("c1", KeyA, "text"), method); - - if (refused) - { - var error = client.Next(); - Assert.That(error.Details!.Field, Is.EqualTo("channel")); - Assert.That(error.MessageId, Is.EqualTo("c1")); - Assert.That(running.Cache.Get(KeyA), Is.Null); - } - else - { - Assert.That(client.Next().MessageId, Is.EqualTo("c1")); - await WaitFor(() => running.Cache.Get(KeyA)?.Text == "text"); - } - } - - [Test] - public void ADeleteOnAnUnreliableChannelOfAReceiptPeerIsRefusedWithItsId() - { - using var running = Running.Start(); - using var client = running.Connect(); - client.Send(HelloOf(BothFrames)); - client.Next(); - - client.SendUnreliable(new CultNetDocumentDeleteMessage - { - MessageId = "ud1", SchemaId = running.SchemaId, RecordKey = KeyA.Value, ShardId = ShardId, ShardEpoch = 1 - }); - - var error = client.Next(); - Assert.That(error.Code, Is.EqualTo("message_invalid")); - Assert.That(error.Details!.Field, Is.EqualTo("channel")); - Assert.That(error.MessageId, Is.EqualTo("ud1")); - } - - [Test] - public async Task OnlyWritesAreRefusedOnTheUnreliableChannelAndALegacyPeerIsLeftAsBefore() - { - using var running = Running.Start(); - using var receipt = running.Connect(); - receipt.Send(HelloOf(BothFrames)); - receipt.Next(); - receipt.SendUnreliable(HelloOf(BothFrames)); // not a write: answered with nothing on the unreliable channel - receipt.Send(running.PutOf("r1", KeyA, "one")); - Assert.That(receipt.Next().MessageId, Is.EqualTo("r1"), "nothing else was sent ahead of the receipt"); - receipt.Send(running.PutOf("r2", KeyA, "two")); - Assert.That(receipt.Next().MessageId, Is.EqualTo("r2")); - receipt.ExpectNothingMore("the unreliable hello was answered with nothing"); - - using var legacy = running.Connect(); - legacy.Send(HelloOf()); - legacy.SendUnreliable(running.PutOf("l1", KeyB, "two")); // refused by the authority, answered as v0 as before - Assert.That(legacy.Next().Code, Is.EqualTo("write_not_authorized")); - legacy.SendUnreliable(running.PutOf("l2", KeyA, "three")); - await WaitFor(() => running.Cache.Get(KeyA)?.Text == "three"); - legacy.ExpectNothingMore("a legacy peer is never sent a receipt, error.v1 or a channel refusal"); - } - - private static async Task WaitFor(Func condition) - { - var deadline = DateTime.UtcNow + Cap; - while (!condition()) - { - if (DateTime.UtcNow > deadline) Assert.Fail("the condition did not hold within the cap"); - await Task.Delay(5); - } - } - } -} diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs deleted file mode 100644 index c572cfbf..00000000 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs +++ /dev/null @@ -1,1272 +0,0 @@ -#nullable enable -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using GameCult.Caching; -using NUnit.Framework; -using static GameCult.Networking.Tests.NetworkingTests; - -namespace GameCult.Networking.Tests -{ - // The database server's half of contracts/cultnet/interop/write-receipt.md. A peer whose hello lists both new frames is - // answered with write_admitted or error.v1, each naming the write's id; any other peer is answered as before. The handlers - // are reached through their private delegates; a peer's transport is a capture of what leaves it. - public sealed class CultNetWriteReceiptServerTests - { - private const string ShardId = "receipt-shard"; - private static readonly CultRecordKey KeyA = new("tests:receipt:a"); - private static readonly CultRecordKey KeyB = new("tests:receipt:b"); - - private sealed class Authority : ICultNetRemoteWriteAuthority - { - public HashSet Denied { get; } = new(); - public int Asked { get; private set; } - public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) - { - Asked++; - return Denied.Contains(request.RecordKey.Value) ? CultNetRemoteWriteDecision.Refuse("denied") : CultNetRemoteWriteDecision.Grant(); - } - } - - private sealed class Wire - { - public List Sent { get; } = new(); - /// When set, the connection cannot queue what it is sent. - public bool Unsendable { get; set; } - public int Ended { get; private set; } - public LiteNetLibTransportConnection Transport { get; } - public CultNetServerPeer Peer { get; } - - public Wire() - { - Transport = new LiteNetLibTransportConnection( - bytes => - { - if (Unsendable) throw new InvalidOperationException("the send queue is full"); - Sent.Add(LiteNetLibTransportConnection.DecodeSchema(LiteNetLibTransportConnection.Decode(bytes))); - }, - () => Ended++); - Peer = (CultNetServerPeer)RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer)); - typeof(CultNetServerPeer).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(Peer, Transport); - } - } - - private sealed class Rig - { - public CultCache Cache = null!; - public CultNetDatabase Database = null!; - public CultNetDatabaseServer DatabaseServer = null!; - public Authority Authority = null!; - public string SchemaId = string.Empty; - public Func PutHandler = null!; - public Func DeleteHandler = null!; - public Func HelloHandler = null!; - - public int LogLength => Database.GetMutationLog(ShardId).Count; - public string? TextOf(CultRecordKey key) => Cache.Get(key)?.Text; - - public CultNetDocumentPutRawMessage PutOf(string id, CultRecordKey key, string text) - { - var message = Database.Documents.CreateRawDocumentPutMessage(id, new CultRecordHandle(key), - new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = text }); - message.ShardId = ShardId; - message.ShardEpoch = 1; - return message; - } - - public CultNetDocumentDeleteMessage DeleteOf(string id, CultRecordKey key) => - new() { MessageId = id, SchemaId = SchemaId, RecordKey = key.Value, ShardId = ShardId, ShardEpoch = 1 }; - - public Task Put(Wire wire, string id, CultRecordKey key, string text) => PutHandler(PutOf(id, key, text), wire.Peer); - public Task Delete(Wire wire, string id, CultRecordKey key) => DeleteHandler(DeleteOf(id, key), wire.Peer); - public Task Hello(Wire wire, params string[] versions) => HelloHandler(HelloOf(versions), wire.Peer); - } - - private static CultNetHelloMessage HelloOf(params string[] versions) => - new() { RuntimeId = "test-client", RuntimeKind = "test", SupportedMessageVersions = versions }; - - private static readonly string[] BothFrames = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]; - - private static Rig Build(Authority? authority = null, bool primary = true, ICultNetShardWriteForwarder? forwarder = null) - { - var cache = new CultCache(); - var schemaId = cache.Registry.GetRequired().SchemaId; - var database = new CultNetDatabase(cache, new CultNetDatabaseOptions - { - Shards = - [ - new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: primary, schemaIds: [schemaId], - primaryEndpoints: primary ? null : ["cultnet://runtime-owner:3075"]) - ] - }); - var host = new Server(cache, ServerSecurityOptions.Development()); - authority ??= new Authority(); - var databaseServer = new CultNetDatabaseServer(host, database, new CultNetDatabaseServerOptions - { - RemoteWriteAuthority = authority, - ForwardNonPrimaryWrites = forwarder != null, - WriteForwarder = forwarder - }); - const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance; - T Handler(string field) => (T)typeof(CultNetDatabaseServer).GetField(field, flags)!.GetValue(databaseServer)!; - return new Rig - { - Cache = cache, - Database = database, - DatabaseServer = databaseServer, - Authority = authority, - SchemaId = schemaId, - PutHandler = Handler>("_putHandler"), - DeleteHandler = Handler>("_deleteHandler"), - HelloHandler = Handler>("_helloHandler") - }; - } - - private static async Task<(Rig Rig, Wire Wire)> Capable() - { - var rig = Build(); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); // the server's own hello is checked on its own - return (rig, wire); - } - - private static CultNetWriteAdmittedMessage AssertAdmitted(ICultNetSchemaMessage message, string id, string operation, string recordKey) - { - var admitted = (CultNetWriteAdmittedMessage)message; - Assert.That(admitted.MessageId, Is.EqualTo(id)); - Assert.That(admitted.Operation, Is.EqualTo(operation)); - Assert.That(admitted.RecordKey, Is.EqualTo(recordKey)); - return admitted; - } - - private static CultNetErrorV1Message AssertRefused(ICultNetSchemaMessage message, string? id, string? code) - { - var error = (CultNetErrorV1Message)message; - Assert.That(error.MessageId, Is.EqualTo(id)); - Assert.That(error.Code, Is.EqualTo(code)); - return error; - } - - [Test] - public async Task AnAdmittedPutOnAReceiptConnectionIsAnsweredAfterItsCommit() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Clear(); - - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); - var admitted = AssertAdmitted(wire.Sent.Single(), "m1", "put", KeyA.Value); - Assert.That(admitted.SchemaId, Is.EqualTo(rig.SchemaId)); - } - - [Test] - public async Task AnAdmittedDeleteIsAnsweredWithAReceiptNamingTheDelete() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - wire.Sent.Clear(); - - await rig.Delete(wire, "d1", KeyA); - - Assert.That(rig.TextOf(KeyA), Is.Null); - AssertAdmitted(wire.Sent.Single(), "d1", "delete", KeyA.Value); - } - - [Test] - public async Task TheServerAnswersAReceiptHelloWithItsOwnListingBothFrames() - { - var rig = Build(); - var wire = new Wire(); - - await rig.Hello(wire, BothFrames); - - var hello = (CultNetHelloMessage)wire.Sent.Single(); - Assert.That(hello.SupportedMessageVersions, Is.EquivalentTo(BothFrames)); - } - - [TestCase("")] - [TestCase("cultnet.write_admitted.v0")] - [TestCase("cultnet.error.v1")] - public async Task APeerThatDoesNotListBothFramesIsAnsweredAsBeforeAndTheServerSaysNothingOfReceipts(string listed) - { - var rig = Build(); - var wire = new Wire(); - await rig.Hello(wire, listed.Length == 0 ? [] : [listed]); - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(rig.TextOf(KeyA), Is.EqualTo("one"), "admitted all the same"); - Assert.That(wire.Sent, Is.Empty, "no receipt, no hello"); - - rig.Authority.Denied.Add(KeyB.Value); - await rig.Put(wire, "m2", KeyB, "two"); - Assert.That(wire.Sent.Single(), Is.TypeOf(), "a refusal is the v0 error, without an id"); - } - - [Test] - public async Task APeerThatNeverSaidHelloGetsNoReceipt() - { - var rig = Build(); - var wire = new Wire(); - - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty); - } - - [Test] - public async Task ASecondHelloDoesNotChangeTheCapability() - { - var (rig, wire) = await Capable(); - await rig.Hello(wire); - await rig.Put(wire, "m1", KeyA, "one"); - AssertAdmitted(wire.Sent.Single(), "m1", "put", KeyA.Value); - - var late = Build(); - var legacy = new Wire(); - await late.Put(legacy, "m1", KeyA, "one"); - await late.Hello(legacy, BothFrames); - await late.Put(legacy, "m2", KeyB, "two"); - Assert.That(legacy.Sent, Is.Empty, "a hello after the first write does not opt the connection in"); - } - - [Test] - public async Task ACapabilityNeverAuthorizes() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - - await rig.Put(wire, "m1", KeyA, "one"); - await rig.Delete(wire, "d1", KeyA); - - Assert.That(rig.TextOf(KeyA), Is.Null); - AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); - AssertRefused(wire.Sent[1], "d1", "write_not_authorized"); - Assert.That(wire.Sent, Has.Count.EqualTo(2), "one answer per write"); - } - - [Test] - public async Task AResendOfAnAdmittedWriteIsReceiptedAgainWithoutBeingAppliedAgain() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - var logAfterFirst = rig.LogLength; - - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(wire.Sent, Has.Count.EqualTo(2)); - AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); - Assert.That(rig.LogLength, Is.EqualTo(logAfterFirst), "the resend was not committed again"); - } - - [Test] - public async Task TheSameIdWithOtherContentIsRefusedAndNeverReReceipted() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - - await rig.Put(wire, "m1", KeyA, "other"); - await rig.Put(wire, "m1", KeyB, "one"); - await rig.Delete(wire, "m1", KeyA); - - Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); - Assert.That(rig.TextOf(KeyB), Is.Null); - Assert.That(wire.Sent, Has.Count.EqualTo(4)); - for (var index = 1; index < 4; index++) - { - var error = AssertRefused(wire.Sent[index], "m1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("messageId")); - } - } - - [Test] - public async Task AReusedIdIsJudgedBeforeAuthorityIsAskedAgain() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - rig.Authority.Denied.Add(KeyB.Value); - - await rig.Put(wire, "m1", KeyB, "other"); - - AssertRefused(wire.Sent[1], "m1", "message_invalid"); - Assert.That(rig.Authority.Asked, Is.EqualTo(1), "authority is asked on an id's first sighting only"); - } - - [Test] - public async Task AResendIsAnsweredFromMemoryWithoutAskingAuthorityAgain() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - rig.Authority.Denied.Add(KeyA.Value); - - await rig.Put(wire, "m1", KeyA, "one"); - - AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); - Assert.That(rig.Authority.Asked, Is.EqualTo(1)); - } - - [Test] - public async Task ARefusedWriteIsRememberedSoItsResendReplaysTheRefusal() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - await rig.Put(wire, "m1", KeyA, "one"); - rig.Authority.Denied.Clear(); - - await rig.Put(wire, "m1", KeyA, "one"); - - var first = AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); - var again = AssertRefused(wire.Sent[1], "m1", "write_not_authorized"); - Assert.That(again.Error, Is.EqualTo(first.Error)); - Assert.That(rig.TextOf(KeyA), Is.Null, "the resend was not decided afresh"); - Assert.That(rig.Authority.Asked, Is.EqualTo(1)); - } - - [Test] - public async Task ARefusedIdReusedForOtherContentIsMessageInvalid() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - await rig.Put(wire, "m1", KeyA, "one"); - - await rig.Put(wire, "m1", KeyB, "other"); - - AssertRefused(wire.Sent[1], "m1", "message_invalid"); - Assert.That(rig.TextOf(KeyB), Is.Null); - } - - [Test] - public async Task RefusedWritesCountAgainstTheRememberedWriteLimit() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - { - await rig.Put(wire, $"r{index}", KeyA, $"v{index}"); - } - - wire.Sent.Clear(); - await rig.Put(wire, "over", KeyB, "x"); - await rig.Put(wire, "r0", KeyA, "v0"); - - AssertRefused(wire.Sent[0], "over", "limit_reached"); - AssertRefused(wire.Sent[1], "r0", "write_not_authorized"); - Assert.That(rig.TextOf(KeyB), Is.Null); - } - - [Test] - public async Task APutAndADeleteNeverShareAnId() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "p1", KeyA, "one"); - await rig.Delete(wire, "d1", KeyA); - wire.Sent.Clear(); - - await rig.Delete(wire, "p1", KeyA); - await rig.Put(wire, "d1", KeyA, "one"); - - AssertRefused(wire.Sent[0], "p1", "message_invalid"); - AssertRefused(wire.Sent[1], "d1", "message_invalid"); - Assert.That(rig.TextOf(KeyA), Is.Null, "neither reuse was applied"); - } - - [Test] - public async Task ADeleteAsTheFirstFrameSettlesTheConnectionAsLegacy() - { - var rig = Build(); - var wire = new Wire(); - await rig.Delete(wire, "d1", KeyA); - await rig.Hello(wire, BothFrames); - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty, "a hello after the first delete does not opt the connection in"); - } - - [Test] - public async Task AnAnswerThatCannotBeSentEndsTheConnectionInsteadOfLeavingTheWriteSilent() - { - var (rig, wire) = await Capable(); - wire.Unsendable = true; - await rig.Put(wire, "m1", KeyA, "one"); - Assert.That(wire.Ended, Is.EqualTo(1), "a receipt that cannot be queued"); - - rig.Authority.Denied.Add(KeyB.Value); - await rig.Put(wire, "m2", KeyB, "two"); - Assert.That(wire.Ended, Is.EqualTo(2), "a refusal that cannot be queued"); - - wire.Transport.AnswerUnreadableSchemaFrame(new byte[] { 0xC1 }); - Assert.That(wire.Ended, Is.EqualTo(3), "an unreadable-frame answer that cannot be queued"); - } - - [Test] - public async Task AnUnexpectedFailureIsRefusedWithFixedTextNotTheExceptionsOwn() - { - var (rig, wire) = await Capable(); - var message = rig.PutOf("m1", KeyA, "one"); - message.Document!.Payload = [0xC1]; - var legacy = new Wire(); - - await rig.PutHandler(message, wire.Peer); - await rig.PutHandler(message, legacy.Peer); - await rig.DeleteHandler(new CultNetDocumentDeleteMessage { MessageId = "d1", SchemaId = "no.such.schema", RecordKey = "k" }, legacy.Peer); - - Assert.That(((CultNetErrorV1Message)wire.Sent.Single()).Error, Is.EqualTo(CultNetWriteReceiptSession.WriteFailedText)); - Assert.That(((CultNetErrorMessage)legacy.Sent[0]).Error, Is.EqualTo(CultNetWriteReceiptSession.WriteFailedText)); - } - - [Test] - public void TheLimitReachedRefusalIsTheSpecVectorByteForByte() - { - const string vector = "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235"; - - var bytes = CultNetSchemaMessageSerialization.Serialize(CultNetErrorV1Message.LimitReached("put-1025")); - - Assert.That(Convert.ToHexString(bytes), Is.EqualTo(vector)); - } - - [Test] - public void TheSpecWireVectorsDecodeToTheirExpectedFields() - { - var admitted = (CultNetWriteAdmittedMessage)CultNetSchemaMessageSerialization.Deserialize(Convert.FromHexString( - "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31")); - Assert.That((admitted.MessageId, admitted.Operation, admitted.SchemaId, admitted.RecordKey), - Is.EqualTo(("put-1", "put", "note.v0", "note:1"))); - - var refused = (CultNetErrorV1Message)CultNetSchemaMessageSerialization.Deserialize(Convert.FromHexString( - "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32")); - Assert.That(refused.Error, Is.EqualTo("write_not_authorized: the catalog refused the document")); - Assert.That(refused.Code, Is.EqualTo("write_not_authorized")); - Assert.That(refused.MessageId, Is.EqualTo("put-2")); - Assert.That((refused.Details!.Field, refused.Details.Value), Is.EqualTo(("recordKey", "note:2"))); - Assert.That(refused.Details.AsOf, Is.Null); - Assert.That(refused.Details.Current, Is.Null); - } - - [Test] - public async Task AFullDedupeTableRefusesNewWritesAndStillAnswersRememberedOnes() - { - var (rig, wire) = await Capable(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - { - await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); - } - - wire.Sent.Clear(); - await rig.Put(wire, "over", KeyA, "x"); - await rig.Put(wire, "w0", new CultRecordKey("tests:receipt:0"), "x"); - - var limit = AssertRefused(wire.Sent[0], "over", "limit_reached"); - Assert.That(limit.Error, Is.EqualTo("limit_reached: the session's remembered-write limit is full")); - AssertAdmitted(wire.Sent[1], "w0", "put", "tests:receipt:0"); - Assert.That(rig.TextOf(KeyA), Is.Null); - } - - [Test] - public async Task AWriteWithNoIdIsRefusedWithNoId() - { - var (rig, wire) = await Capable(); - - var message = rig.PutOf("placeholder", KeyA, "one"); - message.MessageId = string.Empty; - await rig.PutHandler(message, wire.Peer); - - var error = AssertRefused(wire.Sent.Single(), null, "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("messageId")); - Assert.That(rig.TextOf(KeyA), Is.Null); - } - - [Test] - public async Task AFrameThatDoesNotDecodeIsRefusedWithItsIdWhenItHoldsOne() - { - var (_, wire) = await Capable(); - var withId = MessagePack.MessagePackSerializer.Serialize(new Dictionary - { - ["schemaVersion"] = "cultnet.document_put_raw.v9", - ["messageId"] = "bad-1" - }); - - wire.Transport.AnswerUnreadableSchemaFrame(withId); - wire.Transport.AnswerUnreadableSchemaFrame(new byte[] { 0xC1 }); - - var error = AssertRefused(wire.Sent[0], "bad-1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("message")); - AssertRefused(wire.Sent[1], null, "message_invalid"); - } - - [Test] - public void ReadableMessageIdFindsOnlyANonEmptyStringIdInATopLevelMap() - { - string? IdOf(object value) => CultNetReadableMessageId.From(MessagePack.MessagePackSerializer.Serialize(value)); - - Assert.That(IdOf(new Dictionary { ["a"] = new[] { 1, 2 }, ["messageId"] = "x" }), Is.EqualTo("x")); - Assert.That(IdOf(new Dictionary { ["messageId"] = 5 }), Is.Null); - Assert.That(IdOf(new Dictionary { ["messageId"] = "" }), Is.Null); - Assert.That(IdOf(new Dictionary { ["nested"] = new Dictionary { ["messageId"] = "deep" } }), Is.Null); - Assert.That(IdOf("messageId"), Is.Null); - } - - [Test] - public void TheContentHashMatchesTheSpecVectors() - { - byte[] a = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x31]; - byte[] b = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x32]; - string Hex(byte[] hash) => Convert.ToHexString(hash).ToLowerInvariant(); - - Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:1", a)), Is.EqualTo("7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d")); - Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:1", b)), Is.EqualTo("58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f")); - Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:2", a)), Is.EqualTo("a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8")); - Assert.That(Hex(CultNetWriteContentHash.Compute("delete", "note.v0", "note:1", null)), Is.EqualTo("232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37")); - } - - [Test] - public void TheNewFramesRoundTripOnTheWire() - { - var admitted = new CultNetWriteAdmittedMessage { MessageId = "m", Operation = "put", SchemaId = "s", RecordKey = "k" }; - var error = new CultNetErrorV1Message { Error = "e", Code = "message_invalid", MessageId = null }; - - var admittedBack = (CultNetWriteAdmittedMessage)CultNetSchemaMessageSerialization.Deserialize(CultNetSchemaMessageSerialization.Serialize(admitted)); - var errorBack = (CultNetErrorV1Message)CultNetSchemaMessageSerialization.Deserialize(CultNetSchemaMessageSerialization.Serialize(error)); - - Assert.That(admittedBack.MessageId, Is.EqualTo("m")); - Assert.That(admittedBack.RecordKey, Is.EqualTo("k")); - Assert.That(errorBack.MessageId, Is.Null); - Assert.That(errorBack.Code, Is.EqualTo("message_invalid")); - } - - private sealed class ScriptedForwarder : ICultNetShardWriteForwarder - { - public Func OnPut { get; set; } = _ => Task.CompletedTask; - public Func OnDelete { get; set; } = _ => Task.CompletedTask; - public int Forwarded { get; private set; } - - public Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) - { - Forwarded++; - return OnPut(message); - } - - public Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) - { - Forwarded++; - return OnDelete(message); - } - } - - [Test] - public async Task AForwardedWriteIsReceiptedUnderItsOriginalIdOnlyWhenThePrimaryAnswered() - { - var forwarder = new ScriptedForwarder(); - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - await rig.Delete(wire, "fwd-2", KeyA); - - AssertAdmitted(wire.Sent[0], "fwd-1", "put", KeyA.Value); - AssertAdmitted(wire.Sent[1], "fwd-2", "delete", KeyA.Value); - Assert.That(forwarder.Forwarded, Is.EqualTo(2)); - } - - [Test] - public async Task AForwardedWritesRefusalIsRelayedAsErrorV1UnderTheOriginalId() - { - var forwarder = new ScriptedForwarder - { - OnPut = _ => Task.FromException(new CultNetWriteRefusedException(new CultNetErrorMessage { Error = "primary says no", Code = "write_not_authorized" })), - OnDelete = _ => Task.FromException(new CultNetWriteRefusedException(CultNetErrorMessage.ForMessageInvalid("recordKey", "bad"))) - }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - await rig.Delete(wire, "fwd-2", KeyA); - - AssertRefused(wire.Sent[0], "fwd-1", "write_not_authorized"); - Assert.That(((CultNetErrorV1Message)wire.Sent[0]).Error, Is.EqualTo("write_not_authorized"), "a relayed refusal is in fixed text, the same one a resend replays"); - AssertRefused(wire.Sent[1], "fwd-2", "message_invalid"); - } - - [Test] - public async Task AForwardThePrimaryLeftSilentIsNeverForwardedAgainOnTheSession() - { - var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(new CultNetWriteUnconfirmedException("fwd-1")) }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - forwarder.OnPut = _ => Task.CompletedTask; - await rig.Put(wire, "fwd-1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty, "no receipt, no error: the write stays unconfirmed"); - Assert.That(forwarder.Forwarded, Is.EqualTo(1), "the primary may have applied it, so it is not sent again"); - - await rig.Put(wire, "fwd-1", KeyB, "other"); - AssertRefused(wire.Sent.Single(), "fwd-1", "message_invalid"); - } - - [Test] - public async Task AForwardRefusedByThePrimaryReplaysItsRefusalOnResend() - { - var forwarder = new ScriptedForwarder - { - OnPut = _ => Task.FromException(new CultNetWriteRefusedException(new CultNetErrorMessage { Error = "primary says no", Code = "write_not_authorized" })) - }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - await rig.Put(wire, "fwd-1", KeyA, "one"); - - Assert.That(wire.Sent, Has.Count.EqualTo(2)); - Assert.That(((CultNetErrorV1Message)wire.Sent[1]).Error, Is.EqualTo("write_not_authorized"), "a remembered refusal is replayed in fixed text, not the primary's own"); - Assert.That(forwarder.Forwarded, Is.EqualTo(1)); - } - - [Test] - public async Task AForwardThatWasNotSentMayBeRetriedUnderTheSameId() - { - var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(new CultNetWriteNotSentException("fwd-1", new TimeoutException("primary unreachable"))) }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - Assert.That(wire.Sent, Is.Empty, "no receipt, no error, no synthesized refusal"); - - forwarder.OnPut = _ => Task.CompletedTask; - await rig.Put(wire, "fwd-1", KeyA, "one"); - AssertAdmitted(wire.Sent.Single(), "fwd-1", "put", KeyA.Value); - Assert.That(forwarder.Forwarded, Is.EqualTo(2), "the unsent attempt left no claim behind"); - } - - // Once the frame may have gone out, whatever failed, the id is never forwarded again on the session. - private static IEnumerable PostSendFailures() - { - yield return new TestCaseData(new TimeoutException("no answer")).SetName("timeout"); - yield return new TestCaseData(new System.IO.IOException("connection reset")).SetName("reset"); - yield return new TestCaseData(new InvalidOperationException("client disconnected")).SetName("disconnected"); - yield return new TestCaseData(new CultNetWriteUnconfirmedException("fwd-1")).SetName("no answer in the window"); - yield return new TestCaseData(new Exception("anything else, ambiguous")).SetName("ambiguous"); - } - - [TestCaseSource(nameof(PostSendFailures))] - public async Task AForwardThatMayHaveBeenSentIsNeverForwardedAgainOnTheSession(Exception failure) - { - var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(failure) }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - forwarder.OnPut = _ => Task.CompletedTask; - await rig.Put(wire, "fwd-1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty, "unconfirmed: nothing answered, nothing synthesized"); - Assert.That(forwarder.Forwarded, Is.EqualTo(1), "the claim stays"); - } - - [Test] - public async Task TheRealForwarderRetriesAConnectFailureButNotAFailureAfterTheWriteWasSent() - { - var clients = new Queue(); - var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions - { - CreateClient = () => clients.Dequeue(), - ConnectTimeout = TimeSpan.FromMilliseconds(100), - ResultWindow = TimeSpan.FromMilliseconds(100) - }); - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - clients.Enqueue(new ForwardingClient { ConnectFails = true }); - await rig.Put(wire, "fwd-1", KeyA, "one"); - var sent = new ForwardingClient { AdmitWrites = true }; - clients.Enqueue(sent); - await rig.Put(wire, "fwd-1", KeyA, "one"); - AssertAdmitted(wire.Sent.Single(), "fwd-1", "put", KeyA.Value); - - wire.Sent.Clear(); - var reset = new ForwardingClient { WriteSendFails = true }; - clients.Enqueue(reset); - await rig.Put(wire, "fwd-2", KeyA, "two"); - clients.Enqueue(new ForwardingClient { AdmitWrites = true }); - await rig.Put(wire, "fwd-2", KeyA, "two"); - Assert.That(wire.Sent, Is.Empty, "the write send threw: it may have gone out"); - Assert.That(clients, Has.Count.EqualTo(1), "and the id was not forwarded again"); - } - - private sealed class ForwardingClient : ICultNetSchemaClient - { - private Action? _admitted; - public bool ConnectFails { get; set; } - public bool NeverConnects { get; set; } - public bool HelloSendFails { get; set; } - public bool WriteSendFails { get; set; } - public bool WriteNotSentFails { get; set; } - public bool AdmitWrites { get; set; } - public bool Connected { get; private set; } - public List Sent { get; } = new(); - - public void Connect(string host, int port) - { - if (ConnectFails) throw new System.Net.Sockets.SocketException(); - Connected = !NeverConnects; - } - - public void SendCultNet(T message) where T : ICultNetSchemaMessage - { - if (message is CultNetHelloMessage) - { - if (HelloSendFails) throw new System.IO.IOException("hello failed"); - return; - } - - if (WriteNotSentFails) throw new CultNetWriteNotSentException("x"); - if (WriteSendFails) throw new System.IO.IOException("send failed after an unknown number of bytes"); - Sent.Add(message); - if (AdmitWrites && message is CultNetDocumentPutRawMessage put) - _admitted?.Invoke(new CultNetWriteAdmittedMessage { MessageId = put.MessageId, Operation = "put", SchemaId = put.Document?.SchemaId, RecordKey = put.Document?.RecordKey }); - } - - public void OnCultNet(Action callback) where T : ICultNetSchemaMessage - { - if (callback is Action admitted) _admitted = admitted; - } - - public void Dispose() { } - } - - private static CultNetSchemaWriteForwarder RealForwarder(ForwardingClient client) => new(new CultNetSchemaWriteForwarderOptions - { - CreateClient = () => client, - ConnectTimeout = TimeSpan.FromMilliseconds(100), - ResultWindow = TimeSpan.FromMilliseconds(100) - }); - - private static readonly CultNetShardDescriptor ForwardShard = new("notes", "primary", 1, isPrimary: false, primaryEndpoints: ["cultnet://primary.example.test:4075"]); - - private static CultNetDocumentPutRawMessage ForwardPut() => new() { MessageId = "fwd-1", Document = new CultNetRawDocumentRecord { SchemaId = "s", RecordKey = "k", Payload = [1] } }; - - [TestCase("connect")] - [TestCase("never connects")] - [TestCase("hello")] - [TestCase("write not sent")] - public void TheForwarderNamesEveryFailureBeforeTheWriteWasSentAsNotSent(string stage) - { - var client = new ForwardingClient - { - ConnectFails = stage == "connect", - NeverConnects = stage == "never connects", - HelloSendFails = stage == "hello", - WriteNotSentFails = stage == "write not sent" - }; - - Assert.ThrowsAsync(() => RealForwarder(client).ForwardPutAsync(ForwardShard, ForwardPut())); - Assert.That(client.Sent, Is.Empty); - } - - [Test] - public void TheForwarderNamesNoPrimaryEndpointAsNotSent() - { - var shard = new CultNetShardDescriptor("notes", "primary", 1, isPrimary: false, primaryEndpoints: []); - Assert.ThrowsAsync(() => RealForwarder(new ForwardingClient()).ForwardPutAsync(shard, ForwardPut())); - } - - [Test] - public void ASendThatThrewWithoutProvingNothingWasWrittenIsNotNotSent() - { - var client = new ForwardingClient { WriteSendFails = true }; - var failure = Assert.CatchAsync(() => RealForwarder(client).ForwardPutAsync(ForwardShard, ForwardPut())); - Assert.That(failure, Is.Not.InstanceOf(), "ambiguous: unconfirmed"); - } - - [Test] - public void ASentWriteNobodyAnswersIsUnconfirmedNotNotSent() - { - var client = new ForwardingClient(); - Assert.ThrowsAsync(() => RealForwarder(client).ForwardPutAsync(ForwardShard, ForwardPut())); - Assert.That(client.Sent, Has.Count.EqualTo(1)); - } - - [Test] - public async Task TheRoutingRefusalAndTheUnownedSchemaRefusalCarryNoExceptionText() - { - var rig = Build(primary: false); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - await rig.Put(wire, "fwd-1", KeyA, "one"); - var routing = AssertRefused(wire.Sent.Single(), "fwd-1", null); - Assert.That(routing.Error, Is.EqualTo(CultNetDatabaseServer.RoutingRefusalText)); - - var secret = new CultNetUnownedSchemaException("secret.schema.v1", new CultRecordKey("secret:key")); - var unowned = CultNetErrorMessage.ForUnownedSchema(secret); - Assert.That(unowned.Error, Does.Not.Contain(secret.Message).And.Not.Contain("secret")); - Assert.That(unowned.Details!.Value, Is.EqualTo("secret.schema.v1"), "the structured detail still names the schema"); - } - - [Test] - public async Task APeerThatDidNotOptInIsToldNothingOfAnUnansweredForward() - { - var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(new CultNetWriteUnconfirmedException("fwd-1")) }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty); - } - - [Test] - public async Task AReceiptConnectionNotForwardableIsRefusedWithTheRoutingErrorAndItsId() - { - var rig = Build(primary: false); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - - var error = AssertRefused(wire.Sent.Single(), "fwd-1", null); - Assert.That(error.RoutingHint, Is.Not.Null); - } - - [Test] - public async Task AResendWhileTheFirstAttemptIsStillBeingAppliedIsNotAnsweredTwice() - { - var release = new TaskCompletionSource(); - var forwarder = new ScriptedForwarder { OnPut = _ => release.Task }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - var first = rig.Put(wire, "fwd-1", KeyA, "one"); - await rig.Put(wire, "fwd-1", KeyA, "one"); - Assert.That(wire.Sent, Is.Empty, "the resend waits for the first attempt's answer"); - release.SetResult(); - await first; - - AssertAdmitted(wire.Sent.Single(), "fwd-1", "put", KeyA.Value); - Assert.That(forwarder.Forwarded, Is.EqualTo(1), "and is not forwarded again"); - } - - [Test] - public void TheForwarderWaitsTheReceiptWindowByDefault() - { - Assert.That(new CultNetSchemaWriteForwarderOptions().ResultWindow, Is.EqualTo(CultNetWriteCompletion.ReceiptResultWindow)); - } - - // ---- Heimdall round two ---- - - private static string IdOfBytes(int bytes) => new string('i', bytes); - - [Test] - public async Task AnIdOver256BytesIsRefusedBeforeAnythingIsRemembered() - { - var (rig, wire) = await Capable(); - var tooLong = IdOfBytes(257); - - await rig.Put(wire, tooLong, KeyA, "one"); - await rig.Put(wire, tooLong, KeyA, "one"); - - Assert.That(wire.Sent, Has.Count.EqualTo(2)); - foreach (var sent in wire.Sent) - { - var error = AssertRefused(sent, tooLong, "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("messageId")); - Assert.That(error.Error, Is.EqualTo("message_invalid: messageId exceeds 256 bytes")); - } - - Assert.That(rig.TextOf(KeyA), Is.Null); - // Nothing was remembered: the whole table is still free for admitted writes. - wire.Sent.Clear(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); - Assert.That(wire.Sent.OfType().Count(), Is.EqualTo(CultNetWriteReceiptSession.MaxRememberedWrites)); - } - - [Test] - public async Task TheIdLimitCountsUtf8BytesNotCharacters() - { - var (rig, wire) = await Capable(); - var atLimit = IdOfBytes(256); - var multibyte = new string('\u00e9', 129); // 129 chars, 258 bytes - - await rig.Put(wire, atLimit, KeyA, "one"); - await rig.Put(wire, multibyte, KeyB, "two"); - - AssertAdmitted(wire.Sent[0], atLimit, "put", KeyA.Value); - AssertRefused(wire.Sent[1], multibyte, "message_invalid"); - } - - [Test] - public void ARememberedEntryHoldsOnlyFixedSizeDigestsNotTheStrings() - { - var flags = BindingFlags.NonPublic | BindingFlags.Instance; - var table = typeof(CultNetWriteReceiptSession).GetFields(flags) - .Where(f => f.FieldType.IsGenericType && f.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>)); - Assert.That(table, Is.Not.Empty); - foreach (var field in table) - { - var args = field.FieldType.GetGenericArguments(); - Assert.That(args[0].IsValueType, Is.True, $"{field.Name} is keyed by {args[0]}"); - foreach (var held in args[1].IsValueType || args[1] == typeof(string) ? new[] { args[1] } : args[1].GetFields(flags).Select(f => f.FieldType).Concat(args[1].GetProperties(flags | BindingFlags.Public).Select(p => p.PropertyType))) - { - Assert.That(held == typeof(string) || held == typeof(byte[]), Is.False, $"{field.Name} holds {held}"); - } - } - } - - [Test] - public async Task AnAdmittedResendIsAnsweredFromDigestsWithTheResendsOwnBody() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - await rig.Put(wire, "m1", KeyA, "one"); - - AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); - Assert.That(rig.LogLength, Is.EqualTo(1)); - } - - [Test] - public async Task ARefusalIsReplayedForAResendAfterTheDigestChange() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - await rig.Put(wire, "m1", KeyA, "one"); - await rig.Put(wire, "m1", KeyA, "one"); - - AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); - AssertRefused(wire.Sent[1], "m1", "write_not_authorized"); - Assert.That(rig.Authority.Asked, Is.EqualTo(1)); - } - - [Test] - public async Task APutWithNoDocumentIsRefusedNamingTheDocumentWithFixedText([Values] bool capable) - { - var rig = Build(); - var wire = new Wire(); - if (capable) await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - var message = rig.PutOf("m1", KeyA, "one"); - message.Document = null!; - - await rig.PutHandler(message, wire.Peer); - - var sent = wire.Sent.Single(); - var error = capable ? AssertRefused(sent, "m1", "message_invalid").ToV0() : (CultNetErrorMessage)sent; - Assert.That(error.Code, Is.EqualTo("message_invalid")); - Assert.That(error.Details!.Field, Is.EqualTo("document")); - Assert.That(error.Error, Is.EqualTo("message_invalid: document is required")); - } - - [Test] - public async Task APutWithNoSchemaIdIsRefusedNamingTheSchemaIdWithFixedText() - { - var (rig, wire) = await Capable(); - var message = rig.PutOf("m1", KeyA, "one"); - message.Document.SchemaId = null!; - - await rig.PutHandler(message, wire.Peer); - - var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("schemaId")); - Assert.That(error.Error, Is.EqualTo("message_invalid: schemaId is required")); - } - - [Test] - public async Task ADeleteWithNoSchemaIdIsRefusedNamingTheSchemaIdWithFixedText([Values] bool capable) - { - var rig = Build(); - var wire = new Wire(); - if (capable) await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - var message = rig.DeleteOf("d1", KeyA); - message.SchemaId = null!; - - await rig.DeleteHandler(message, wire.Peer); - - var sent = wire.Sent.Single(); - var error = capable ? AssertRefused(sent, "d1", "message_invalid").ToV0() : (CultNetErrorMessage)sent; - Assert.That(error.Details!.Field, Is.EqualTo("schemaId")); - Assert.That(error.Error, Is.EqualTo("message_invalid: schemaId is required")); - } - - // An empty or blank schemaId or recordKey is refused like a frame that does not read (the Rust server's rule): message_invalid, - // details.field "message", the write's id, nothing applied. A null schemaId stays "schemaId is required". - [TestCase("", null)] - [TestCase(" ", null)] - [TestCase(null, "")] - [TestCase(null, " ")] - public async Task APutWithAnEmptyOrBlankSchemaIdOrRecordKeyIsRefusedAsUnreadableAndNotApplied(string? schemaId, string? recordKey) - { - var (rig, wire) = await Capable(); - var message = rig.PutOf("m1", KeyA, "one"); - if (schemaId != null) message.Document.SchemaId = schemaId; - if (recordKey != null) message.Document.RecordKey = recordKey; - wire.Sent.Clear(); - - await rig.PutHandler(message, wire.Peer); - - var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("message")); - Assert.That(rig.TextOf(KeyA), Is.Null); - } - - [TestCase("", "k")] - [TestCase(" ", "k")] - [TestCase("note.v0", "")] - public async Task ADeleteWithAnEmptyOrBlankSchemaIdOrRecordKeyIsRefusedAsUnreadable(string schemaId, string recordKey) - { - var (rig, wire) = await Capable(); - var message = rig.DeleteOf("d1", new CultRecordKey(recordKey)); - message.SchemaId = schemaId; - wire.Sent.Clear(); - - await rig.DeleteHandler(message, wire.Peer); - - var error = AssertRefused(wire.Sent.Single(), "d1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("message")); - } - - [Test] - public async Task ARefusalAtTheCapIsRememberedSoAResendReplaysItAndADifferentContentResendIsRefused() - { - var (rig, wire) = await Capable(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); - wire.Sent.Clear(); - - await rig.Put(wire, "over", KeyA, "x"); - await rig.Put(wire, "over", KeyA, "x"); - await rig.Put(wire, "over", KeyB, "other"); - - AssertRefused(wire.Sent[0], "over", "limit_reached"); - AssertRefused(wire.Sent[1], "over", "limit_reached"); - var changed = AssertRefused(wire.Sent[2], "over", "message_invalid"); - Assert.That(changed.Details!.Field, Is.EqualTo("messageId")); - Assert.That(rig.TextOf(KeyA), Is.Null); - } - - private static byte[] HashOf(string text) => CultNetWriteContentHash.Compute("put", "s", text, new byte[] { 1 }); - - [Test] - public void AnAbandonedClaimFreesNoSlotSoARefusedIdNeverBecomesAdmissible() - { - var session = new CultNetWriteReceiptSession(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - Assert.That(session.TryBegin($"w{index}", HashOf($"k{index}"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.New)); - Assert.That(session.TryBegin("over", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - - session.Abandoned("w0"); - - Assert.That(session.TryBegin("over", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - Assert.That(session.TryBegin("another", HashOf("a"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - // The abandoned id itself is judged afresh, in its own slot. - Assert.That(session.TryBegin("w0", HashOf("k0"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.New)); - } - - [Test] - public void TheRefusalSetIsBoundedAndNeverAdmitsAnId() - { - var session = new CultNetWriteReceiptSession(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - session.TryBegin($"w{index}", HashOf($"k{index}"), out _); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites + 5; index++) - Assert.That(session.TryBegin($"o{index}", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - - session.Abandoned("w1"); - - Assert.That(session.TryBegin("o1030", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - } - - [Test] - public void TheRefusalSetStopsRememberingAtItsBound() - { - var session = new CultNetWriteReceiptSession(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - session.TryBegin($"w{index}", HashOf($"k{index}"), out _); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedLimitRefusals; index++) - session.TryBegin($"o{index}", HashOf("o"), out _); - - // Past the bound a refusal is not remembered: its changed-content resend is simply refused at the limit again. - Assert.That(session.TryBegin("late", HashOf("a"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - Assert.That(session.TryBegin("late", HashOf("b"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - // Inside the bound it is remembered. - Assert.That(session.TryBegin("o0", HashOf("changed"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.DifferentContent)); - } - - [Test] - public void AnAbandonedIdMayBeResentWithOtherContentBecauseItWasNeverSent() - { - var session = new CultNetWriteReceiptSession(); - session.TryBegin("m1", HashOf("a"), out _); - session.Abandoned("m1"); - - Assert.That(session.TryBegin("m1", HashOf("b"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.New)); - Assert.That(session.TryBegin("m1", HashOf("b"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Unanswered)); - } - - // ---- Fixed text in v0 errors: an exception's own words never reach the wire ---- - - private static Func HandlerOf(Rig rig, string field) => - (Func)typeof(CultNetDatabaseServer) - .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(rig.DatabaseServer)!; - - [Test] - public async Task ASnapshotFaultIsAnsweredWithFixedTextNotTheExceptionMessage() - { - var rig = Build(); - var wire = new Wire(); - - await HandlerOf(rig, "_snapshotHandlerV1")( - new CultNetSnapshotRequestV1Message { MessageId = "s1", Selection = null! }, wire.Peer); - - var error = (CultNetErrorMessage)wire.Sent.Single(); - Assert.That(error.Error, Is.EqualTo("the snapshot request could not be answered")); - } - - [Test] - public async Task AShardLogFaultIsAnsweredWithFixedTextNotTheExceptionMessage() - { - var rig = Build(); - var wire = new Wire(); - - await HandlerOf(rig, "_shardLogHandler")( - new CultNetShardLogRequestMessage { MessageId = "l1", ShardId = string.Empty }, wire.Peer); - - var error = (CultNetErrorMessage)wire.Sent.Single(); - Assert.That(error.Error, Is.EqualTo("the shard log request could not be answered")); - } - - private static CultNetErrorMessage DeniedWith(string recordKey, string reason) => new() - { - Error = $"write_not_authorized: {reason}", - Code = "write_not_authorized", - Details = new CultNetErrorDetails { Field = "recordKey", Value = recordKey } - }; - - // write-receipt.md, What is remembered: a refusal is remembered as its code (and the field it names), never the strings of - // the write or of the authority's reason; the replay is built from fixed text and the resend's own fields. - [Test] - public void ARefusalIsReplayedFromFixedTextAndTheResendsOwnFieldsNotFromRememberedStrings() - { - var session = new CultNetWriteReceiptSession(); - var sent = new List(); - var longKey = new string('k', 50_000); - CultNetReceiptedWrite Write() => new(session, sent.Add, "put", "m1", "schema.v1", longKey, [1]); - - var first = Write(); - Assert.That(first.Begin(), Is.True); - first.Refused(DeniedWith(longKey, new string('r', 50_000))); - Assert.That(Write().Begin(), Is.False); - - var again = (CultNetErrorV1Message)sent[1]; - Assert.That(again.MessageId, Is.EqualTo("m1")); - Assert.That(again.Code, Is.EqualTo("write_not_authorized")); - Assert.That(again.Error, Is.EqualTo("write_not_authorized")); - Assert.That(again.Details!.Field, Is.EqualTo("recordKey")); - Assert.That(again.Details.Value, Is.EqualTo(longKey)); - var firstAnswer = (CultNetErrorV1Message)sent[0]; - Assert.That(firstAnswer.Error, Is.EqualTo(again.Error), "the replay is the refusal again"); - Assert.That(firstAnswer.Details!.Value, Is.EqualTo(again.Details.Value)); - } - - [Test] - public void ThousandsOfRefusalsOf50kCharacterKeysAreRememberedInBoundedMemory() - { - var session = new CultNetWriteReceiptSession(); - var before = GC.GetTotalMemory(true); - for (var i = 0; i < CultNetWriteReceiptSession.MaxRememberedWrites; i++) - { - var key = new string('k', 50_000) + i; - var write = new CultNetReceiptedWrite(session, _ => { }, "put", $"m{i}", "schema.v1", key, [1]); - Assert.That(write.Begin(), Is.True); - write.Refused(DeniedWith(key, key)); - } - - // Held as strings, the 1024 keys and reasons alone would be over 200 MB. - var growth = GC.GetTotalMemory(true) - before; - GC.KeepAlive(session); - Assert.That(growth, Is.LessThan(8L * 1024 * 1024)); - } - - // A remembered refusal is replayed with the details its code carries: the schema id of an unowned write, no details - // at all for the routing refusal. - [Test] - public void AReplayedRefusalCarriesTheDetailsOfItsCodeAndNoOthers() - { - var session = new CultNetWriteReceiptSession(); - var sent = new List(); - CultNetReceiptedWrite Write(string id) => new(session, sent.Add, "put", id, "schema.v1", "note:1", [1]); - - var unowned = Write("u1"); - Assert.That(unowned.Begin(), Is.True); - unowned.Refused(CultNetErrorMessage.ForUnownedSchema(new CultNetUnownedSchemaException("schema.v1", new CultRecordKey("note:1")))); - Assert.That(Write("u1").Begin(), Is.False); - - var routed = Write("g1"); - Assert.That(routed.Begin(), Is.True); - routed.Refused(new CultNetErrorMessage - { - Error = CultNetDatabaseServer.RoutingRefusalText, - RoutingHint = new CultNetShardRoutingHint { Reason = "not_primary" } - }); - Assert.That(Write("g1").Begin(), Is.False); - - var unownedAgain = (CultNetErrorV1Message)sent[1]; - Assert.That(unownedAgain.Code, Is.EqualTo("unowned_schema")); - Assert.That(unownedAgain.Details!.Field, Is.EqualTo("schemaId")); - Assert.That(unownedAgain.Details.Value, Is.EqualTo("schema.v1")); - Assert.That(unownedAgain.Error, Is.EqualTo(((CultNetErrorV1Message)sent[0]).Error)); - var routedAgain = (CultNetErrorV1Message)sent[3]; - Assert.That(routedAgain.Details, Is.Null); - Assert.That(routedAgain.RoutingHint!.Reason, Is.EqualTo("not_primary")); - Assert.That(routedAgain.Error, Is.EqualTo(CultNetDatabaseServer.RoutingRefusalText)); - } - - // A write that carries no record key or schema id has none to echo: the detail value is null, not an empty string. - [Test] - public void AReplayedRefusalOfAWriteWithoutTheNamedFieldCarriesANullValue() - { - var session = new CultNetWriteReceiptSession(); - var sent = new List(); - CultNetReceiptedWrite Write() => new(session, sent.Add, "delete", "m1", null, null, null); - var first = Write(); - Assert.That(first.Begin(), Is.True); - first.Refused(DeniedWith("ignored", "reason")); - Assert.That(Write().Begin(), Is.False); - - foreach (var message in sent) - { - var error = (CultNetErrorV1Message)message; - Assert.That(error.Details!.Field, Is.EqualTo("recordKey")); - Assert.That(error.Details.Value, Is.Null); - } - - var unowned = new CultNetReceiptedWrite(session, sent.Add, "delete", "m2", null, null, null); - Assert.That(unowned.Begin(), Is.True); - unowned.Refused(CultNetErrorMessage.ForUnownedSchema(new CultNetUnownedSchemaException("x", new CultRecordKey("k")))); - Assert.That(((CultNetErrorV1Message)sent[^1]).Details!.Field, Is.EqualTo("schemaId")); - Assert.That(((CultNetErrorV1Message)sent[^1]).Details!.Value, Is.Null); - } - } -} diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 606d5625..2a75b5c2 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -5189,9 +5189,7 @@ namespace GameCult.Networking.Tests [Test] public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort() { - var fake = new CapturingSchemaClient(message => message is CultNetDocumentDeleteMessage delete - ? new CultNetWriteAdmittedMessage { MessageId = delete.MessageId, Operation = "delete", SchemaId = delete.SchemaId, RecordKey = delete.RecordKey } - : null); + var fake = new CapturingSchemaClient(_ => null); var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake @@ -5205,16 +5203,11 @@ namespace GameCult.Networking.Tests await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { - MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" }); - var hello = (CultNetHelloMessage)fake.SentMessages[0]; - Assert.That(hello.SupportedMessageVersions, Is.EquivalentTo(new[] { CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1 }), - "the hello opts the forwarding connection in before the write"); - var sent = (CultNetDocumentDeleteMessage)fake.SentMessages[1]; - Assert.That(sent.MessageId, Is.EqualTo("fwd-del"), "the write goes under its original id"); + var sent = (CultNetDocumentDeleteMessage)fake.SentMessages.Single(); Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test")); Assert.That(sent.ShardId, Is.EqualTo("notes")); Assert.That(sent.ShardEpoch, Is.EqualTo(3)); @@ -5248,7 +5241,7 @@ namespace GameCult.Networking.Tests } [Test] - public void CultNetSchemaWriteForwarder_SilenceIsUnconfirmedNotSuccess() + public async Task CultNetSchemaWriteForwarder_DoesNotCompleteOnTheSendReturning() { var fake = new CapturingSchemaClient(_ => null); var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions @@ -5261,51 +5254,11 @@ namespace GameCult.Networking.Tests primaryEndpoints: ["cultnet://primary.example.test:4075"]); var started = DateTime.UtcNow; - var unconfirmed = Assert.ThrowsAsync(() => - forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); + await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" }); - Assert.That(unconfirmed!.MessageId, Is.EqualTo("fwd-del")); Assert.That(DateTime.UtcNow - started, Is.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380))); } - [Test] - public void CultNetSchemaWriteForwarder_AnswersNamingAnotherWriteSettleNothing() - { - var fake = new CapturingSchemaClient(_ => null); - var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions - { - CreateClient = () => fake, - ResultWindow = TimeSpan.FromMilliseconds(300) - }); - var shard = new CultNetShardDescriptor( - "notes", "primary", 3, isPrimary: false, - primaryEndpoints: ["cultnet://primary.example.test:4075"]); - fake.OnSend = () => - { - fake.Deliver(new CultNetWriteAdmittedMessage { MessageId = "someone-else", Operation = "delete", SchemaId = "note.v0", RecordKey = "note:1" }); - fake.Deliver(new CultNetErrorV1Message { Error = "refused", Code = "write_not_authorized", MessageId = "someone-else" }); - }; - - Assert.ThrowsAsync(() => - forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); - } - - [Test] - public void CultNetSchemaWriteForwarder_RejectsWithTheErrorV1NamingItsWrite() - { - var fake = new CapturingSchemaClient(_ => null); - var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake }); - var shard = new CultNetShardDescriptor( - "notes", "primary", 3, isPrimary: false, - primaryEndpoints: ["cultnet://primary.example.test:4075"]); - fake.OnSend = () => fake.Deliver(new CultNetErrorV1Message { Error = "refused", Code = "write_not_authorized", MessageId = "fwd-del" }); - - var refused = Assert.ThrowsAsync(() => - forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); - - Assert.That(refused!.Code, Is.EqualTo("write_not_authorized")); - } - [Test] public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets() {