Fix flaky GalaxyHierarchyRefreshServiceTests timing race
ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFault BackgroundService cancelled the service immediately after StartAsync, so under parallel load the first RefreshAsync could be skipped (RefreshCallCount 0) and `await executeTask` rethrew TaskCanceledException before the IsFaulted assertion. The test now waits for a TaskCompletionSource signal that the first refresh was attempted before cancelling, and uses Task.WhenAny so a Canceled ExecuteTask does not rethrow. Confirmed stable across full-suite runs (408/408). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,13 +22,23 @@ public sealed class GalaxyHierarchyRefreshServiceTests
|
|||||||
using CancellationTokenSource cts = new();
|
using CancellationTokenSource cts = new();
|
||||||
|
|
||||||
await service.StartAsync(cts.Token);
|
await service.StartAsync(cts.Token);
|
||||||
|
|
||||||
|
// Wait until the first RefreshAsync has actually been attempted (and
|
||||||
|
// thrown) before cancelling, so cancellation cannot race ahead of the
|
||||||
|
// first-load path under test — this is what made the test flaky under
|
||||||
|
// parallel load.
|
||||||
|
await cache.FirstRefreshAttempted.WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
await cts.CancelAsync();
|
await cts.CancelAsync();
|
||||||
|
|
||||||
// The background loop must have stopped cleanly: ExecuteTask completes
|
// The background loop must have stopped cleanly: ExecuteTask reaches a
|
||||||
// (RanToCompletion or Canceled) rather than faulting on the first refresh.
|
// terminal state that is not Faulted (RanToCompletion or Canceled)
|
||||||
|
// rather than faulting on the first refresh. WhenAny is used so a
|
||||||
|
// Canceled task does not rethrow before the IsFaulted assertion.
|
||||||
Task? executeTask = service.ExecuteTask;
|
Task? executeTask = service.ExecuteTask;
|
||||||
Assert.NotNull(executeTask);
|
Assert.NotNull(executeTask);
|
||||||
await executeTask;
|
Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||||
|
Assert.Same(executeTask, completed);
|
||||||
Assert.False(executeTask.IsFaulted);
|
Assert.False(executeTask.IsFaulted);
|
||||||
Assert.Equal(1, cache.RefreshCallCount);
|
Assert.Equal(1, cache.RefreshCallCount);
|
||||||
|
|
||||||
@@ -49,13 +59,20 @@ public sealed class GalaxyHierarchyRefreshServiceTests
|
|||||||
|
|
||||||
private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache
|
private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache
|
||||||
{
|
{
|
||||||
|
private readonly TaskCompletionSource firstRefreshAttempted =
|
||||||
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
public int RefreshCallCount { get; private set; }
|
public int RefreshCallCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Completes once <see cref="RefreshAsync"/> has been invoked at least once.</summary>
|
||||||
|
public Task FirstRefreshAttempted => firstRefreshAttempted.Task;
|
||||||
|
|
||||||
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
|
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
|
||||||
|
|
||||||
public Task RefreshAsync(CancellationToken cancellationToken)
|
public Task RefreshAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
RefreshCallCount++;
|
RefreshCallCount++;
|
||||||
|
firstRefreshAttempted.TrySetResult();
|
||||||
throw toThrow;
|
throw toThrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user