diff --git a/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs b/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs
index b9c6f3c6..2bf691a6 100644
--- a/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs
+++ b/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs
@@ -217,10 +217,30 @@ public sealed class CultMeshQuicRealtimeServer : IAsyncDisposable
CancellationToken cancellationToken = default)
{
if (frame == null) throw new ArgumentNullException(nameof(frame));
- var sends = _clients.Keys.Select(client => client.SendAsync(frame, cancellationToken)).ToArray();
+ var sends = _clients.Keys.Select(client => SendToConnectedClientAsync(client, frame, cancellationToken)).ToArray();
if (sends.Length > 0) await Task.WhenAll(sends).ConfigureAwait(false);
}
+ private async Task SendToConnectedClientAsync(
+ CultMeshQuicRealtimeTransport client,
+ CultMeshRealtimeFrame frame,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await client.SendAsync(frame, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ if (_clients.TryRemove(client, out _))
+ client.Dispose();
+ }
+ }
+
/// Receives the next client-originated state frame.
public Task ReceiveAsync(CancellationToken cancellationToken = default) =>
_received.ReceiveAsync(cancellationToken);
diff --git a/src/GameCult.Mesh/docs/transport-planes.md b/src/GameCult.Mesh/docs/transport-planes.md
index 219df88a..2d529593 100644
--- a/src/GameCult.Mesh/docs/transport-planes.md
+++ b/src/GameCult.Mesh/docs/transport-planes.md
@@ -49,6 +49,10 @@ advertised mapping when it is actually reachable; otherwise it connects to the
advertised QUIC route. Latest-only receivers must replace pending generations
per body rather than queueing render debt.
+Provider broadcast isolates physical peers. A client disconnect or send failure
+removes that session without failing the publication tick for healthy clients;
+only caller cancellation stops the broadcast operation.
+
**Derived state:** connector choice, physical endpoint, connection health, and
transport diagnostics. None of these grant provider authority or become content
identity.
diff --git a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs
index f46f833e..b491d912 100644
--- a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs
+++ b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs
@@ -78,6 +78,46 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests
.WithMessage("*certificate*pin*");
}
+ [Test]
+ public async Task NativeClientDepartureCannotBreakProviderBroadcast()
+ {
+ if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported)
+ Assert.Ignore("Native MsQuic integration requires Windows QUIC support.");
+ using var certificate = CreateCertificate();
+ await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
+ {
+ ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
+ ServerCertificate = certificate
+ });
+ var pin = Convert.ToHexString(SHA256.HashData(certificate.RawData));
+ var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}";
+ var connector = new CultMeshNativeQuicRealtimeTransportConnector();
+ var client = await connector.ConnectAsync(
+ new CultMeshTransportCandidate(endpoint),
+ CultMeshEndpointId.Parse("aetheria.local"));
+ await WaitUntilAsync(() => server.ConnectionCount == 1);
+ await server.BroadcastAsync(Frame(sequence: 1));
+ using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await client.ReceiveAsync(receiveTimeout.Token);
+
+ client.Dispose();
+
+ Func broadcastAfterDeparture = () => server.BroadcastAsync(Frame(sequence: 2));
+ await broadcastAfterDeparture.Should().NotThrowAsync(
+ "a short-lived realtime observer cannot terminate provider publication");
+ }
+
+ private static CultMeshRealtimeFrame Frame(long sequence) => new()
+ {
+ ChannelId = "aetheria.entities",
+ SchemaId = "gamecult.aetheria.entity_soa.v1",
+ BodyId = "aetheria.entities.current-zone",
+ ProducerEpoch = 17,
+ Sequence = sequence,
+ Delivery = CultMeshRealtimeDelivery.LatestOnly,
+ Payload = new byte[] { 1, 3, 3, 7 }
+ };
+
private static async Task WaitUntilAsync(Func predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md
index bd5bb268..136c38ab 100644
--- a/unity/org.gamecult.cultlib/README.md
+++ b/unity/org.gamecult.cultlib/README.md
@@ -9,10 +9,10 @@ runtime; the Microsoft license is included under `Third Party Notices`.
Build it with `scripts/build-unity-package.ps1`. Do not edit generated plugin
assemblies in the package output.
-Install version `1.0.36` through Unity Package Manager with:
+Install version `1.0.37` through Unity Package Manager with:
```text
-https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.36
+https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.37
```
CultMesh chooses mapped memory for reachable same-machine bodies and advertised
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll
index 9cfcb1e1..9512f6b3 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb
index b7893d1a..d932026c 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll
index 251eddf7..575b4d98 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb
index 9885f367..9cd13875 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll
index 0ea237f8..6dbc721f 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb
index 848a61b5..2c47c069 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll
index 2c28b0fb..288cbd05 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb
index 535ffbcf..515a7472 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll
index 5024debc..3aac9d60 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb
index feb471bb..54429321 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
index dd68bd10..a27f022d 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb
index 45da0cfd..3bef8c59 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb differ
diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json
index 743a4920..08930273 100644
--- a/unity/org.gamecult.cultlib/package.json
+++ b/unity/org.gamecult.cultlib/package.json
@@ -1,7 +1,7 @@
{
"name": "org.gamecult.cultlib",
"displayName": "CultLib",
- "version": "1.0.36",
+ "version": "1.0.37",
"unity": "2021.3",
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
"license": "MIT",