diff --git a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs index 12744bee..49dc303f 100644 --- a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs +++ b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs @@ -197,7 +197,11 @@ namespace GameCult.Caching.Tests Assert.That(leaf.TryGetIndexNumber(tiny, "tiny_double", out var tinyNumber), Is.True); Assert.That(tinyNumber, Does.Not.Contain("E")); - Assert.That(tinyNumber, Is.EqualTo("0.0000001")); + // R-D (docs/cultnet-selection-cut.md): the exact decimal expansion, not a shortest + // round-trip form. 1e-7 is not exactly representable as a double; its nearest double's + // exact value is this - the old assertion pinned "0.0000001", the shortest form a + // pre-R-D render would have produced instead. + Assert.That(tinyNumber, Is.EqualTo("0.0000000999999999999999954748111825886258685613938723690807819366455078125")); } [Test] diff --git a/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs b/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs index d179acef..76f2c488 100644 --- a/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs @@ -32,6 +32,12 @@ namespace GameCult.Networking.Tests [TestCase(" 1")] [TestCase("1.")] [TestCase("1..5")] + // R-D/Q-J (Soul's whole-cut pass, confirmed): .NET's $ matches immediately before a single + // trailing newline even without RegexOptions.Multiline, so a naive `$`-anchored pattern accepts + // "5\n" as canonical - the door must refuse it exactly as it refuses any other non-canonical + // spelling, byte for byte the same as Rust and the JSON schema's ECMA-262 $. + [TestCase("5\n")] + [TestCase("5\r\n")] public void IsCanonicalRefusesNonCanonicalSpellings(string value) => Assert.That(CultNetCanonicalNumber.IsCanonical(value), Is.False); diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs new file mode 100644 index 00000000..0f1f20a9 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs @@ -0,0 +1,150 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // R-I (docs/cultnet-selection-cut.md): SelectPage/CreateSelectionResponse had no test at all before + // this cut's fix batch, and Soul's whole-cut pass found two untested mutants survive 196/196: + // dropping `wantDocument &&` in ToEdge (a header-projection edge would then carry the document's + // payload bytes anyway), and always emitting Edges instead of `selection.HasHop ? ... : null` (a + // hop-less selection's page would then carry an empty Edges array instead of no Edges member). + public sealed class CultNetSelectionPageTests + { + private static (CultCache cache, CultNetDocumentRegistry documents) BuildFixture() + { + var cache = new CultCache(); + var documents = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)) + .Register(CultNetDocumentBinding.ForDocument(cache.Registry)); + return (cache, documents); + } + + private static async Task AddLeafAsync(CultCache cache, string key, string kind) + { + var recordKey = new CultRecordKey(key); + await cache.AddAsync( + new PageFixtureLeaf { Name = key, Kind = kind }, + new CultRecordHandle(recordKey)); + return recordKey; + } + + // Kills Soul's ToEdge mutant (CultNetDocumentRegistry.cs, ~:468): dropping `wantDocument &&` + // from `if (wantDocument && edge.Payload != null)` would leak the dictionary edge's payload + // bytes into a header-projection page, which carries no payloads by contract. + [Test] + public async Task CreateSelectionResponse_UnderHeaderProjection_CarriesNoEdgePayload() + { + var (cache, documents) = BuildFixture(); + var target = await AddLeafAsync(cache, "leaf-target", "weapon"); + await cache.AddAsync( + new PageFixtureCiter { Name = "citer-1", Components = new Dictionary, float> { [new CultRecordRef(target)] = 2.5f } }, + new CultRecordHandle(new CultRecordKey("citer-1"))); + + var selection = new CultNetSelection + { + Cited = new CultNetIncoming { Role = "Components", Exists = true }, + Projection = CultNetSelectionProjections.Header + }; + var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1); + + Assert.That(response.Edges, Is.Not.Null.And.Not.Empty, "a hop-bearing selection with a match must carry the edge it traversed"); + foreach (var edge in response.Edges!) + { + Assert.That(edge.PayloadEncoding, Is.Null, "header projection must not carry an edge's payload encoding"); + Assert.That(edge.Payload, Is.Null, "header projection must not carry an edge's payload bytes"); + } + } + + // The positive case for the same rule: document projection does carry the payload. + [Test] + public async Task CreateSelectionResponse_UnderDocumentProjection_CarriesEdgePayload() + { + var (cache, documents) = BuildFixture(); + var target = await AddLeafAsync(cache, "leaf-target", "weapon"); + await cache.AddAsync( + new PageFixtureCiter { Name = "citer-1", Components = new Dictionary, float> { [new CultRecordRef(target)] = 2.5f } }, + new CultRecordHandle(new CultRecordKey("citer-1"))); + + var selection = new CultNetSelection + { + Cited = new CultNetIncoming { Role = "Components", Exists = true }, + Projection = CultNetSelectionProjections.Document + }; + var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1); + + Assert.That(response.Edges, Is.Not.Null.And.Not.Empty); + Assert.That(response.Edges![0].PayloadEncoding, Is.EqualTo("messagepack")); + Assert.That(response.Edges![0].Payload, Is.Not.Null.And.Not.Empty); + } + + // Kills Soul's SelectPage/CreateSelectionResponse mutant (CultNetDocumentRegistry.cs, ~:378): + // always emitting `Edges = ...` instead of `selection.HasHop ? ... : null` would give a + // hop-less selection's page an empty Edges array (Is.Empty) instead of no Edges member at all + // (Is.Null) - a mutant a shape-only "no edges reported" assertion cannot tell apart from the rule. + [Test] + public async Task CreateSelectionResponse_WithoutAHop_CarriesNoEdgesMember() + { + var (cache, documents) = BuildFixture(); + await AddLeafAsync(cache, "leaf-only", "weapon"); + + var selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document }; + var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1); + + Assert.That(response.Documents, Has.Length.EqualTo(1)); + Assert.That(response.Edges, Is.Null); + } + + // SelectPage/CreateSelectionResponse's own paging and matched-total contract (R-G/C5): a + // limited page reports the whole matching count, not the page's row count, and carries a + // cursor while more rows remain. + [Test] + public async Task CreateSelectionResponse_ReportsTotalMatchedNotPageLength() + { + var (cache, documents) = BuildFixture(); + await AddLeafAsync(cache, "leaf-1", "weapon"); + await AddLeafAsync(cache, "leaf-2", "weapon"); + await AddLeafAsync(cache, "leaf-3", "weapon"); + + var selection = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } }, + Limit = 2 + }; + var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: static (_, _) => 1, asOf: 1); + + Assert.That(response.Headers, Has.Length.EqualTo(2), "the page itself is capped at the limit"); + Assert.That(response.Matched, Is.EqualTo(3), "matched is the selection's whole matching count, not this page's length"); + Assert.That(response.Next, Is.Not.Null.And.Not.Empty, "a cursor is minted while more rows remain"); + } + + [CultDocument("selection.page-fixture.leaf", "selection.page-fixture.leaf.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class PageFixtureLeaf + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("kind")] + public string Kind = string.Empty; + } + + [CultDocument("selection.page-fixture.citer", "selection.page-fixture.citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class PageFixtureCiter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultReference(typeof(PageFixtureLeaf), many: true)] + public Dictionary, float> Components = new(); + } + } +} diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index e75f9d2a..1251065d 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -1471,6 +1471,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { async Task FetchOnce(string messageId) { @@ -1488,8 +1493,12 @@ namespace GameCult.Networking.Tests await FetchOnce("first"); await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); await FetchOnce("second"); - cancellation.Cancel(); - serverThread.Join(); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } // A batch is a value: nothing staged in it reaches CultNet observers before the store commits it. A put made @@ -1551,6 +1560,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("database-subscription-client"); var subscribed = new TaskCompletionSource( @@ -1577,13 +1591,16 @@ namespace GameCult.Networking.Tests Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); - serverThread.Join(); Assert.That(update.SubscriptionId, Is.EqualTo("note")); Assert.That(update.ChangeKind, Is.EqualTo("added")); Assert.That(update.Document, Is.Not.Null); Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey)); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -1611,6 +1628,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { await database.PutAsync(new CultRecordKey("tests:public:initial"), new NetworkSchemaNote { @@ -1655,11 +1677,14 @@ namespace GameCult.Networking.Tests Text = "public-live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); - serverThread.Join(); Assert.That(update.Document, Is.Not.Null); Assert.That(update.Document!.RecordKey, Is.EqualTo("tests:public:live")); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -1698,6 +1723,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -1717,6 +1747,12 @@ namespace GameCult.Networking.Tests supportedBodyTransports: [CultMeshBodyTransportKind.SharedMemory.ToString()]), TimeSpan.FromSeconds(2)); Assert.That(targetCache.Get(new CultRecordKey(recordKeyOne)), Is.Not.Null); + // R-K: PublishDemand runs after the snapshot is sent, on the server thread, while + // SubscribeAsync's completion is driven by the client's receipt of that same snapshot on a + // different thread - asserting immediately races the two. Wait for the deterministic + // condition (the demand actually being published) instead of assuming order-of-completion + // across a network round trip. + await WaitUntilAsync(() => bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2)); Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.True); // Drive the record's identity change through the selection itself @@ -1744,6 +1780,8 @@ namespace GameCult.Networking.Tests await WaitUntilAsync( () => targetCache.Get(new CultRecordKey(recordKeyTwo)) == null, TimeSpan.FromSeconds(2)); + // R-K: same race as the HasConsumers=true assertion above, the other direction. + await WaitUntilAsync(() => !bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2)); Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.False); var changeCountAfterRevocation = changes.Count; await sourceDatabase.PutAsync(new CultRecordKey(recordKeyTwo), new NetworkSchemaNote @@ -1752,11 +1790,14 @@ namespace GameCult.Networking.Tests Text = "must-not-escape" }); await Task.Delay(100); - cancellation.Cancel(); - serverThread.Join(); - Assert.That(changes.Count, Is.EqualTo(changeCountAfterRevocation)); Assert.That(targetCache.Get(new CultRecordKey(recordKeyTwo)), Is.Null); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } // Reconcile's remaining responsibility after the S2-2 deletion (docs/cultnet-selection-cut.md, @@ -1874,6 +1915,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("database-body-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); @@ -1904,10 +1950,14 @@ namespace GameCult.Networking.Tests SubscriptionId = "world-body" }); var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2)); - cancellation.Cancel(); - serverThread.Join(); Assert.That(removed.ConsumerRuntimeId, Is.EqualTo("eve-unity")); Assert.That(removed.Active, Is.False); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } // A load's delivery reaches a subscription observer that needs the lifecycle gate while a subscribe's demand @@ -1958,6 +2008,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("demand-write-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); @@ -1974,7 +2029,14 @@ namespace GameCult.Networking.Tests cancellation.Cancel(); Assert.That(finished, Is.True, "the demand handler's cache write deadlocked with the load's delivery"); await written.Task; - serverThread.Join(); + } + finally + { + // Cancel/Join before disposing anything the poll thread touches (server), so a failed + // assertion above cannot race the thread against a disposed socket (R-K). + cancellation.Cancel(); + serverThread.Join(); + } subscriptions.Dispose(); server.Dispose(); database.Dispose(); @@ -2038,6 +2100,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("database-reactive-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); @@ -2053,11 +2120,15 @@ namespace GameCult.Networking.Tests }); var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2)); - cancellation.Cancel(); - serverThread.Join(); Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:field:fog" })); Assert.That(observed.SchemaIds, Is.EqualTo(new[] { "gamecult.fields.splats.v1" })); Assert.That(observed.BodyIds, Is.Empty); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2089,6 +2160,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { using var client = CultNetSchemaClients.CreateRudp("database-disconnect-demand-client"); client.Connect("127.0.0.1", server.LocalEndPoint.Port); @@ -2107,11 +2183,14 @@ namespace GameCult.Networking.Tests client.Dispose(); var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2)); - cancellation.Cancel(); - serverThread.Join(); - Assert.That(removed.SubscriptionId, Is.EqualTo("disconnect-world-body")); Assert.That(removed.Active, Is.False); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2141,6 +2220,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2164,12 +2248,15 @@ namespace GameCult.Networking.Tests Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); - serverThread.Join(); Assert.That(update.SubscriptionId, Is.EqualTo("notes")); Assert.That(update.Document, Is.TypeOf()); Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live")); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2199,6 +2286,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2225,11 +2317,14 @@ namespace GameCult.Networking.Tests Text = "live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); - serverThread.Join(); Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("live")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2254,6 +2349,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2290,11 +2390,14 @@ namespace GameCult.Networking.Tests Text = "updated" }); update = await AwaitWithTimeout(updated.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); - serverThread.Join(); Assert.That(update.Text, Is.EqualTo("updated")); Assert.That(value.Current.Text, Is.EqualTo("updated")); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2328,6 +2431,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2341,11 +2449,14 @@ namespace GameCult.Networking.Tests client.SubscribeLiveValueAsync("demanded-note", recordKey), TimeSpan.FromSeconds(2)); await WaitUntilAsync(() => value.HasValue, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); - serverThread.Join(); Assert.That(value.Current.Text, Is.EqualTo("materialized-on-demand")); Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2376,6 +2487,11 @@ namespace GameCult.Networking.Tests } }) { IsBackground = true }; serverThread.Start(); + // R-K: the join moves into finally - a failed assertion below must not leave this poll + // thread running past the point where `using` disposes the socket it polls, which turns a + // test failure into an unhandled-exception host crash. + try + { var targetCache = new CultCache(); var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry) @@ -2396,11 +2512,14 @@ namespace GameCult.Networking.Tests Text = "wire-live" }); var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2)); - - cancellation.Cancel(); - serverThread.Join(); Assert.That(update.SchemaId, Is.EqualTo(wireSchema)); Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("wire-live")); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2430,16 +2549,29 @@ namespace GameCult.Networking.Tests var method = typeof(CultNetDatabaseSubscriptionServer).GetMethod( "CreateMatchedRecord", BindingFlags.Instance | BindingFlags.NonPublic)!; + var requestType = typeof(CultNetDatabaseSubscriptionServer).GetNestedType( + "SubscriptionRequest", BindingFlags.NonPublic)!; + var requestCtor = requestType.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single(); + var request = requestCtor.Invoke(new object?[] + { + new CultNetSelection + { + Schemas = ["tests.networking_note.v1"], + Keys = ["tests:subscription-client:alias-note"] + }, + "alias-notes", + "alias-notes", + true, + null, + null, + null, + false + }); var record = (CultNetRawDocumentRecord?)method.Invoke(subscriptions, new object?[] { change, - new CultNetDatabaseSubscribeMessage - { - SubscriptionId = "alias-notes", - SchemaIds = ["tests.networking_note.v1"], - RecordKeys = ["tests:subscription-client:alias-note"] - }, + request, null }); @@ -2494,6 +2626,8 @@ namespace GameCult.Networking.Tests catch (Exception error) { serverFailure.TrySetResult(error); } }) { IsBackground = true }; serverThread.Start(); + try + { using var client = CultNetSchemaClients.CreateRudp( runtimeId: "csharp-rudp-large-snapshot-client", @@ -2516,12 +2650,16 @@ namespace GameCult.Networking.Tests await WaitUntilAsync( () => server.Peers.Count == 1 && server.Peers.Single().PendingReliablePacketCount == 0, TimeSpan.FromSeconds(2)); - cancellation.Cancel(); - serverThread.Join(); Assert.That(response.Documents, Has.Length.EqualTo(1)); Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024)); Assert.That(server.Stats.BytesSent, Is.LessThan(512 * 1024), "A lossless large response must not remain trapped in the reliable resend queue."); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } } [Test] @@ -2555,6 +2693,8 @@ namespace GameCult.Networking.Tests catch (Exception error) { serverFailure.TrySetResult(error); } }) { IsBackground = true }; serverThread.Start(); + try + { using var client = CultNetSchemaClients.CreateRudp( runtimeId: "csharp-rudp-concurrent-publish-client", @@ -2597,8 +2737,12 @@ namespace GameCult.Networking.Tests if (completed == serverFailure.Task) throw await serverFailure.Task; if (completed != receivedAll.Task) Assert.Fail($"Concurrent publish timed out after receiving {receivedIds.Count} of {expectedMessages} logical messages."); - cancellation.Cancel(); - serverThread.Join(); + } + finally + { + cancellation.Cancel(); + serverThread.Join(); + } Assert.That(receivedIds.Count, Is.EqualTo(expectedMessages)); }