Skip to content

Commit 9b2b957

Browse files
HandyS11claude
andcommitted
fix(test): deflake InfoMapHostedService tests (publish-before-subscribe race)
Ready_key_publishes_InfoMapReadyEvent_once_per_requester failed on the Linux CI runner with 0 events received. Root cause: the test collected events by subscribing to the real InMemoryEventBus from a Task.Run — that bus drops events published before a subscriber is active, and the service's first tick (10 ms poll) can announce the key before the background subscription starts on a loaded runner. The key is announced at most once, so the events are gone for good and the test times out at 0. Replace the racy live-bus collector with a small CapturingEventBus fake that records every published InfoMapReadyEvent synchronously — deterministic, and the tests now finish in ~170 ms instead of riding the 5 s deadline. The Connect_registers test keeps the real bus (it re-publishes in a loop, so the subscribe race self-heals there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e2d069c commit 9b2b957

1 file changed

Lines changed: 38 additions & 23 deletions

File tree

tests/RustPlusBot.Features.Map.Tests/Hosting/InfoMapHostedServiceTests.cs

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using Microsoft.Extensions.DependencyInjection;
23
using Microsoft.Extensions.Logging.Abstractions;
34
using Microsoft.Extensions.Options;
@@ -36,6 +37,17 @@ private static IOptions<MapOptions> ShortPollOptions() => Options.Create(new Map
3637
}
3738
});
3839

40+
/// <summary>
41+
/// A fake bus that records every published <see cref="InfoMapReadyEvent"/>. Deterministic, unlike
42+
/// subscribing to the real <see cref="InMemoryEventBus"/> from a background task: that bus drops
43+
/// events published before the subscriber is active, which races the service's first tick.
44+
/// </summary>
45+
private static (IEventBus Bus, ConcurrentQueue<InfoMapReadyEvent> Received) CapturingBus()
46+
{
47+
var bus = new CapturingEventBus();
48+
return (bus, bus.Received);
49+
}
50+
3951
[Fact]
4052
public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester()
4153
{
@@ -55,16 +67,10 @@ public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester()
5567
var driver = new RustMapsGenerationDriver(client, coordinator, NullLogger<RustMapsGenerationDriver>.Instance);
5668

5769
var query = Substitute.For<IRustServerQuery>();
58-
var bus = new InMemoryEventBus();
59-
var received = new List<InfoMapReadyEvent>();
60-
using var collectCts = new CancellationTokenSource();
61-
_ = Task.Run(async () =>
62-
{
63-
await foreach (var evt in bus.SubscribeAsync<InfoMapReadyEvent>(collectCts.Token))
64-
{
65-
received.Add(evt);
66-
}
67-
});
70+
// Capture publishes off a substituted bus: the real InMemoryEventBus drops events published
71+
// before a subscriber is active, so collecting via a background subscription races the
72+
// service's first tick and flakes on slow CI runners.
73+
var (bus, received) = CapturingBus();
6874

6975
var service = new InfoMapHostedService(
7076
bus, coordinator, driver, query, ShortPollOptions(),
@@ -82,7 +88,6 @@ public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester()
8288
finally
8389
{
8490
await service.StopAsync(CancellationToken.None);
85-
await collectCts.CancelAsync();
8691
}
8792

8893
Assert.Equal(2, received.Count);
@@ -120,16 +125,7 @@ public async Task Tick_registers_and_generates_connected_servers_without_any_con
120125
IReadOnlyList<(ulong GuildId, Guid ServerId)> connectable = [(GuildA, serverId)];
121126
store.ListConnectableServersAsync(Arg.Any<CancellationToken>()).Returns(connectable);
122127

123-
var bus = new InMemoryEventBus();
124-
var received = new List<InfoMapReadyEvent>();
125-
using var collectCts = new CancellationTokenSource();
126-
_ = Task.Run(async () =>
127-
{
128-
await foreach (var evt in bus.SubscribeAsync<InfoMapReadyEvent>(collectCts.Token))
129-
{
130-
received.Add(evt);
131-
}
132-
});
128+
var (bus, received) = CapturingBus();
133129

134130
var service = new InfoMapHostedService(
135131
bus, coordinator, driver, query, ShortPollOptions(),
@@ -139,15 +135,14 @@ public async Task Tick_registers_and_generates_connected_servers_without_any_con
139135
try
140136
{
141137
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
142-
while (DateTimeOffset.UtcNow < deadline && received.Count < 1)
138+
while (DateTimeOffset.UtcNow < deadline && received.IsEmpty)
143139
{
144140
await Task.Delay(20);
145141
}
146142
}
147143
finally
148144
{
149145
await service.StopAsync(CancellationToken.None);
150-
await collectCts.CancelAsync();
151146
}
152147

153148
// No ConnectionStatusChangedEvent was ever published — registration came from the store tick.
@@ -206,4 +201,24 @@ public async Task Connect_registers_the_servers_world_key_with_the_coordinator()
206201
Assert.Contains(Key, coordinator.PendingKeys());
207202
Assert.Contains((GuildA, serverId), coordinator.Requesters(Key));
208203
}
204+
205+
private sealed class CapturingEventBus : IEventBus
206+
{
207+
public ConcurrentQueue<InfoMapReadyEvent> Received { get; } = new();
208+
209+
public ValueTask PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
210+
where TEvent : notnull
211+
{
212+
if (@event is InfoMapReadyEvent ready)
213+
{
214+
Received.Enqueue(ready);
215+
}
216+
217+
return ValueTask.CompletedTask;
218+
}
219+
220+
public IAsyncEnumerable<TEvent> SubscribeAsync<TEvent>(CancellationToken cancellationToken = default)
221+
where TEvent : notnull =>
222+
AsyncEnumerable.Empty<TEvent>(); // No live subscriptions needed: assertions read Received.
223+
}
209224
}

0 commit comments

Comments
 (0)