118 lines
5.9 KiB
C#
118 lines
5.9 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins the teardown ordering of the two self-polling pages (<c>/fleet</c>, <c>/hosts</c>).
|
|
/// Both schedule their refresh as <c>_ = InvokeAsync(...)</c> from a <see cref="System.Threading.Timer"/>
|
|
/// callback, so <c>await _timer.DisposeAsync()</c> awaits only the SCHEDULING of a tick, never the
|
|
/// tick itself: a query can still be running against <c>_cts.Token</c> after that await returns.
|
|
/// Three properties follow, and none of them is visible in a passing render test.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Source scans rather than rendering assertions because the repo has no bUnit (same technique as
|
|
/// <c>HostsPanelWiringTests</c>). A scan proves the shape of the teardown, not that a real circuit
|
|
/// tears down cleanly — the fix itself is inspection-verified against the disposal race.
|
|
/// </remarks>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class PollTimerTeardownOrderingTests
|
|
{
|
|
private static string ReadPageSource(string fileName)
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
|
dir = dir.Parent;
|
|
dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
|
|
|
|
return File.ReadAllText(Path.Combine(
|
|
dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI",
|
|
"Components", "Pages", fileName));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The CTS must be cancelled BEFORE the timer is drained. Reversed, a tick that is in flight
|
|
/// during teardown runs its queries to completion against a component that is already gone
|
|
/// instead of aborting on a cancelled token.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("Fleet.razor")]
|
|
[InlineData("Hosts.razor")]
|
|
public void The_page_cancels_its_token_source_before_draining_the_poll_timer(string page)
|
|
{
|
|
var source = ReadPageSource(page);
|
|
|
|
var cancelIndex = source.IndexOf("_cts.Cancel();", StringComparison.Ordinal);
|
|
var drainIndex = source.IndexOf("_timer.DisposeAsync();", StringComparison.Ordinal);
|
|
|
|
cancelIndex.ShouldBeGreaterThan(-1, $"{page} no longer cancels its page-lifetime CancellationTokenSource");
|
|
drainIndex.ShouldBeGreaterThan(-1, $"{page} no longer drains its poll timer on dispose");
|
|
cancelIndex.ShouldBeLessThan(drainIndex,
|
|
$"{page} must cancel before draining — a tick in flight has to observe cancellation, not " +
|
|
"run its queries out against a torn-down component");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposing the CTS is gated on the overlap guard. Ungated it can be disposed out from under a
|
|
/// still-running tick, which converts the clean <c>OperationCanceledException</c> the cancel
|
|
/// above arranged into an <c>ObjectDisposedException</c> thrown mid-query.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("Fleet.razor")]
|
|
[InlineData("Hosts.razor")]
|
|
public void The_page_only_disposes_its_token_source_when_no_tick_holds_it(string page)
|
|
{
|
|
ReadPageSource(page).ShouldContain(
|
|
"if (Volatile.Read(ref _tickRunning) == 0) _cts.Dispose();",
|
|
Case.Sensitive,
|
|
customMessage:
|
|
$"{page} disposes its CancellationTokenSource without checking the overlap guard — " +
|
|
"the timer callback discards its InvokeAsync task, so DisposeAsync awaited only the " +
|
|
"scheduling of the tick and a query may still hold the token");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <c>StateHasChanged</c> in the refresh <c>finally</c> sits outside the try, so it has no
|
|
/// catch to fall into; rendering into a disposed renderer throws inside a task nobody awaits.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("Fleet.razor")]
|
|
[InlineData("Hosts.razor")]
|
|
public void The_page_skips_the_teardown_render_once_disposed(string page)
|
|
{
|
|
ReadPageSource(page).ShouldContain(
|
|
"if (!_disposed) StateHasChanged();",
|
|
Case.Sensitive,
|
|
customMessage:
|
|
$"{page}'s finally-block StateHasChanged is unguarded — it is outside the try, so a " +
|
|
"render against a torn-down circuit escapes into a discarded task");
|
|
}
|
|
|
|
/// <summary>
|
|
/// THE regression pin for the /hosts swallow-and-clear: an <c>ObjectDisposedException</c> raised
|
|
/// by teardown must rethrow as a clean exit, never reach the generic catch that blanks
|
|
/// <c>_nodes</c>/<c>_instances</c>. That blanking is permanent — a torn-down page never ticks
|
|
/// again to refill them — and its own comment forbids it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_hosts_config_load_rethrows_a_disposal_race_instead_of_blanking_its_cache()
|
|
{
|
|
var source = ReadPageSource("Hosts.razor");
|
|
|
|
var rethrowIndex = source.IndexOf(
|
|
"catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)",
|
|
StringComparison.Ordinal);
|
|
// Anchored on the warning line, not on `_nodes = new();` — that assignment also appears as a
|
|
// field initializer far earlier in the file, which would make the ordering check vacuous.
|
|
var blankIndex = source.IndexOf(
|
|
"/hosts: failed to load driver-instance config", StringComparison.Ordinal);
|
|
|
|
rethrowIndex.ShouldBeGreaterThan(-1,
|
|
"/hosts LoadConfigAsync no longer rethrows the teardown exceptions — an ObjectDisposedException " +
|
|
"from disposing _cts under a live tick falls into the generic catch and blanks the cached config");
|
|
blankIndex.ShouldBeGreaterThan(-1, "/hosts LoadConfigAsync no longer has the swallow-and-clear fallback");
|
|
rethrowIndex.ShouldBeLessThan(blankIndex,
|
|
"the rethrow filter must precede the blanking catch, or it never runs");
|
|
}
|
|
}
|