9110a4eb01
The host does not guarantee IHostedService.StopAsync is driven before the DI container is disposed — WebApplicationFactory's teardown reaches Dispose first — so cancelling the internal CTS from StopAsync threw ObjectDisposedException and aborted the host's whole shutdown sequence. Four services shared the same copy-pasted lifecycle and the same two races: StopAsync cancelling an already- disposed CTS, and StartAsync reading _cts.Token lazily inside the Task.Run lambda, which faults the loop task the host awaits when Dispose wins that race. Each service now captures the token on the caller's thread, tolerates a disposed CTS, and cancels-before-disposing so the loop is always signalled and its pending Task.Delay sees a cancelled token rather than a dead source. SiteAuditBacklogReporter also gains the outer OperationCanceledException guard its sibling SiteAuditRetentionService already carried (arch-review 04 R2, R7), without which a shutdown landing mid-probe threw TaskCanceledException out of Host.StopAsync. Surfaced while verifying the Gitea #15 test-harness fix: in Host.Tests the aborted teardown skipped the fixture's env-var restore, contaminating every later test in the run. Refs: Gitea #15
119 lines
4.5 KiB
C#
119 lines
4.5 KiB
C#
using System.Diagnostics;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
|
|
|
|
/// <summary>
|
|
/// Deferred-work #21: the backlog reporter's poll cadence is configurable via
|
|
/// <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/> instead of the
|
|
/// old hard-coded 30 s constant.
|
|
/// </summary>
|
|
public class SiteAuditBacklogReporterCadenceTests
|
|
{
|
|
private static SiteAuditBacklogReporter Create(
|
|
IOptions<SqliteAuditWriterOptions>? options, TimeSpan? explicitInterval = null) =>
|
|
new(
|
|
Substitute.For<ISiteAuditQueue>(),
|
|
Substitute.For<ISiteHealthCollector>(),
|
|
NullLogger<SiteAuditBacklogReporter>.Instance,
|
|
explicitInterval,
|
|
options);
|
|
|
|
[Fact]
|
|
public async Task StopAsync_WhileProbeInFlight_LoopCompletesCleanly()
|
|
{
|
|
// SafeProbeAsync rethrows OperationCanceledException by design so a shutdown
|
|
// aborts the probe promptly — but RunLoopAsync must absorb it, because StopAsync
|
|
// hands _loop straight to the host and a canceled task there throws out of
|
|
// Host.StopAsync. Mirrors the guard SiteAuditRetentionService already carries
|
|
// (arch-review 04 round 2, R7).
|
|
var probeStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var queue = Substitute.For<ISiteAuditQueue>();
|
|
queue.GetBacklogStatsAsync(Arg.Any<CancellationToken>())
|
|
.Returns(ci => BlockUntilCancelledAsync(probeStarted, ci.Arg<CancellationToken>()));
|
|
|
|
var reporter = new SiteAuditBacklogReporter(
|
|
queue,
|
|
Substitute.For<ISiteHealthCollector>(),
|
|
NullLogger<SiteAuditBacklogReporter>.Instance,
|
|
TimeSpan.FromHours(1),
|
|
null);
|
|
|
|
await reporter.StartAsync(CancellationToken.None);
|
|
await probeStarted.Task; // the immediate first probe is now in flight
|
|
|
|
// The assertion is that awaiting the loop task does not throw.
|
|
await reporter.StopAsync(CancellationToken.None);
|
|
reporter.Dispose();
|
|
}
|
|
|
|
private static async Task<SiteAuditBacklogSnapshot> BlockUntilCancelledAsync(
|
|
TaskCompletionSource started, CancellationToken ct)
|
|
{
|
|
started.TrySetResult();
|
|
await Task.Delay(Timeout.Infinite, ct);
|
|
throw new UnreachableException("the delay above always throws on cancellation");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StopAsync_AfterDispose_DoesNotThrow()
|
|
{
|
|
// Regression (Gitea #15 follow-up): Dispose tears down the CTS StopAsync
|
|
// cancels, and the host does not guarantee StopAsync is driven before the DI
|
|
// container is disposed. Cancel() on a disposed CTS throws, and letting that
|
|
// escape an IHostedService aborts the host's whole shutdown sequence.
|
|
var reporter = Create(Options.Create(new SqliteAuditWriterOptions()), TimeSpan.FromHours(1));
|
|
|
|
await reporter.StartAsync(CancellationToken.None);
|
|
reporter.Dispose();
|
|
|
|
// The assertion is the absence of ObjectDisposedException.
|
|
await reporter.StopAsync(CancellationToken.None);
|
|
reporter.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public void Cadence_ComesFromOptions_WhenConfigured()
|
|
{
|
|
var options = Options.Create(new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = 12 });
|
|
|
|
var reporter = Create(options);
|
|
|
|
Assert.Equal(TimeSpan.FromSeconds(12), reporter.RefreshInterval);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cadence_FallsBackToDefault_WhenOptionsNonPositive()
|
|
{
|
|
var options = Options.Create(new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = 0 });
|
|
|
|
var reporter = Create(options);
|
|
|
|
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cadence_FallsBackToDefault_WhenNoOptions()
|
|
{
|
|
var reporter = Create(options: null);
|
|
|
|
Assert.Equal(SiteAuditBacklogReporter.DefaultRefreshInterval, reporter.RefreshInterval);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExplicitInterval_WinsOverOptions()
|
|
{
|
|
var options = Options.Create(new SqliteAuditWriterOptions { BacklogPollIntervalSeconds = 12 });
|
|
|
|
var reporter = Create(options, explicitInterval: TimeSpan.FromSeconds(3));
|
|
|
|
Assert.Equal(TimeSpan.FromSeconds(3), reporter.RefreshInterval);
|
|
}
|
|
}
|