C#: database server answers writes with write_admitted / error.v1 (write-receipt spec) #61
15 changed files with 2602 additions and 53 deletions
|
|
@ -1017,6 +1017,8 @@ namespace GameCult.Networking
|
|||
/// <summary>
|
||||
/// 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 <see cref="CultNetDatabaseServer"/>, which does.
|
||||
/// Returning normally means the put is committed and visible to readers: a receipt-capable peer is told
|
||||
/// <c>write_admitted</c> on the strength of it, so a write that has only been queued must not return.
|
||||
/// </summary>
|
||||
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
||||
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
||||
|
|
@ -1047,6 +1049,7 @@ namespace GameCult.Networking
|
|||
/// <summary>
|
||||
/// 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 <see cref="CultNetDatabaseServer"/>, which does.
|
||||
/// Returning normally means the delete is committed and visible to readers (see <see cref="ApplyPutAsync(CultNetDocumentPutRawMessage)"/>).
|
||||
/// </summary>
|
||||
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
||||
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ namespace GameCult.Networking
|
|||
private readonly Func<CultNetSnapshotRequestV1Message, CultNetServerPeer, Task> _snapshotHandlerV1;
|
||||
private readonly Func<CultNetDocumentPutRawMessage, CultNetServerPeer, Task> _putHandler;
|
||||
private readonly Func<CultNetDocumentDeleteMessage, CultNetServerPeer, Task> _deleteHandler;
|
||||
private readonly Func<CultNetHelloMessage, CultNetServerPeer, Task> _helloHandler;
|
||||
private readonly Func<CultNetShardCatalogRequestMessage, CultNetServerPeer, Task> _shardCatalogHandler;
|
||||
private readonly Func<CultNetShardLogRequestMessage, CultNetServerPeer, Task> _shardLogHandler;
|
||||
private readonly Func<CultNetDatabaseSubscribeMessage, CultNetServerPeer, Task> _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<CultNetSnapshotRequestV1Message>(_snapshotHandlerV1);
|
||||
_server.RemoveCultNetMessageListener<CultNetDocumentPutRawMessage>(_putHandler);
|
||||
_server.RemoveCultNetMessageListener<CultNetDocumentDeleteMessage>(_deleteHandler);
|
||||
_server.RemoveCultNetMessageListener<CultNetHelloMessage>(_helloHandler);
|
||||
_server.RemoveCultNetMessageListener<CultNetShardCatalogRequestMessage>(_shardCatalogHandler);
|
||||
_server.RemoveCultNetMessageListener<CultNetShardLogRequestMessage>(_shardLogHandler);
|
||||
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_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,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ namespace GameCult.Networking
|
|||
|
||||
/// <summary>
|
||||
/// Sends one schema-v0 message through the transport. Fire-and-forget: an ack or a return is not admission;
|
||||
/// read the result frame (<see cref="CultNetWriteCompletion"/>).
|
||||
/// read the result frame (<see cref="CultNetWriteCompletion"/>). Throws <see cref="CultNetWriteNotSentException"/> only
|
||||
/// when it can prove no byte was written; any other exception leaves the send ambiguous and a forwarded write unconfirmed.
|
||||
/// </summary>
|
||||
void SendCultNet<T>(T message)
|
||||
where T : ICultNetSchemaMessage;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ namespace GameCult.Networking
|
|||
CultNetSchemaVersions.Verify => Deserialize<CultNetVerifyMessage>(payload),
|
||||
CultNetSchemaVersions.LoginSuccess => Deserialize<CultNetLoginSuccessMessage>(payload),
|
||||
CultNetSchemaVersions.Error => Deserialize<CultNetErrorMessage>(payload),
|
||||
CultNetSchemaVersions.ErrorV1 => Deserialize<CultNetErrorV1Message>(payload),
|
||||
CultNetSchemaVersions.WriteAdmitted => Deserialize<CultNetWriteAdmittedMessage>(payload),
|
||||
CultNetSchemaVersions.SampleChangeName => Deserialize<CultNetSampleChangeNameMessage>(payload),
|
||||
CultNetSchemaVersions.SampleChat => Deserialize<CultNetSampleChatMessage>(payload),
|
||||
CultNetSchemaVersions.OperationRequest => Deserialize<CultNetOperationRequestMessage>(payload),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public const string Error = "cultnet.error.v0";
|
||||
/// <summary>
|
||||
/// error v1 contract identifier: error v0 plus the id of the request it answers (write-receipt.md).
|
||||
/// </summary>
|
||||
public const string ErrorV1 = "cultnet.error.v1";
|
||||
/// <summary>
|
||||
/// write admitted receipt contract identifier (write-receipt.md).
|
||||
/// </summary>
|
||||
public const string WriteAdmitted = "cultnet.write_admitted.v0";
|
||||
/// <summary>
|
||||
/// sample change name contract identifier.
|
||||
/// </summary>
|
||||
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
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>cultnet.error.v1</c> (contracts/cultnet/interop/write-receipt.md): the v0 error plus <see cref="MessageId"/>,
|
||||
/// the id of the request it answers. Sent only to a connection that declared the write-receipt capability.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CultNetErrorV1Message : ICultNetSchemaMessage
|
||||
{
|
||||
/// <summary>Gets or sets the schema version.</summary>
|
||||
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.ErrorV1;
|
||||
/// <summary>Gets or sets the error.</summary>
|
||||
[Key("error")] public string Error { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets optional shard routing details for authority failures.</summary>
|
||||
[Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; }
|
||||
/// <summary>Gets or sets the machine-readable refusal code (the v0 enum).</summary>
|
||||
[Key("code")] public string? Code { get; set; }
|
||||
/// <summary>Gets or sets the code-specific structured detail.</summary>
|
||||
[Key("details")] public CultNetErrorDetails? Details { get; set; }
|
||||
/// <summary>Gets or sets the id of the request this answers; null only when no id could be read.</summary>
|
||||
[Key("messageId")] public string? MessageId { get; set; }
|
||||
|
||||
/// <summary>Builds the v1 form of <paramref name="error"/> answering <paramref name="messageId"/>.</summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The refusal of a write a session has no room to remember (write-receipt.md, rule 5): code <c>limit_reached</c>,
|
||||
/// details present and all nil. Exists only as error.v1; v0 has no such code.
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>Gets the v0 form, dropping the id.</summary>
|
||||
public CultNetErrorMessage ToV0() => new CultNetErrorMessage
|
||||
{
|
||||
Error = Error,
|
||||
RoutingHint = RoutingHint,
|
||||
Code = Code,
|
||||
Details = Details
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>cultnet.write_admitted.v0</c>: the receipt for one admitted put or delete, sent after its commit point.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CultNetWriteAdmittedMessage : ICultNetSchemaMessage
|
||||
{
|
||||
/// <summary>Gets or sets the schema version.</summary>
|
||||
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.WriteAdmitted;
|
||||
/// <summary>Gets or sets the messageId of the admitted write, echoed.</summary>
|
||||
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets <c>put</c> or <c>delete</c>.</summary>
|
||||
[Key("operation")] public string Operation { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the schemaId of the admitted write, echoed.</summary>
|
||||
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the recordKey of the admitted write, echoed.</summary>
|
||||
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code-specific structured detail carried on <see cref="CultNetErrorMessage"/> (R-N). Fields are
|
||||
/// populated only for the code that defines them; every field is otherwise omitted (nil on the wire).
|
||||
|
|
|
|||
|
|
@ -10,12 +10,16 @@ namespace GameCult.Networking
|
|||
public interface ICultNetShardWriteForwarder
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="CultNetWriteRefusedException"/> when it refused, <see cref="CultNetWriteNotSentException"/> 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 <see cref="CultNetWriteUnconfirmedException"/>). The server relays to the original peer exactly what the owner answered.
|
||||
/// </summary>
|
||||
Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message);
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a raw document delete to the shard owner.
|
||||
/// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as
|
||||
/// <see cref="ForwardPutAsync"/> does.
|
||||
/// </summary>
|
||||
Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message);
|
||||
}
|
||||
|
|
@ -70,10 +74,12 @@ namespace GameCult.Networking
|
|||
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how long after a forwarded write the primary's refusal may still arrive; a refusal fails the
|
||||
/// forward with <see cref="CultNetWriteRefusedException"/>. Defaults to <see cref="CultNetWriteCompletion.DefaultResultWindow"/>.
|
||||
/// 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 <see cref="CultNetWriteRefusedException"/>, and silence fails it with
|
||||
/// <see cref="CultNetWriteUnconfirmedException"/>. Defaults to <see cref="CultNetWriteCompletion.ReceiptResultWindow"/>; a
|
||||
/// primary that predates write receipts never answers, so forwarding to one is unconfirmed after this window.
|
||||
/// </summary>
|
||||
public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow;
|
||||
public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -92,29 +98,29 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a put and completes on the primary's result frame: a refusal throws
|
||||
/// <see cref="CultNetWriteRefusedException"/>, 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 <see cref="CultNetWriteRefusedException"/> and silence throws <see cref="CultNetWriteUnconfirmedException"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards a delete and completes on the primary's result frame: a refusal throws
|
||||
/// <see cref="CultNetWriteRefusedException"/>.
|
||||
/// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as
|
||||
/// <see cref="ForwardPutAsync"/> does.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -138,19 +144,50 @@ namespace GameCult.Networking
|
|||
return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port);
|
||||
}
|
||||
|
||||
private async Task SendAsync<T>(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<ICultNetSchemaClient> 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<T>(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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3533,7 +3533,8 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public sealed class LiteNetLibTransportConnection
|
||||
{
|
||||
private readonly NetPeer? _peer;
|
||||
private readonly Action<byte[]>? _deliver;
|
||||
private readonly Action? _end;
|
||||
private readonly CultNetTransportStats _stats = new CultNetTransportStats();
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -3541,10 +3542,99 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
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<byte[]> deliver, Action? end = null)
|
||||
{
|
||||
_deliver = deliver ?? throw new ArgumentNullException(nameof(deliver));
|
||||
_end = end;
|
||||
Profile = CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<c>session_ended</c>), never as silence.
|
||||
/// </summary>
|
||||
internal void End() => _end?.Invoke();
|
||||
|
||||
/// <summary>
|
||||
/// Answers a schema frame that did not decode with a <c>message_invalid</c> error.v1 naming its id when it holds one;
|
||||
/// when that answer cannot be sent the connection ends instead.
|
||||
/// </summary>
|
||||
internal void AnswerUnreadableSchemaFrame(byte[] payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
SendSchema(CultNetErrorV1Message.From(
|
||||
CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"),
|
||||
CultNetReadableMessageId.From(payload)));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
End();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A put or delete that arrived on an unreliable or sequenced channel of a receipt session (write-receipt.md, Reliable channel only):
|
||||
/// answered <c>message_invalid</c>, details <c>channel</c>, fixed text, carrying the write's id, and not applied. True when
|
||||
/// the frame was such a write and has been answered here.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The write-receipt state of a connection that opted in (write-receipt.md); null for every other connection, which is
|
||||
/// answered exactly as before.
|
||||
/// </summary>
|
||||
internal CultNetWriteReceiptSession? WriteReceipts { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification.
|
||||
/// </summary>
|
||||
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,40 @@ namespace GameCult.Networking
|
|||
public CultNetErrorMessage Refusal { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write nothing answered within its window: neither admitted nor refused (write-receipt.md, "unconfirmed").
|
||||
/// </summary>
|
||||
public sealed class CultNetWriteUnconfirmedException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception for the write <paramref name="messageId"/>.</summary>
|
||||
public CultNetWriteUnconfirmedException(string messageId)
|
||||
: base($"No admission or refusal arrived for write '{messageId}'.")
|
||||
{
|
||||
MessageId = messageId;
|
||||
}
|
||||
|
||||
/// <summary>Gets the id of the unconfirmed write.</summary>
|
||||
public string MessageId { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class CultNetWriteNotSentException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception for the write <paramref name="messageId"/>; <paramref name="cause"/> is for logs only.</summary>
|
||||
public CultNetWriteNotSentException(string messageId, Exception? cause = null)
|
||||
: base($"Write '{messageId}' was not sent.", cause)
|
||||
{
|
||||
MessageId = messageId;
|
||||
}
|
||||
|
||||
/// <summary>Gets the id of the write that was not sent.</summary>
|
||||
public string MessageId { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
|||
/// <summary>How long after a send a refusal may still arrive when the caller names no window.</summary>
|
||||
public static readonly TimeSpan DefaultResultWindow = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <summary>How long a receipt-capable peer has to answer a write (write-receipt.md, <c>RECEIPT_RESULT_WINDOW_MS</c>).</summary>
|
||||
public static readonly TimeSpan ReceiptResultWindow = TimeSpan.FromMilliseconds(5000);
|
||||
|
||||
/// <summary>
|
||||
/// Sends one write to a receipt-capable peer and waits <paramref name="resultWindow"/> for its answer: a
|
||||
/// <c>write_admitted</c> naming <paramref name="messageId"/> returns, an <c>error.v1</c> naming it throws
|
||||
/// <see cref="CultNetWriteRefusedException"/>, 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 <see cref="CultNetWriteUnconfirmedException"/>.
|
||||
/// </summary>
|
||||
public static async Task SendAndAwaitReceiptAsync(
|
||||
Func<Action<CultNetWriteAdmittedMessage>, Action<CultNetErrorMessage>, Action<CultNetErrorV1Message>, 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<CultNetErrorMessage?>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one write and waits <paramref name="resultWindow"/> for the peer's answer. A refusal frame rejects with
|
||||
/// <see cref="CultNetWriteRefusedException"/> whatever its order against the transport acknowledgement. A peer
|
||||
|
|
|
|||
396
src/GameCult.Networking/CultNetWriteReceipts.cs
Normal file
396
src/GameCult.Networking/CultNetWriteReceipts.cs
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
|
||||
namespace GameCult.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// The content hash a write-receipt server binds to a (peer, messageId) dedupe key
|
||||
/// (contracts/cultnet/interop/write-receipt.md, rule 5).
|
||||
/// </summary>
|
||||
public static class CultNetWriteContentHash
|
||||
{
|
||||
/// <summary>
|
||||
/// SHA-256 of the MessagePack array <c>[operation, schemaId, recordKey, payload]</c>, <c>payload</c> a <c>bin</c>
|
||||
/// for a put and nil for a delete.
|
||||
/// </summary>
|
||||
public static byte[] Compute(string operation, string schemaId, string recordKey, byte[]? payload)
|
||||
{
|
||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a peer's hello opted in to write receipts: it lists both <c>cultnet.write_admitted.v0</c> and
|
||||
/// <c>cultnet.error.v1</c>. The capability changes framing only; it never authorizes.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A SHA-256 digest held by value: the fixed-size stand-in for an id or a content hash.</summary>
|
||||
internal readonly struct CultNetDigest : IEquatable<CultNetDigest>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>The refusal, in fixed text, naming the write's own record key or schema id where its details do.</summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class CultNetWriteReceiptSession
|
||||
{
|
||||
/// <summary>The most writes a peer is remembered for, refused ones included; a peer at the limit has new writes refused.</summary>
|
||||
internal const int MaxRememberedWrites = 1024;
|
||||
|
||||
/// <summary>The most ids refused at the limit that are remembered, so their resend is judged the same way.</summary>
|
||||
internal const int MaxRememberedLimitRefusals = 1024;
|
||||
|
||||
/// <summary>The longest messageId a write may carry, in UTF-8 bytes (write-receipt.md, Id size).</summary>
|
||||
internal const int MaxMessageIdBytes = 256;
|
||||
|
||||
/// <summary>What a write is refused with when it failed for a reason no typed refusal names. Never an exception's own text.</summary>
|
||||
internal const string WriteFailedText = "the write could not be applied";
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<CultNetDigest, Entry> _writes = new();
|
||||
private readonly Dictionary<CultNetDigest, CultNetDigest> _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));
|
||||
|
||||
/// <summary>
|
||||
/// Looks <paramref name="messageId"/> up first. An id not seen is claimed for this content (<see cref="Begin.New"/>);
|
||||
/// otherwise the outcome says what already holds it, and <paramref name="refusal"/> is the refusal to repeat.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The write was admitted: remember it so a resend repeats the receipt.</summary>
|
||||
public void Admitted(string messageId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Admitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The write was refused: remember the refusal so a resend repeats it.</summary>
|
||||
public void Refused(string messageId, CultNetRememberedRefusal refusal)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Refusal = refusal;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Abandoned(string messageId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Abandoned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
internal static class CultNetReadableMessageId
|
||||
{
|
||||
/// <summary>The frame's top-level string <c>messageId</c>, or null when the bytes are not a map holding one.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5).
|
||||
/// </summary>
|
||||
internal sealed class CultNetReceiptedWrite
|
||||
{
|
||||
private readonly CultNetWriteReceiptSession _session;
|
||||
private readonly Action<ICultNetSchemaMessage> _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<ICultNetSchemaMessage> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>The write committed: remember the receipt and send it.</summary>
|
||||
public void Admitted()
|
||||
{
|
||||
if (_answered) return;
|
||||
_answered = true;
|
||||
if (_claimed) _session.Admitted(_messageId);
|
||||
_send(AdmittedReceipt());
|
||||
}
|
||||
|
||||
/// <summary>The write was refused: remember the refusal (a resend repeats it) and send its one error.v1.</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void LeftSilent()
|
||||
{
|
||||
if (_answered) return;
|
||||
_answered = true;
|
||||
}
|
||||
|
||||
/// <summary>A forward that was provably not sent (connect failed, or the send failed before any byte was written): forget the claim, send nothing.</summary>
|
||||
public void NotSent()
|
||||
{
|
||||
if (_answered) return;
|
||||
_answered = true;
|
||||
if (_claimed) _session.Abandoned(_messageId);
|
||||
_claimed = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Why the write's id cannot be used at all, before anything is remembered: it is missing (refused with no id) or longer
|
||||
/// than <see cref="CultNetWriteReceiptSession.MaxMessageIdBytes"/> UTF-8 bytes. Null when the id is usable.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|||
/// </summary>
|
||||
public bool LogSensitivePayloads { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UDP port <see cref="Start"/> binds (default 3075; 0 asks the OS for a free one). After
|
||||
/// <see cref="Start"/> it holds the port actually bound.
|
||||
/// </summary>
|
||||
public int Port { get; set; } = DefaultPort;
|
||||
|
||||
private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -74,7 +80,7 @@ namespace GameCult.Networking
|
|||
{
|
||||
TransportId = "litenetlib-server",
|
||||
Host = "0.0.0.0",
|
||||
Port = ServerPort
|
||||
Port = Port
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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<byte>(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)
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@ namespace GameCult.Networking.Tests
|
|||
CultNetDatabaseChangeKind.Added, VariantKey, schemaId, database.Shards[0], cache.Get<VariantWireFixture>(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<VariantWireFixture>(
|
||||
CultNetDatabaseChangeKind.Removed, VariantKey, schemaId, database.Shards[0], document: null, previousDocument: cache.Get<VariantWireFixture>(VariantKey)!);
|
||||
|
|
|
|||
|
|
@ -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<CultNetHelloMessage>();
|
||||
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<string>();
|
||||
Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.WriteAdmitted));
|
||||
Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.ErrorV1));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Socket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CultNetErrorMessage> ErrorsReceivedBy(Client client)
|
||||
{
|
||||
var errors = new List<CultNetErrorMessage>();
|
||||
|
|
|
|||
|
|
@ -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<ICultNetSchemaMessage> _received = new();
|
||||
public int Closed;
|
||||
|
||||
public Client(int port, string key)
|
||||
{
|
||||
var listener = new EventBasedNetListener();
|
||||
var connected = new TaskCompletionSource<bool>(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>(T message) where T : ICultNetSchemaMessage =>
|
||||
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered);
|
||||
|
||||
public void SendUnreliable<T>(T message) where T : ICultNetSchemaMessage =>
|
||||
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.Unreliable);
|
||||
|
||||
public void Send<T>(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<T>() where T : ICultNetSchemaMessage
|
||||
{
|
||||
if (!_received.TryTake(out var message, Cap)) Assert.Fail($"no {typeof(T).Name} arrived within {Cap}");
|
||||
Assert.That(message, Is.InstanceOf<T>());
|
||||
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<NetworkSchemaNote>().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<NetworkSchemaNote>(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<CultNetHelloMessage>();
|
||||
|
||||
client.SendRaw(garbage);
|
||||
|
||||
var error = client.Next<CultNetErrorV1Message>();
|
||||
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<CultNetHelloMessage>();
|
||||
receipt.SendRaw(new byte[] { 0xC1 });
|
||||
receipt.Next<CultNetErrorV1Message>();
|
||||
|
||||
receipt.Send(running.PutOf("r1", KeyA, "one"));
|
||||
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().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<CultNetErrorMessage>();
|
||||
Assert.That(refusal, Is.TypeOf<CultNetErrorMessage>());
|
||||
Assert.That(refusal.Code, Is.EqualTo("write_not_authorized"));
|
||||
legacy.Send(running.PutOf("l2", KeyA, "three"));
|
||||
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "three");
|
||||
legacy.ExpectNothingMore("a legacy peer is never sent a receipt or error.v1");
|
||||
Assert.That(running.Cache.Get<NetworkSchemaNote>(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<CultNetHelloMessage>();
|
||||
|
||||
client.SendUnreliable(running.PutOf("u1", KeyA, "lost"));
|
||||
|
||||
var error = client.Next<CultNetErrorV1Message>();
|
||||
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<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("u1"));
|
||||
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(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<CultNetHelloMessage>();
|
||||
|
||||
client.Send(running.PutOf("c1", KeyA, "text"), method);
|
||||
|
||||
if (refused)
|
||||
{
|
||||
var error = client.Next<CultNetErrorV1Message>();
|
||||
Assert.That(error.Details!.Field, Is.EqualTo("channel"));
|
||||
Assert.That(error.MessageId, Is.EqualTo("c1"));
|
||||
Assert.That(running.Cache.Get<NetworkSchemaNote>(KeyA), Is.Null);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.That(client.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("c1"));
|
||||
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "text");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADeleteOnAnUnreliableChannelOfAReceiptPeerIsRefusedWithItsId()
|
||||
{
|
||||
using var running = Running.Start();
|
||||
using var client = running.Connect();
|
||||
client.Send(HelloOf(BothFrames));
|
||||
client.Next<CultNetHelloMessage>();
|
||||
|
||||
client.SendUnreliable(new CultNetDocumentDeleteMessage
|
||||
{
|
||||
MessageId = "ud1", SchemaId = running.SchemaId, RecordKey = KeyA.Value, ShardId = ShardId, ShardEpoch = 1
|
||||
});
|
||||
|
||||
var error = client.Next<CultNetErrorV1Message>();
|
||||
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<CultNetHelloMessage>();
|
||||
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<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("r1"), "nothing else was sent ahead of the receipt");
|
||||
receipt.Send(running.PutOf("r2", KeyA, "two"));
|
||||
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().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<CultNetErrorMessage>().Code, Is.EqualTo("write_not_authorized"));
|
||||
legacy.SendUnreliable(running.PutOf("l2", KeyA, "three"));
|
||||
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(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<bool> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1272
tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs
Normal file
1272
tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<CultNetWriteUnconfirmedException>(() =>
|
||||
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<CultNetWriteUnconfirmedException>(() =>
|
||||
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<CultNetWriteRefusedException>(() =>
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue