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()
{