From 58cb6a2beaa8d3da4ade2affe56df8211639dae2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:47:33 +0000 Subject: [PATCH 1/3] Bump the nuget group with 4 updates Bumps dotnet-ef from 10.0.12 to 10.0.13 Bumps Microsoft.CodeAnalysis.NetAnalyzers from 10.0.401 to 10.0.402 Bumps Microsoft.NET.Test.Sdk from 18.10.1 to 18.11.0 Bumps Microsoft.VisualStudio.Threading.Analyzers from 18.7.23 to 18.12.1 --- updated-dependencies: - dependency-name: dotnet-ef dependency-version: 10.0.13 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget - dependency-name: Microsoft.CodeAnalysis.NetAnalyzers dependency-version: 10.0.402 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget - dependency-name: Microsoft.VisualStudio.Threading.Analyzers dependency-version: 18.12.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 2 +- Directory.Packages.props | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6f6d677b..437399a9 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "dotnet-ef": { - "version": "10.0.12", + "version": "10.0.13", "commands": [ "dotnet-ef" ], diff --git a/Directory.Packages.props b/Directory.Packages.props index db0dfaea..91681fd1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,7 +32,7 @@ - + @@ -42,8 +42,8 @@ - - + + From 0fc3f85531f0ccb166683354ee2ab51e6fc339c1 Mon Sep 17 00:00:00 2001 From: Valentin Clergue Date: Mon, 19 Oct 2026 18:39:03 +0200 Subject: [PATCH 2/3] fix: stop chained primes from backlogging the map status loop A_server_that_is_no_longer_connected_drops_out_of_the_periodic_tick republished every status event every 20 ms until its fence fired. The status loop is sequential and every event repaints, so each priming step left a backlog the next fence had to drain while publishing more, and a slow ubuntu runner ran past the 30 s patience. Prime once until the loop is listening, wait until every primed copy has been read, then publish each later status event exactly once. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Hosting/MapRefreshTests.cs | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/MapRefreshTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/MapRefreshTests.cs index a4f4dfda..7372ab15 100644 --- a/tests/RustPlusBot.Features.Map.Tests/Hosting/MapRefreshTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/MapRefreshTests.cs @@ -176,26 +176,29 @@ public async Task A_server_that_is_no_longer_connected_drops_out_of_the_periodic await h.Service.StartAsync(CancellationToken.None); try { - await h.PublishUntilAsync(() => new ConnectionStatusChangedEvent(Guild, Server, true, false), - h.PostsToReachesAsync(MapChannel, 1)); + // Republish only until the status loop is listening. Each status event repaints, so copies queued + // behind a slow render pile up on the sequential loop, and every later step would wait behind them. + var primed = await h.PublishUntilAsync( + () => new ConnectionStatusChangedEvent(Guild, Server, true, false), h.StatusReadsReachAsync(1)); + await h.StatusReadsReachAsync(primed).WaitAsync(Patience); - // Nothing is published here, so reaching three posts can only be the tick repainting it: the - // server really is on the tick before the disconnect under test. - await h.PostsToReachesAsync(MapChannel, 3).WaitAsync(Patience); + // Every copy has been read, so at most the last one's repaint is still in flight. Two more posts + // therefore include at least one from the tick: the server really is on the tick before the + // disconnect under test. + await h.PostsToReachesAsync(MapChannel, h.PostsTo(MapChannel) + 2).WaitAsync(Patience); // A second server stays connected throughout. Its posts are the heartbeat that proves the tick - // kept firing afterwards, which is what makes "stopped repainting" mean something. - await h.PublishUntilAsync(() => new ConnectionStatusChangedEvent(Guild, SecondServer, true, false), - h.PostsToReachesAsync(SecondMapChannel, 1)); + // kept firing afterwards, which is what makes "stopped repainting" mean something. The loop is + // known to be listening now, so one publish is enough. + await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(Guild, SecondServer, true, false)); + await h.PostsToReachesAsync(SecondMapChannel, 1).WaitAsync(Patience); var offlineReads = 0; var handled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); h.ConnectionStore.GetStateAsync(Guild, Server, Arg.Any()).Returns(_ => { - // Only reads that see the offline state count, so status events queued before the store was - // switched over cannot satisfy the fence. The status loop consumes sequentially, so a second - // offline read proves the first one's handler - the one that drops the server from the tick - - // already ran to completion. + // The status loop consumes sequentially, so the second offline read proves the first one's + // handler - the one that drops the server from the tick - already ran to completion. if (Interlocked.Increment(ref offlineReads) == 2) { handled.TrySetResult(); @@ -203,8 +206,9 @@ public async Task A_server_that_is_no_longer_connected_drops_out_of_the_periodic return Task.FromResult(null); }); - await h.PublishUntilAsync(() => new ConnectionStatusChangedEvent(Guild, Server, false, true), - handled.Task); + await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(Guild, Server, false, true)); + await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(Guild, Server, false, true)); + await handled.Task.WaitAsync(Patience); // Nothing is published from here on, so every further post comes from the tick. The tick reads // a snapshot of the connected set, so the iteration that was already in flight when the server @@ -404,6 +408,7 @@ private sealed class Harness : IDisposable private readonly List<(int Count, TaskCompletionSource Tcs)> _postTargets = []; private readonly ConcurrentDictionary _postsByChannel = new(); private readonly TaskCompletionSource _statusHandled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly List<(int Count, TaskCompletionSource Tcs)> _statusReadTargets = []; private int _baseMapFetches; private int _locatorFaults; private int _posts; @@ -431,6 +436,8 @@ private sealed class Harness : IDisposable public Task WhenStatusHandled => _statusHandled.Task; + public int StatusReads => Volatile.Read(ref _statusReads); + public void Dispose() { Service.Dispose(); @@ -526,6 +533,14 @@ public Task PostsToReachesAsync(ulong channelId, int count) => /// A task that completes when that many lookups have failed. public Task LocatorFaultsReachAsync(int count) => WaitForCountAsync(_locatorTargets, count, LocatorFaults); + /// + /// Completes once the status loop has read connection state times. Every + /// status event costs exactly one read, so this is how a test knows the loop has caught up. + /// + /// How many reads to wait for. + /// A task that completes when that many reads have happened. + public Task StatusReadsReachAsync(int count) => WaitForCountAsync(_statusReadTargets, count, StatusReads); + /// Counts one locator failure and returns the exception to throw. /// The exception the locator reports. /// The same exception, for the caller to throw. @@ -542,17 +557,21 @@ public Exception CountLocatorFault(Exception fault) /// The event type being published. /// Builds the event to publish. /// The signal that the service has done the work under test. - public async Task PublishUntilAsync(Func make, Task until) + /// How many copies were published, all of which the consumer will still handle. + public async Task PublishUntilAsync(Func make, Task until) where TEvent : notnull { + var published = 0; using var deadline = new CancellationTokenSource(Patience); while (!until.IsCompleted && !deadline.IsCancellationRequested) { await Bus.PublishAsync(make()); + published++; await Task.WhenAny(until, Task.Delay(20, deadline.Token)); } await until.WaitAsync(Patience); + return published; } /// The #map channel a server posts to; each server gets its own so posts are separable. @@ -590,7 +609,9 @@ private void OnStatusRead() { // The consume loop is sequential, so a second read proves the first status event's handler ran // to completion — which is what lets the assertions below not race it. - if (Interlocked.Increment(ref _statusReads) == 2) + var reads = Interlocked.Increment(ref _statusReads); + ReleaseReached(_statusReadTargets, reads); + if (reads == 2) { _statusHandled.TrySetResult(); } From afe414b1b4cb000299a69a68b3c3a748ced152a7 Mon Sep 17 00:00:00 2001 From: Valentin Clergue Date: Mon, 19 Oct 2026 18:52:10 +0200 Subject: [PATCH 3/3] fix: fence the drop-out test on a FIFO read, not a publish count The previous commit waited for one status read per primed copy, but the bus only delivers to existing subscribers: copies published before the status loop subscribed are dropped, so on windows-latest the read count never reached the publish count and the fence timed out. Prime until the loop is listening, then publish the second server's connect once. The loop is first-in first-out and awaits each repaint, so that read proves every queued copy ahead of it was handled. This also drops the harness additions the publish count needed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Hosting/MapRefreshTests.cs | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/tests/RustPlusBot.Features.Map.Tests/Hosting/MapRefreshTests.cs b/tests/RustPlusBot.Features.Map.Tests/Hosting/MapRefreshTests.cs index 7372ab15..93d0a132 100644 --- a/tests/RustPlusBot.Features.Map.Tests/Hosting/MapRefreshTests.cs +++ b/tests/RustPlusBot.Features.Map.Tests/Hosting/MapRefreshTests.cs @@ -169,28 +169,33 @@ public async Task A_server_that_is_no_longer_connected_drops_out_of_the_periodic // #map keeps being re-uploaded for a world the bot is no longer watching. using var h = Harness.Create(tick: TimeSpan.FromMilliseconds(20)); h.ConnectionStore.GetStateAsync(Guild, Server, Arg.Any()).Returns(Connected(Server)); - h.ConnectionStore.GetStateAsync(Guild, SecondServer, Arg.Any()) - .Returns(Connected(SecondServer)); + var secondServerRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.ConnectionStore.GetStateAsync(Guild, SecondServer, Arg.Any()).Returns(_ => + { + secondServerRead.TrySetResult(); + return Task.FromResult(Connected(SecondServer)); + }); int afterDisconnect; await h.Service.StartAsync(CancellationToken.None); try { - // Republish only until the status loop is listening. Each status event repaints, so copies queued - // behind a slow render pile up on the sequential loop, and every later step would wait behind them. - var primed = await h.PublishUntilAsync( - () => new ConnectionStatusChangedEvent(Guild, Server, true, false), h.StatusReadsReachAsync(1)); - await h.StatusReadsReachAsync(primed).WaitAsync(Patience); - - // Every copy has been read, so at most the last one's repaint is still in flight. Two more posts - // therefore include at least one from the tick: the server really is on the tick before the - // disconnect under test. - await h.PostsToReachesAsync(MapChannel, h.PostsTo(MapChannel) + 2).WaitAsync(Patience); + // Republish only until the status loop is listening. Every status event repaints, so copies + // queued behind a slow render pile up on the sequential loop, and each later step would wait + // behind them while republishing more of its own. + await h.PublishUntilAsync(() => new ConnectionStatusChangedEvent(Guild, Server, true, false), + h.WhenStatusHandled); // A second server stays connected throughout. Its posts are the heartbeat that proves the tick // kept firing afterwards, which is what makes "stopped repainting" mean something. The loop is - // known to be listening now, so one publish is enough. + // listening now, so one publish is enough, and its read is a fence: the loop is first-in + // first-out and awaits each repaint, so every copy queued ahead of it has been fully handled. await h.Bus.PublishAsync(new ConnectionStatusChangedEvent(Guild, SecondServer, true, false)); + await secondServerRead.Task.WaitAsync(Patience); + + // Nothing republishes the first server's connect any more, so its next post can only be the + // tick: the server really is on the tick before the disconnect under test. + await h.PostsToReachesAsync(MapChannel, h.PostsTo(MapChannel) + 1).WaitAsync(Patience); await h.PostsToReachesAsync(SecondMapChannel, 1).WaitAsync(Patience); var offlineReads = 0; @@ -408,7 +413,6 @@ private sealed class Harness : IDisposable private readonly List<(int Count, TaskCompletionSource Tcs)> _postTargets = []; private readonly ConcurrentDictionary _postsByChannel = new(); private readonly TaskCompletionSource _statusHandled = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly List<(int Count, TaskCompletionSource Tcs)> _statusReadTargets = []; private int _baseMapFetches; private int _locatorFaults; private int _posts; @@ -436,8 +440,6 @@ private sealed class Harness : IDisposable public Task WhenStatusHandled => _statusHandled.Task; - public int StatusReads => Volatile.Read(ref _statusReads); - public void Dispose() { Service.Dispose(); @@ -533,14 +535,6 @@ public Task PostsToReachesAsync(ulong channelId, int count) => /// A task that completes when that many lookups have failed. public Task LocatorFaultsReachAsync(int count) => WaitForCountAsync(_locatorTargets, count, LocatorFaults); - /// - /// Completes once the status loop has read connection state times. Every - /// status event costs exactly one read, so this is how a test knows the loop has caught up. - /// - /// How many reads to wait for. - /// A task that completes when that many reads have happened. - public Task StatusReadsReachAsync(int count) => WaitForCountAsync(_statusReadTargets, count, StatusReads); - /// Counts one locator failure and returns the exception to throw. /// The exception the locator reports. /// The same exception, for the caller to throw. @@ -557,21 +551,17 @@ public Exception CountLocatorFault(Exception fault) /// The event type being published. /// Builds the event to publish. /// The signal that the service has done the work under test. - /// How many copies were published, all of which the consumer will still handle. - public async Task PublishUntilAsync(Func make, Task until) + public async Task PublishUntilAsync(Func make, Task until) where TEvent : notnull { - var published = 0; using var deadline = new CancellationTokenSource(Patience); while (!until.IsCompleted && !deadline.IsCancellationRequested) { await Bus.PublishAsync(make()); - published++; await Task.WhenAny(until, Task.Delay(20, deadline.Token)); } await until.WaitAsync(Patience); - return published; } /// The #map channel a server posts to; each server gets its own so posts are separable. @@ -609,9 +599,7 @@ private void OnStatusRead() { // The consume loop is sequential, so a second read proves the first status event's handler ran // to completion — which is what lets the assertions below not race it. - var reads = Interlocked.Increment(ref _statusReads); - ReleaseReached(_statusReadTargets, reads); - if (reads == 2) + if (Interlocked.Increment(ref _statusReads) == 2) { _statusHandled.TrySetResult(); }