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",