diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index d9e7ec09..af561ea5 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -1017,6 +1017,8 @@ 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. @@ -1047,6 +1049,7 @@ 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 6433c6c3..1746391e 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -22,6 +22,7 @@ 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; @@ -45,6 +46,7 @@ namespace GameCult.Networking _snapshotHandlerV1 = HandleSnapshotRequestV1Async; _putHandler = HandlePutAsync; _deleteHandler = HandleDeleteAsync; + _helloHandler = HandleHelloAsync; _shardCatalogHandler = HandleShardCatalogRequestAsync; _shardLogHandler = HandleShardLogRequestAsync; _subscribeHandler = HandleSubscribeAsync; @@ -55,6 +57,7 @@ namespace GameCult.Networking _server.OnCultNet(_snapshotHandlerV1); _server.OnCultNet(_putHandler); _server.OnCultNet(_deleteHandler); + _server.OnCultNet(_helloHandler); _server.OnCultNet(_shardCatalogHandler); _server.OnCultNet(_shardLogHandler); _server.OnCultNet(_subscribeHandler); @@ -217,6 +220,7 @@ namespace GameCult.Networking _server.RemoveCultNetMessageListener(_snapshotHandlerV1); _server.RemoveCultNetMessageListener(_putHandler); _server.RemoveCultNetMessageListener(_deleteHandler); + _server.RemoveCultNetMessageListener(_helloHandler); _server.RemoveCultNetMessageListener(_shardCatalogHandler); _server.RemoveCultNetMessageListener(_shardLogHandler); _server.RemoveCultNetMessageListener(_subscribeHandler); @@ -262,7 +266,7 @@ namespace GameCult.Networking catch (Exception ex) { _server.Logger.LogError($"CultNet snapshot v1 request failed: {ex.Message}"); - peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); + peer.SendCultNet(new CultNetErrorMessage { Error = "the snapshot request could not be answered" }); } return Task.CompletedTask; @@ -282,7 +286,8 @@ namespace GameCult.Networking } catch (Exception ex) { - peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); + _server.Logger.LogError($"CultNet shard log request failed: {ex.Message}"); + peer.SendCultNet(new CultNetErrorMessage { Error = "the shard log request could not be answered" }); } return Task.CompletedTask; @@ -323,74 +328,256 @@ namespace GameCult.Networking }; } - private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer) + // 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 { - await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch)) - .ConfigureAwait(false); + 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(); } catch (CultNetRemoteWriteRefusedException ex) { _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); - peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); + Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { - if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false)) + 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 { _server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}"); - peer.SendCultNet(CreateRoutingError(ex)); + Refuse(peer, write, CreateRoutingError(ex)); } } catch (CultNetUnownedSchemaException ex) { _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); - peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex)); + Refuse(peer, write, 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}"); - peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); + Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText }); } } 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(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch)) - .ConfigureAwait(false); + await _database.ApplyDeleteAsync(message, authorize).ConfigureAwait(false); + write?.Admitted(); } catch (CultNetRemoteWriteRefusedException ex) { _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); - peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); + Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { - if (!await TryForwardDeleteAsync(ex, message).ConfigureAwait(false)) + bool forwarded; + try { - peer.SendCultNet(CreateRoutingError(ex)); + 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)); } } catch (CultNetUnownedSchemaException ex) { _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); - peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex)); + Refuse(peer, write, 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) { - peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); + Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText }); } } @@ -588,7 +775,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 = refusal.Message }); + sendError(new CultNetErrorMessage { Error = "the record is a variant CultNet cannot carry" }); return null; } @@ -666,11 +853,14 @@ 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 = exception.Message, + Error = RoutingRefusalText, RoutingHint = new CultNetShardRoutingHint { Reason = exception.Reason, diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs index 83865e31..79f0ce20 100644 --- a/src/GameCult.Networking/CultNetSchemaClient.cs +++ b/src/GameCult.Networking/CultNetSchemaClient.cs @@ -27,7 +27,8 @@ 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 (). + /// 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. /// void SendCultNet(T message) where T : ICultNetSchemaMessage; diff --git a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs index d098a923..cdcca912 100644 --- a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs +++ b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs @@ -53,6 +53,8 @@ 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 5f37f150..090c6a45 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -48,6 +48,14 @@ 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"; @@ -538,7 +546,7 @@ namespace GameCult.Networking public static CultNetErrorMessage ForUnownedSchema(CultNetUnownedSchemaException ex) => new CultNetErrorMessage { - Error = $"unowned_schema: {ex.Message}", + Error = "unowned_schema: no shard owns the schema and key", Code = "unowned_schema", Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId } }; @@ -621,6 +629,80 @@ 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 0fa6c4f7..e4e66298 100644 --- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs +++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs @@ -10,12 +10,16 @@ namespace GameCult.Networking public interface ICultNetShardWriteForwarder { /// - /// Forwards a raw document put to the shard owner. + /// 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. /// Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message); /// - /// Forwards a raw document delete to the shard owner. + /// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as + /// does. /// Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message); } @@ -70,10 +74,12 @@ namespace GameCult.Networking public Func? CreateClient { get; set; } /// - /// Gets or sets how long after a forwarded write the primary's refusal may still arrive; a refusal fails the - /// forward with . Defaults to . + /// 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. /// - public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow; + public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow; } /// @@ -92,29 +98,29 @@ namespace GameCult.Networking } /// - /// Forwards a put and completes on the primary's result frame: a refusal throws - /// , the same failure path as an unreachable primary. + /// 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 . /// public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); - var endpoint = ResolvePrimaryEndpoint(shard); + if (shard == null) throw new ArgumentNullException(nameof(shard)); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; - await SendAsync(endpoint, message).ConfigureAwait(false); + await SendAsync(shard, message, message.MessageId).ConfigureAwait(false); } /// - /// Forwards a delete and completes on the primary's result frame: a refusal throws - /// . + /// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as + /// does. /// public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); - var endpoint = ResolvePrimaryEndpoint(shard); + if (shard == null) throw new ArgumentNullException(nameof(shard)); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; - await SendAsync(endpoint, message).ConfigureAwait(false); + await SendAsync(shard, message, message.MessageId).ConfigureAwait(false); } /// @@ -138,19 +144,50 @@ namespace GameCult.Networking return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port); } - private async Task SendAsync(string endpoint, T message) where T : ICultNetSchemaMessage + // 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) { - var (host, port) = ParseEndpoint(endpoint); - using var client = CreateClient(endpoint); - client.Connect(host, port); - await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); - await CultNetWriteCompletion.SendAndAwaitResultAsync( - refuse => + 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 { + 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 da465326..fc41b2f4 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -3533,7 +3533,8 @@ namespace GameCult.Networking /// public sealed class LiteNetLibTransportConnection { - private readonly NetPeer? _peer; + private readonly Action? _deliver; + private readonly Action? _end; private readonly CultNetTransportStats _stats = new CultNetTransportStats(); /// @@ -3541,10 +3542,99 @@ namespace GameCult.Networking /// public LiteNetLibTransportConnection(NetPeer peer, CultNetTransportProfile? profile = null) { - _peer = peer ?? throw new ArgumentNullException(nameof(peer)); + if (peer == null) throw new ArgumentNullException(nameof(peer)); + _deliver = payload => peer.Send(payload, DeliveryMethod.ReliableOrdered); + _end = peer.Disconnect; 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. /// @@ -3580,14 +3670,14 @@ namespace GameCult.Networking public void Send(string channelId, byte[] payload) { if (payload == null) throw new ArgumentNullException(nameof(payload)); - if (_peer == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport."); + if (_deliver == 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}\"."); } - _peer.Send(payload, DeliveryMethod.ReliableOrdered); + _deliver(payload); _stats.BytesSent += payload.Length; _stats.FramesSent++; } diff --git a/src/GameCult.Networking/CultNetWriteCompletion.cs b/src/GameCult.Networking/CultNetWriteCompletion.cs index 6d5c226d..8767be6e 100644 --- a/src/GameCult.Networking/CultNetWriteCompletion.cs +++ b/src/GameCult.Networking/CultNetWriteCompletion.cs @@ -24,6 +24,40 @@ 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. @@ -33,6 +67,49 @@ 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 new file mode 100644 index 00000000..2a90f35d --- /dev/null +++ b/src/GameCult.Networking/CultNetWriteReceipts.cs @@ -0,0 +1,396 @@ +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 128eb47d..08af0cc4 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 ServerPort = 3075; + private const int DefaultPort = 3075; private const float SessionTimeoutSeconds = 1800; // 30 minutes private const float SessionRefreshThresholdSeconds = 300; // 5 minutes private const int MaxConnectionAttemptsPerMinute = 30; @@ -63,6 +63,12 @@ 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); /// @@ -74,7 +80,7 @@ namespace GameCult.Networking { TransportId = "litenetlib-server", Host = "0.0.0.0", - Port = ServerPort + Port = Port }); /// @@ -357,7 +363,8 @@ namespace GameCult.Networking UnsyncedEvents = true, NatPunchEnabled = true }; - _netManager.Start(ServerPort); + _netManager.Start(Port); + Port = _netManager.LocalPort; listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}"); @@ -408,11 +415,25 @@ 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; } - var message = LiteNetLibTransportConnection.DecodeLegacy(frame); + 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; + } + if (LogSensitivePayloads) { Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(frame.Payload))}"); @@ -507,7 +528,7 @@ namespace GameCult.Networking } }); - Logger.LogInfo($"Server started on port {ServerPort}."); + Logger.LogInfo($"Server started on port {Port}."); } 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 64de1093..5713b0da 100644 --- a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs @@ -418,6 +418,7 @@ 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 e7900e7c..2bb59952 100644 --- a/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs @@ -178,6 +178,46 @@ 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 new file mode 100644 index 00000000..d5e32fa0 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptLoopbackTests.cs @@ -0,0 +1,290 @@ +#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 new file mode 100644 index 00000000..c572cfbf --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs @@ -0,0 +1,1272 @@ +#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 2a75b5c2..606d5625 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -5189,7 +5189,9 @@ namespace GameCult.Networking.Tests [Test] public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort() { - var fake = new CapturingSchemaClient(_ => null); + var fake = new CapturingSchemaClient(message => message is CultNetDocumentDeleteMessage delete + ? new CultNetWriteAdmittedMessage { MessageId = delete.MessageId, Operation = "delete", SchemaId = delete.SchemaId, RecordKey = delete.RecordKey } + : null); var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake @@ -5203,11 +5205,16 @@ namespace GameCult.Networking.Tests await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { + MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" }); - var sent = (CultNetDocumentDeleteMessage)fake.SentMessages.Single(); + 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"); Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test")); Assert.That(sent.ShardId, Is.EqualTo("notes")); Assert.That(sent.ShardEpoch, Is.EqualTo(3)); @@ -5241,7 +5248,7 @@ namespace GameCult.Networking.Tests } [Test] - public async Task CultNetSchemaWriteForwarder_DoesNotCompleteOnTheSendReturning() + public void CultNetSchemaWriteForwarder_SilenceIsUnconfirmedNotSuccess() { var fake = new CapturingSchemaClient(_ => null); var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions @@ -5254,11 +5261,51 @@ namespace GameCult.Networking.Tests primaryEndpoints: ["cultnet://primary.example.test:4075"]); var started = DateTime.UtcNow; - await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" }); + var unconfirmed = Assert.ThrowsAsync(() => + forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", 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() {