diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt
index 10cc2a58..7d052785 100644
--- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt
+++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt
@@ -808,6 +808,7 @@ interface CultNetSchemaMessageTransport : AutoCloseable {
val profile: CultNetTransportProfile
val stats: CultNetTransportStats
+ /** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun sendSchemaMessage(message: CultNetMessage)
fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage?
@@ -3167,13 +3168,16 @@ class CultNetRudpSocketTransportConnection(
return connected
}
+ /** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun send(channelId: String, payload: ByteArray) {
session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) }
framesSent += 1
}
+ /** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8))
+ /** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun sendSchema(payload: ByteArray) = send("schema", payload)
fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8))
diff --git a/packages/cultnet-py/src/cultnet_py/client.py b/packages/cultnet-py/src/cultnet_py/client.py
index 28060753..35a960e3 100644
--- a/packages/cultnet-py/src/cultnet_py/client.py
+++ b/packages/cultnet-py/src/cultnet_py/client.py
@@ -58,6 +58,7 @@ class CultNetRawClient:
create_transport: CultNetSchemaTransportFactory | None = None
def send(self, message: CultNetMessage | dict[str, Any]) -> None:
+ """Fire-and-forget: an ack or a return is not admission; read the result frame."""
wire = message.to_wire() if isinstance(message, CultNetMessage) else message
with self.open_transport() as transport:
transport.send("schema", msgpack.packb(wire, use_bin_type=True))
@@ -319,6 +320,7 @@ class CultNetDatabaseSubscription:
self._transport = None
def send(self, message: CultNetMessage | dict[str, Any]) -> None:
+ """Fire-and-forget: an ack or a return is not admission; read the result frame."""
if self._transport is None:
raise RuntimeError("CultNet database subscription is not open")
wire = message.to_wire() if isinstance(message, CultNetMessage) else message
diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py
index 3ecfa31b..801d1313 100644
--- a/packages/cultnet-py/src/cultnet_py/transport.py
+++ b/packages/cultnet-py/src/cultnet_py/transport.py
@@ -1256,6 +1256,7 @@ class CultNetRudpSocketTransportConnection:
self._send_packet(self.session.create_connect(_now_ms(), payload))
def send(self, channel_id: str, payload: bytes) -> None:
+ """Fire-and-forget: an ack or a return is not admission; read the result frame."""
packets = self.session.send_many(
channel_id,
payload,
@@ -1567,6 +1568,7 @@ class TcpFramedTransportConnection:
)
def send(self, channel_id: str, payload: bytes) -> None:
+ """Fire-and-forget: an ack or a return is not admission; read the result frame."""
if channel_id != "schema":
raise ValueError(f"tcp_framed transport only supports the schema channel, got {channel_id!r}")
before = len(payload) + 4
diff --git a/src/GameCult.Mesh/CultMeshClient.cs b/src/GameCult.Mesh/CultMeshClient.cs
index 5db03ca5..5e8ef38d 100644
--- a/src/GameCult.Mesh/CultMeshClient.cs
+++ b/src/GameCult.Mesh/CultMeshClient.cs
@@ -44,6 +44,12 @@ namespace GameCult.Mesh
/// Gets or sets the deadline for one correlated typed operation response.
public TimeSpan OperationResponseTimeout { get; set; } = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Gets or sets how long after a submitted document the provider's refusal may still arrive. The provider
+ /// sends no success frame for an admitted submission, so this window bounds SubmitDocumentAsync.
+ ///
+ public TimeSpan SubmitResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow;
}
///
@@ -149,6 +155,7 @@ namespace GameCult.Mesh
private readonly CultMeshSessionManager _sessions;
private readonly TimeSpan _subscriptionResponseTimeout;
private readonly TimeSpan _operationResponseTimeout;
+ private readonly TimeSpan _submitResultWindow;
private readonly ConcurrentDictionary _documents = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _collections = new(StringComparer.Ordinal);
private bool _disposed;
@@ -191,6 +198,7 @@ namespace GameCult.Mesh
options.Sessions);
_subscriptionResponseTimeout = options.SubscriptionResponseTimeout;
_operationResponseTimeout = options.OperationResponseTimeout;
+ _submitResultWindow = options.SubmitResultWindow;
}
/// Connects to one stable Verse/provider target using one application protocol.
@@ -319,7 +327,10 @@ namespace GameCult.Mesh
///
/// Submits one typed document to a provider by stable identity. Submission is uncommitted input;
- /// only a provider-authored receipt or resulting state can establish acceptance.
+ /// only a provider-authored receipt or resulting state can establish acceptance. The task completes on
+ /// the provider's result frame: a refusal (write_not_authorized, message_invalid, ...) throws
+ /// , and a submission not refused within
+ /// completes.
///
public async Task SubmitDocumentAsync(
CultMeshSessionTarget target,
@@ -351,7 +362,11 @@ namespace GameCult.Mesh
var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
- session.OpenSchemaClient().SendCultNet(message);
+ await CultNetWriteCompletion.SendAndAwaitResultAsync(
+ refuse => session.OnCultNet(refuse),
+ () => session.SendCultNet(message),
+ _submitResultWindow,
+ cancellationToken).ConfigureAwait(false);
}
///
diff --git a/src/GameCult.Mesh/CultMeshSessions.cs b/src/GameCult.Mesh/CultMeshSessions.cs
index 9768dfd5..9e06c196 100644
--- a/src/GameCult.Mesh/CultMeshSessions.cs
+++ b/src/GameCult.Mesh/CultMeshSessions.cs
@@ -352,6 +352,7 @@ namespace GameCult.Mesh
internal long PhysicalGeneration => Interlocked.Read(ref _physicalGeneration);
public Observable WatchState() => _states;
public ICultNetSchemaClient OpenSchemaClient() => new SessionSchemaClient(this);
+ /// Fire-and-forget: an ack or a return is not admission; read the result frame.
public void SendCultNet(T message) where T : ICultNetSchemaMessage => Channel.SendCultNet(message);
public IDisposable OnCultNet(Action callback) where T : ICultNetSchemaMessage
{
diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs
index 5c6117aa..83865e31 100644
--- a/src/GameCult.Networking/CultNetSchemaClient.cs
+++ b/src/GameCult.Networking/CultNetSchemaClient.cs
@@ -26,7 +26,8 @@ namespace GameCult.Networking
void Connect(string host, int port);
///
- /// Sends one schema-v0 message through the transport.
+ /// Sends one schema-v0 message through the transport. Fire-and-forget: an ack or a return is not admission;
+ /// read the result frame ().
///
void SendCultNet(T message)
where T : ICultNetSchemaMessage;
diff --git a/src/GameCult.Networking/CultNetShardWriteForwarding.cs b/src/GameCult.Networking/CultNetShardWriteForwarding.cs
index 7038b0a7..0fa6c4f7 100644
--- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs
+++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs
@@ -68,6 +68,12 @@ namespace GameCult.Networking
/// rudp:// endpoints use the C# RUDP schema client and cultnet:// endpoints use LiteNetLib.
///
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 .
+ ///
+ public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow;
}
///
@@ -85,7 +91,10 @@ namespace GameCult.Networking
_options = options ?? new CultNetSchemaWriteForwarderOptions();
}
- ///
+ ///
+ /// Forwards a put and completes on the primary's result frame: a refusal throws
+ /// , the same failure path as an unreachable primary.
+ ///
public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
@@ -95,7 +104,10 @@ namespace GameCult.Networking
await SendAsync(endpoint, message).ConfigureAwait(false);
}
- ///
+ ///
+ /// Forwards a delete and completes on the primary's result frame: a refusal throws
+ /// .
+ ///
public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
@@ -132,7 +144,14 @@ namespace GameCult.Networking
using var client = CreateClient(endpoint);
client.Connect(host, port);
await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false);
- client.SendCultNet(message);
+ await CultNetWriteCompletion.SendAndAwaitResultAsync(
+ refuse =>
+ {
+ client.OnCultNet(refuse);
+ return null;
+ },
+ () => client.SendCultNet(message),
+ _options.ResultWindow).ConfigureAwait(false);
}
private ICultNetSchemaClient CreateClient(string endpoint)
diff --git a/src/GameCult.Networking/CultNetWriteCompletion.cs b/src/GameCult.Networking/CultNetWriteCompletion.cs
new file mode 100644
index 00000000..6d5c226d
--- /dev/null
+++ b/src/GameCult.Networking/CultNetWriteCompletion.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace GameCult.Networking
+{
+ ///
+ /// A peer refused a write: the typed cultnet.error.v0 it answered with.
+ ///
+ public sealed class CultNetWriteRefusedException : Exception
+ {
+ /// Creates the exception from the refusal frame.
+ public CultNetWriteRefusedException(CultNetErrorMessage refusal)
+ : base(refusal?.Error ?? throw new ArgumentNullException(nameof(refusal)))
+ {
+ Code = refusal.Code;
+ Refusal = refusal;
+ }
+
+ /// Gets the machine-readable refusal code, e.g. write_not_authorized or message_invalid.
+ public string? Code { get; }
+
+ /// Gets the refusal frame as received.
+ public CultNetErrorMessage Refusal { 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.
+ ///
+ public static class CultNetWriteCompletion
+ {
+ /// How long after a send a refusal may still arrive when the caller names no window.
+ public static readonly TimeSpan DefaultResultWindow = TimeSpan.FromMilliseconds(500);
+
+ ///
+ /// Sends one write and waits for the peer's answer. A refusal frame rejects with
+ /// whatever its order against the transport acknowledgement. A peer
+ /// that admits a write sends no success frame, so the end of the window is the interim bound on a refusal
+ /// (until peers send one); the error frame carries no message id, so any refusal inside the window is this
+ /// write's.
+ ///
+ /// Registers the refusal handler and returns its registration; called before the send.
+ /// Sends the write.
+ /// How long a refusal may take to arrive.
+ /// Cancels the wait.
+ public static async Task SendAndAwaitResultAsync(
+ Func, IDisposable?> subscribe,
+ Action send,
+ TimeSpan resultWindow,
+ CancellationToken cancellationToken = default)
+ {
+ if (subscribe == null) throw new ArgumentNullException(nameof(subscribe));
+ if (send == null) throw new ArgumentNullException(nameof(send));
+ var refused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var registration = subscribe(error => refused.TrySetResult(error));
+ send();
+ var window = Task.Delay(resultWindow, cancellationToken);
+ await Task.WhenAny(refused.Task, window).ConfigureAwait(false);
+ if (refused.Task.IsCompleted) throw new CultNetWriteRefusedException(await refused.Task.ConfigureAwait(false));
+ await window.ConfigureAwait(false);
+ }
+ }
+}
diff --git a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs
index dcabf6e6..9b6e3db7 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs
@@ -291,6 +291,51 @@ public sealed class CultMeshClientTests
submitted.Document.SourceRole.Should().Be("pilot");
}
+ [TestCase("write_not_authorized", 0)]
+ [TestCase("message_invalid", 0)]
+ [TestCase("write_not_authorized", 150)]
+ public async Task SubmitDocument_ARefusalRejectsWithTheTypedError_WhateverItsOrderAgainstTheSend(string code, int delayMs)
+ {
+ var connector = new DocumentConnector
+ {
+ RefuseSubmissionsWith = code,
+ RefusalDelay = TimeSpan.FromMilliseconds(delayMs)
+ };
+ using var mesh = new CultMeshClient(new CultMeshClientOptions
+ {
+ RendezvousEndpoints = new[] { "rudp://odin:3076" },
+ Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
+ Sessions = CultMeshTestTrust.LocalSessions,
+ Connectors = new[] { connector }
+ });
+ var request = new ClientTestDocument { Id = "command:1", Text = "move" };
+
+ var act = () => mesh.SubmitDocumentAsync(Target, request.Id, request, "pilot-one", "pilot");
+
+ (await act.Should().ThrowAsync()).Which.Code.Should().Be(code);
+ }
+
+ [Test]
+ public async Task SubmitDocument_CompletesOnlyAfterTheResultWindow_NotOnTheSendReturning()
+ {
+ var connector = new DocumentConnector();
+ using var mesh = new CultMeshClient(new CultMeshClientOptions
+ {
+ RendezvousEndpoints = new[] { "rudp://odin:3076" },
+ Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
+ Sessions = CultMeshTestTrust.LocalSessions,
+ Connectors = new[] { connector },
+ SubmitResultWindow = TimeSpan.FromMilliseconds(400)
+ });
+ var request = new ClientTestDocument { Id = "command:1", Text = "move" };
+ await mesh.ConnectAsync(Target, CultMeshProtocols.Documents);
+ var started = DateTime.UtcNow;
+
+ await mesh.SubmitDocumentAsync(Target, request.Id, request, "pilot-one", "pilot");
+
+ (DateTime.UtcNow - started).Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380));
+ }
+
[Test]
public async Task InvokeOperation_CorrelatesProviderReceiptThroughStableIdentitySession()
{
@@ -496,6 +541,8 @@ public sealed class CultMeshClientTests
public bool SuppressFirstSnapshot { get; set; }
public bool SuppressFirstSubscriptionSnapshot { get; set; }
public bool RejectOperations { get; set; }
+ public string? RefuseSubmissionsWith { get; set; }
+ public TimeSpan RefusalDelay { get; set; }
public bool FailFirstOperationBeforeResponse { get; set; }
public string ResponseSourceRuntimeId { get; set; } = "aetheria-daemon";
public string ConnectorId => "document-test";
@@ -515,6 +562,8 @@ public sealed class CultMeshClientTests
rejectOperations: RejectOperations,
failOperationBeforeResponse: FailFirstOperationBeforeResponse && Clients.Count == 0,
responseSourceRuntimeId: ResponseSourceRuntimeId);
+ client.RefuseSubmissionsWith = RefuseSubmissionsWith;
+ client.RefusalDelay = RefusalDelay;
Clients.Add(client);
return Task.FromResult(client);
}
@@ -522,6 +571,9 @@ public sealed class CultMeshClientTests
private sealed class DocumentClient : ICultNetSchemaClient, ICultNetSchemaClientHealth, ICultMeshVerifiedSchemaClient
{
+ private readonly List> _errors = new();
+ public string? RefuseSubmissionsWith { get; set; }
+ public TimeSpan RefusalDelay { get; set; }
private readonly List> _snapshots = new();
private readonly List> _operationResponses = new();
private readonly CultNetDocumentRegistry _documents;
@@ -558,6 +610,15 @@ public sealed class CultMeshClientTests
if (message is CultNetDocumentPutRawMessage submitted)
{
Submitted.Add(submitted);
+ if (RefuseSubmissionsWith != null)
+ {
+ var refusal = new CultNetErrorMessage { Error = "not allowed", Code = RefuseSubmissionsWith };
+ // A refusal that follows the send's return (and any transport ack) must still reject the submit.
+ Task.Delay(RefusalDelay).ContinueWith(_ =>
+ {
+ foreach (var handler in _errors.ToArray()) handler(refusal);
+ });
+ }
return;
}
if (message is CultNetOperationRequestMessage operation)
@@ -642,6 +703,8 @@ public sealed class CultMeshClientTests
_snapshots.Add(response => callback((T)(object)response));
if (typeof(T) == typeof(CultNetOperationResponseMessage))
_operationResponses.Add(response => callback((T)(object)response));
+ if (typeof(T) == typeof(CultNetErrorMessage))
+ _errors.Add(error => callback((T)(object)error));
}
public void Dispose() { }
public void Fail(Exception error) => _failure.TrySetResult(error);
diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs
index 5a1f570f..2a75b5c2 100644
--- a/tests/GameCult.Networking.Tests/NetworkingTests.cs
+++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs
@@ -5213,6 +5213,52 @@ namespace GameCult.Networking.Tests
Assert.That(sent.ShardEpoch, Is.EqualTo(3));
}
+ [TestCase("write_not_authorized", 0)]
+ [TestCase("message_invalid", 0)]
+ [TestCase("write_not_authorized", 150)]
+ public void CultNetSchemaWriteForwarder_RejectsWithTheTypedRefusal_WhateverItsOrderAgainstTheSend(string code, int delayMs)
+ {
+ var fake = new CapturingSchemaClient(_ => null);
+ var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
+ {
+ CreateClient = () => fake,
+ ResultWindow = TimeSpan.FromSeconds(2)
+ });
+ var shard = new CultNetShardDescriptor(
+ "notes", "primary", 3, isPrimary: false,
+ primaryEndpoints: ["cultnet://primary.example.test:4075"]);
+ fake.OnSend = () =>
+ {
+ var refusal = new CultNetErrorMessage { Error = "refused", Code = code };
+ if (delayMs == 0) fake.Deliver(refusal);
+ else Task.Delay(delayMs).ContinueWith(_ => fake.Deliver(refusal));
+ };
+
+ var put = Assert.ThrowsAsync(() => forwarder.ForwardPutAsync(shard, new CultNetDocumentPutRawMessage()));
+ Assert.That(put!.Code, Is.EqualTo(code));
+ var delete = Assert.ThrowsAsync(() => forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" }));
+ Assert.That(delete!.Code, Is.EqualTo(code));
+ }
+
+ [Test]
+ public async Task CultNetSchemaWriteForwarder_DoesNotCompleteOnTheSendReturning()
+ {
+ var fake = new CapturingSchemaClient(_ => null);
+ var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
+ {
+ CreateClient = () => fake,
+ ResultWindow = TimeSpan.FromMilliseconds(400)
+ });
+ var shard = new CultNetShardDescriptor(
+ "notes", "primary", 3, isPrimary: false,
+ primaryEndpoints: ["cultnet://primary.example.test:4075"]);
+ var started = DateTime.UtcNow;
+
+ await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" });
+
+ Assert.That(DateTime.UtcNow - started, Is.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380)));
+ }
+
[Test]
public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets()
{
@@ -7458,6 +7504,14 @@ namespace GameCult.Networking.Tests
_respond = respond;
}
+ public Action? OnSend { get; set; }
+
+ public void Deliver(ICultNetSchemaMessage message)
+ {
+ if (!_handlers.TryGetValue(message.GetType(), out var handlers)) return;
+ foreach (var handler in handlers.ToArray()) handler.DynamicInvoke(message);
+ }
+
public bool Connected { get; private set; }
public string? ConnectedHost { get; private set; }
public int ConnectedPort { get; private set; }
@@ -7474,6 +7528,7 @@ namespace GameCult.Networking.Tests
where T : ICultNetSchemaMessage
{
SentMessages.Add(message);
+ OnSend?.Invoke();
var response = _respond(message);
if (response == null)
{