@@ -38,15 +38,11 @@ private static IOptions<MapOptions> ShortPollOptions() => Options.Create(new Map
3838 } ) ;
3939
4040 /// <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.
41+ /// A fake bus that records every published <see cref="InfoMapReadyEvent"/> and signals each arrival.
42+ /// Deterministic, unlike subscribing to the real <see cref="InMemoryEventBus"/> from a background task:
43+ /// that bus drops events published before the subscriber is active, which races the service's first tick.
4444 /// </summary>
45- private static ( IEventBus Bus , ConcurrentQueue < InfoMapReadyEvent > Received ) CapturingBus ( )
46- {
47- var bus = new CapturingEventBus ( ) ;
48- return ( bus , bus . Received ) ;
49- }
45+ private static CapturingEventBus CapturingBus ( ) => new ( ) ;
5046
5147 [ Fact ]
5248 public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester ( )
@@ -70,7 +66,8 @@ public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester()
7066 // Capture publishes off a substituted bus: the real InMemoryEventBus drops events published
7167 // before a subscriber is active, so collecting via a background subscription races the
7268 // service's first tick and flakes on slow CI runners.
73- var ( bus , received ) = CapturingBus ( ) ;
69+ using var bus = CapturingBus ( ) ;
70+ var received = bus . Received ;
7471
7572 var service = new InfoMapHostedService (
7673 bus , coordinator , driver , query , ShortPollOptions ( ) ,
@@ -79,11 +76,7 @@ public async Task Ready_key_publishes_InfoMapReadyEvent_once_per_requester()
7976 await service . StartAsync ( CancellationToken . None ) ;
8077 try
8178 {
82- var deadline = DateTimeOffset . UtcNow . AddSeconds ( 5 ) ;
83- while ( DateTimeOffset . UtcNow < deadline && received . Count < 2 )
84- {
85- await Task . Delay ( 20 ) ;
86- }
79+ await bus . WaitForAsync ( 2 ) ;
8780 }
8881 finally
8982 {
@@ -125,7 +118,8 @@ public async Task Tick_registers_and_generates_connected_servers_without_any_con
125118 IReadOnlyList < ( ulong GuildId , Guid ServerId ) > connectable = [ ( GuildA , serverId ) ] ;
126119 store . ListConnectableServersAsync ( Arg . Any < CancellationToken > ( ) ) . Returns ( connectable ) ;
127120
128- var ( bus , received ) = CapturingBus ( ) ;
121+ using var bus = CapturingBus ( ) ;
122+ var received = bus . Received ;
129123
130124 var service = new InfoMapHostedService (
131125 bus , coordinator , driver , query , ShortPollOptions ( ) ,
@@ -134,11 +128,7 @@ public async Task Tick_registers_and_generates_connected_servers_without_any_con
134128 await service . StartAsync ( CancellationToken . None ) ;
135129 try
136130 {
137- var deadline = DateTimeOffset . UtcNow . AddSeconds ( 5 ) ;
138- while ( DateTimeOffset . UtcNow < deadline && received . IsEmpty )
139- {
140- await Task . Delay ( 20 ) ;
141- }
131+ await bus . WaitForAsync ( 1 ) ;
142132 }
143133 finally
144134 {
@@ -179,19 +169,19 @@ public async Task Connect_registers_the_servers_world_key_with_the_coordinator()
179169 GenerationPollInterval = TimeSpan . FromSeconds ( 30 ) // must not need to fire for this test to pass
180170 }
181171 } ) ;
172+ // Signals the registration instead of polling for it: a poll loop needs the thread pool to take its
173+ // next turn, so on a saturated CI runner the whole deadline can elapse inside one Task.Delay.
174+ var registered = new SignallingCoordinator ( coordinator ) ;
182175 var service = new InfoMapHostedService (
183- bus , coordinator , driver , query , pollOptions ,
176+ bus , registered , driver , query , pollOptions ,
184177 ScopeFactory ( connectionStore ) , NullLogger < InfoMapHostedService > . Instance ) ;
185178
186179 await service . StartAsync ( CancellationToken . None ) ;
187180 try
188181 {
189- var deadline = DateTimeOffset . UtcNow . AddSeconds ( 5 ) ;
190- while ( DateTimeOffset . UtcNow < deadline && coordinator . PendingKeys ( ) . Count == 0 )
191- {
192- await bus . PublishAsync ( new ConnectionStatusChangedEvent ( GuildA , serverId , true , false ) ) ;
193- await Task . Delay ( 20 ) ;
194- }
182+ // Published exactly once: StartAsync subscribes before it returns, so no event can be dropped.
183+ await bus . PublishAsync ( new ConnectionStatusChangedEvent ( GuildA , serverId , true , false ) ) ;
184+ await registered . Registered . WaitAsync ( TimeSpan . FromSeconds ( 30 ) ) ;
195185 }
196186 finally
197187 {
@@ -202,16 +192,51 @@ public async Task Connect_registers_the_servers_world_key_with_the_coordinator()
202192 Assert . Contains ( ( GuildA , serverId ) , coordinator . Requesters ( Key ) ) ;
203193 }
204194
205- private sealed class CapturingEventBus : IEventBus
195+ /// <summary>Forwards to a real coordinator and completes <see cref="Registered"/> on the first Register.</summary>
196+ /// <param name="inner">The coordinator that holds the real state.</param>
197+ private sealed class SignallingCoordinator ( IRustMapsMapCoordinator inner ) : IRustMapsMapCoordinator
198+ {
199+ private readonly TaskCompletionSource _registered =
200+ new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
201+
202+ public Task Registered => _registered . Task ;
203+
204+ public void Register ( RustMapsMapKey key , ulong guildId , Guid serverId )
205+ {
206+ inner . Register ( key , guildId , serverId ) ;
207+ _registered . TrySetResult ( ) ;
208+ }
209+
210+ public RustMapsMapSnapshot Snapshot ( RustMapsMapKey key ) => inner . Snapshot ( key ) ;
211+
212+ public bool TrySetGenerating ( RustMapsMapKey key , string ? mapId ) => inner . TrySetGenerating ( key , mapId ) ;
213+
214+ public void SetReady ( RustMapsMapKey key , RustMapsReadyMap ready ) => inner . SetReady ( key , ready ) ;
215+
216+ public void SetFailed ( RustMapsMapKey key ) => inner . SetFailed ( key ) ;
217+
218+ public void SetLimitReached ( RustMapsMapKey key ) => inner . SetLimitReached ( key ) ;
219+
220+ public IReadOnlyList < RustMapsMapKey > PendingKeys ( ) => inner . PendingKeys ( ) ;
221+
222+ public IReadOnlyList < ( ulong Guild , Guid Server ) > Requesters ( RustMapsMapKey key ) => inner . Requesters ( key ) ;
223+ }
224+
225+ private sealed class CapturingEventBus : IEventBus , IDisposable
206226 {
227+ private readonly SemaphoreSlim _published = new ( 0 ) ;
228+
207229 public ConcurrentQueue < InfoMapReadyEvent > Received { get ; } = new ( ) ;
208230
231+ public void Dispose ( ) => _published . Dispose ( ) ;
232+
209233 public ValueTask PublishAsync < TEvent > ( TEvent @event , CancellationToken cancellationToken = default )
210234 where TEvent : notnull
211235 {
212236 if ( @event is InfoMapReadyEvent ready )
213237 {
214238 Received . Enqueue ( ready ) ;
239+ _published . Release ( ) ;
215240 }
216241
217242 return ValueTask . CompletedTask ;
@@ -220,5 +245,27 @@ public ValueTask PublishAsync<TEvent>(TEvent @event, CancellationToken cancellat
220245 public IAsyncEnumerable < TEvent > SubscribeAsync < TEvent > ( CancellationToken cancellationToken = default )
221246 where TEvent : notnull =>
222247 AsyncEnumerable . Empty < TEvent > ( ) ; // No live subscriptions needed: assertions read Received.
248+
249+ /// <summary>
250+ /// Waits for <paramref name="count"/> events. Signal-based rather than a poll loop: polling needs the
251+ /// thread pool to take its next turn, so on a saturated runner a whole wall-clock deadline can elapse
252+ /// inside a single Task.Delay. A timeout here is not asserted on — the caller's assertions report it.
253+ /// </summary>
254+ /// <param name="count">How many events to wait for.</param>
255+ public async Task WaitForAsync ( int count )
256+ {
257+ using var timeout = new CancellationTokenSource ( TimeSpan . FromSeconds ( 30 ) ) ;
258+ try
259+ {
260+ for ( var i = 0 ; i < count ; i ++ )
261+ {
262+ await _published . WaitAsync ( timeout . Token ) ;
263+ }
264+ }
265+ catch ( OperationCanceledException )
266+ {
267+ // Timed out; the caller asserts on what actually arrived.
268+ }
269+ }
223270 }
224271}
0 commit comments