fix(core): resolve Low code-review findings (Core-004,008,009,010,011,012)
- Core-004: add ConfigureAwait(false) to DriverHost.RegisterAsync / UnregisterAsync / DisposeAsync. - Core-008: rewrite the BuildAddressSpaceAsync XML doc to correctly name the caller (OpcUaApplicationHost.PopulateAddressSpaces) that owns the per-driver isolation. - Core-009: snapshot DriverResilienceOptions once per non-idempotent write in CapabilityInvoker.ExecuteWriteAsync. - Core-010: switch DriverResilienceOptions.Resolve to TryGetValue with a diagnostic error message when a tier table is missing a capability. - Core-011: add an optional diagnostic callback to PermissionTrieBuilder so production callers can surface scope-path mismatches. - Core-012: correct the stale WedgeDetector ctor summary and add the Reconnecting row to DriverHealthReport's state matrix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Core-011 regression coverage for <see cref="PermissionTrieBuilder.Build"/>'s
|
||||
/// <c>Descend</c> helper:
|
||||
/// <list type="bullet">
|
||||
/// <item>With a <c>scopePaths</c> lookup the row must land at the correct multi-level
|
||||
/// trie node — a deep <see cref="NodeAclScopeKind.UnsLine"/> grant must be visible
|
||||
/// ONLY when the requested scope walks the same namespace/area/line chain.</item>
|
||||
/// <item>Without a <c>scopePaths</c> entry the row falls back to a direct child of
|
||||
/// the namespace root keyed on the row's <c>ScopeId</c>. The builder must surface
|
||||
/// this fallback (warning callback) so callers know a grant was placed where the
|
||||
/// walker can't reach it for production hierarchies — silently dropping the grant
|
||||
/// is the Core-011 production hazard.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PermissionTrieBuilderTests
|
||||
{
|
||||
private static NodeAcl Row(string group, NodeAclScopeKind scope, string? scopeId, NodePermissions flags, string clusterId = "c1") =>
|
||||
new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}",
|
||||
GenerationId = 1,
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = group,
|
||||
ScopeKind = scope,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static NodeScope EquipmentTag(string cluster, string ns, string area, string line, string equip, string tag) =>
|
||||
new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = ns,
|
||||
UnsAreaId = area,
|
||||
UnsLineId = line,
|
||||
EquipmentId = equip,
|
||||
TagId = tag,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
|
||||
{
|
||||
// Scope path mirrors the production hierarchy: namespace → area → line.
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["line-42"] = new(new[] { "ns", "area-1", "line-42" }),
|
||||
};
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
||||
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths);
|
||||
|
||||
// Walk through the same chain — the grant must be reachable.
|
||||
var matchOnLine = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area-1", "line-42", "eq-A", "tag-A"),
|
||||
["cn=ops"]);
|
||||
matchOnLine.Count.ShouldBe(1, "row must land at the correct multi-level trie node");
|
||||
|
||||
// A different line under the same area must not pick up the grant.
|
||||
var matchOnOtherLine = trie.CollectMatches(
|
||||
EquipmentTag("c1", "ns", "area-1", "line-99", "eq-A", "tag-A"),
|
||||
["cn=ops"]);
|
||||
matchOnOtherLine.ShouldBeEmpty(
|
||||
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
|
||||
{
|
||||
// Fallback path — deterministic tests pass without a scope-path lookup. The row
|
||||
// is placed as a direct child of the trie root keyed by ScopeId.
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
||||
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows);
|
||||
|
||||
// Root has one child — "line-42".
|
||||
trie.Root.Children.ShouldContainKey("line-42");
|
||||
var node = trie.Root.Children["line-42"];
|
||||
node.Grants.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-011 regression: when a sub-cluster row's ScopeId is not in the supplied
|
||||
/// <c>scopePaths</c>, the fallback diagnostic callback must fire so the caller can
|
||||
/// surface a warning. Silently dropping the grant under the wrong trie level is the
|
||||
/// production hazard the finding flagged.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_Missing_ScopePath_Entry_Invokes_Diagnostic_Callback()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["line-known"] = new(new[] { "ns", "area-1", "line-known" }),
|
||||
};
|
||||
// Row references a line that is NOT in the path lookup.
|
||||
var rows = new[]
|
||||
{
|
||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-orphan", NodePermissions.Read),
|
||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-known", NodePermissions.Read),
|
||||
};
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
|
||||
var trie = PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||
|
||||
diagnostics.Count.ShouldBe(1, "exactly one row had no matching scope-path entry");
|
||||
diagnostics[0].ScopeId.ShouldBe("line-orphan");
|
||||
diagnostics[0].ScopeKind.ShouldBe(NodeAclScopeKind.UnsLine);
|
||||
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
|
||||
{
|
||||
var paths = new Dictionary<string, NodeAclPath>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["line-A"] = new(new[] { "ns", "area-1", "line-A" }),
|
||||
["line-B"] = new(new[] { "ns", "area-1", "line-B" }),
|
||||
};
|
||||
var rows = new[]
|
||||
{
|
||||
Row("cn=ops", NodeAclScopeKind.Cluster, null, NodePermissions.Read), // cluster-level — no descent
|
||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-A", NodePermissions.Read),
|
||||
Row("cn=ops", NodeAclScopeKind.UnsLine, "line-B", NodePermissions.Read),
|
||||
};
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
|
||||
PermissionTrieBuilder.Build("c1", 1, rows, paths, diagnostics.Add);
|
||||
|
||||
diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
|
||||
{
|
||||
// No diagnostics callback should fire when scopePaths itself is null — that's the
|
||||
// "deterministic-test fallback" mode, not a production drop.
|
||||
var rows = new[] { Row("cn=ops", NodeAclScopeKind.UnsLine, "line-42", NodePermissions.Read) };
|
||||
var diagnostics = new List<PermissionTrieBuildDiagnostic>();
|
||||
|
||||
PermissionTrieBuilder.Build("c1", 1, rows, scopePaths: null, diagnostic: diagnostics.Add);
|
||||
|
||||
diagnostics.ShouldBeEmpty(
|
||||
"scopePaths=null is the explicit test-fallback mode and must not emit per-row warnings");
|
||||
}
|
||||
}
|
||||
@@ -77,4 +77,162 @@ public sealed class DriverHostTests
|
||||
host.RegisteredDriverIds.ShouldNotContain("d-1");
|
||||
driver.ShutDown.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-004 regression — DriverHost is a library type whose async calls must use
|
||||
/// ConfigureAwait(false) to match the convention used by CapabilityInvoker /
|
||||
/// AlarmSurfaceInvoker. Asserts the awaited driver call does not post its
|
||||
/// continuation back to a captured SynchronizationContext.
|
||||
/// The driver awaits an unsettled TaskCompletionSource so it does not introduce its
|
||||
/// own capture — only DriverHost's await of the returned Task can drive a post.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RegisterAsync_Does_Not_Capture_SynchronizationContext()
|
||||
{
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var driver = new TcsDriver("d-cfg-1", tcs);
|
||||
var ctx = new TrackingSynchronizationContext();
|
||||
|
||||
// Run the DriverHost call on a dedicated thread that has our tracking SyncContext installed.
|
||||
var workerCtx = await RunOnContextAsync(ctx, async () =>
|
||||
{
|
||||
var host = new DriverHost();
|
||||
var registerTask = host.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
// Complete the driver's InitializeAsync from a background thread so DriverHost's
|
||||
// await must resume via the captured context if ConfigureAwait(false) was missing.
|
||||
_ = Task.Run(() => tcs.SetResult());
|
||||
await registerTask.ConfigureAwait(false);
|
||||
await host.DisposeAsync().ConfigureAwait(false);
|
||||
});
|
||||
|
||||
workerCtx.PostCount.ShouldBe(0,
|
||||
"RegisterAsync's awaited driver call must use ConfigureAwait(false) so the continuation does not post back to the captured context");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisterAsync_Does_Not_Capture_SynchronizationContext()
|
||||
{
|
||||
var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
initTcs.SetResult();
|
||||
var shutdownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var driver = new TcsDriver("d-cfg-2", initTcs, shutdownTcs);
|
||||
var ctx = new TrackingSynchronizationContext();
|
||||
|
||||
var workerCtx = await RunOnContextAsync(ctx, async () =>
|
||||
{
|
||||
var host = new DriverHost();
|
||||
await host.RegisterAsync(driver, "{}", CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// After RegisterAsync we re-enter the context. Reset the post counter so we only
|
||||
// observe UnregisterAsync's behaviour from here on.
|
||||
((TrackingSynchronizationContext)SynchronizationContext.Current!).Reset();
|
||||
|
||||
var task = host.UnregisterAsync("d-cfg-2", CancellationToken.None);
|
||||
_ = Task.Run(() => shutdownTcs.SetResult());
|
||||
await task.ConfigureAwait(false);
|
||||
});
|
||||
|
||||
workerCtx.PostCount.ShouldBe(0,
|
||||
"UnregisterAsync's awaited shutdown call must use ConfigureAwait(false)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_Does_Not_Capture_SynchronizationContext()
|
||||
{
|
||||
var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
initTcs.SetResult();
|
||||
var shutdownTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var driver = new TcsDriver("d-cfg-3", initTcs, shutdownTcs);
|
||||
var ctx = new TrackingSynchronizationContext();
|
||||
|
||||
var workerCtx = await RunOnContextAsync(ctx, async () =>
|
||||
{
|
||||
var host = new DriverHost();
|
||||
await host.RegisterAsync(driver, "{}", CancellationToken.None).ConfigureAwait(false);
|
||||
((TrackingSynchronizationContext)SynchronizationContext.Current!).Reset();
|
||||
|
||||
var task = host.DisposeAsync();
|
||||
_ = Task.Run(() => shutdownTcs.SetResult());
|
||||
await task.ConfigureAwait(false);
|
||||
});
|
||||
|
||||
workerCtx.PostCount.ShouldBe(0,
|
||||
"DisposeAsync's awaited shutdown call must use ConfigureAwait(false)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run <paramref name="body"/> on a dedicated thread with <paramref name="ctx"/>
|
||||
/// installed as the current SynchronizationContext, and return <paramref name="ctx"/>
|
||||
/// after the body completes. The dedicated thread guarantees that resuming via the
|
||||
/// captured context observably routes through our Post hook (the ThreadPool would
|
||||
/// otherwise clear the context on the resuming worker).
|
||||
/// </summary>
|
||||
private static Task<TrackingSynchronizationContext> RunOnContextAsync(TrackingSynchronizationContext ctx, Func<Task> body)
|
||||
{
|
||||
var done = new TaskCompletionSource<TrackingSynchronizationContext>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var t = new Thread(() =>
|
||||
{
|
||||
SynchronizationContext.SetSynchronizationContext(ctx);
|
||||
try
|
||||
{
|
||||
// Pump posted continuations until the body completes.
|
||||
var task = body();
|
||||
while (!task.IsCompleted)
|
||||
{
|
||||
if (ctx.TryDequeue(out var work)) work();
|
||||
else Thread.Sleep(1);
|
||||
}
|
||||
// Drain any tail continuations.
|
||||
while (ctx.TryDequeue(out var work)) work();
|
||||
task.GetAwaiter().GetResult();
|
||||
done.SetResult(ctx);
|
||||
}
|
||||
catch (Exception ex) { done.SetException(ex); }
|
||||
}) { IsBackground = true };
|
||||
t.Start();
|
||||
return done.Task;
|
||||
}
|
||||
|
||||
/// <summary>Driver whose Initialize / Shutdown completions are caller-controlled via TCS.</summary>
|
||||
private sealed class TcsDriver(string id, TaskCompletionSource initTcs, TaskCompletionSource? shutdownTcs = null) : IDriver
|
||||
{
|
||||
public string DriverInstanceId { get; } = id;
|
||||
public string DriverType => "Tcs";
|
||||
|
||||
public Task InitializeAsync(string _, CancellationToken ct) => initTcs.Task;
|
||||
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => (shutdownTcs ?? CompletedTcs).Task;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
|
||||
private static TaskCompletionSource MakeCompleted()
|
||||
{
|
||||
var t = new TaskCompletionSource();
|
||||
t.SetResult();
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>SynchronizationContext that queues posts to a thread-safe work list and counts them.</summary>
|
||||
private sealed class TrackingSynchronizationContext : SynchronizationContext
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _queue = new();
|
||||
public int PostCount;
|
||||
public int SendCount;
|
||||
|
||||
public override void Post(SendOrPostCallback d, object? state)
|
||||
{
|
||||
Interlocked.Increment(ref PostCount);
|
||||
_queue.Enqueue(() => d(state));
|
||||
}
|
||||
public override void Send(SendOrPostCallback d, object? state)
|
||||
{
|
||||
Interlocked.Increment(ref SendCount);
|
||||
d(state);
|
||||
}
|
||||
public bool TryDequeue(out Action work) => _queue.TryDequeue(out work!);
|
||||
public void Reset() { Interlocked.Exchange(ref PostCount, 0); Interlocked.Exchange(ref SendCount, 0); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,41 @@ public sealed class GenericDriverNodeManagerTests
|
||||
nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-008 regression: the XML doc states exception isolation is the caller's
|
||||
/// responsibility — exceptions from <see cref="ITagDiscovery.DiscoverAsync"/> must propagate
|
||||
/// out of <c>BuildAddressSpaceAsync</c> unhandled so the Server layer's per-driver try/catch
|
||||
/// (<c>OpcUaApplicationHost.PopulateAddressSpaces</c>) can mark the subtree Faulted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BuildAddressSpaceAsync_Propagates_Discovery_Exceptions_To_Caller()
|
||||
{
|
||||
var driver = new ThrowingDiscoveryDriver();
|
||||
using var nm = new GenericDriverNodeManager(driver);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
nm.BuildAddressSpaceAsync(new RecordingBuilder(), CancellationToken.None));
|
||||
ex.Message.ShouldBe("discovery boom",
|
||||
"exceptions from DiscoverAsync must propagate unhandled — exception isolation is the caller's responsibility (e.g. OpcUaApplicationHost)");
|
||||
}
|
||||
|
||||
/// <summary>Driver whose DiscoverAsync throws — exercises the exception-isolation boundary.</summary>
|
||||
private sealed class ThrowingDiscoveryDriver : IDriver, ITagDiscovery
|
||||
{
|
||||
public string DriverInstanceId => "throwing";
|
||||
public string DriverType => "Throwing";
|
||||
|
||||
public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
=> throw new InvalidOperationException("discovery boom");
|
||||
}
|
||||
|
||||
// --- test doubles ---
|
||||
|
||||
private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource
|
||||
|
||||
@@ -67,4 +67,53 @@ public sealed class DriverHealthReportTests
|
||||
{
|
||||
DriverHealthReport.HttpStatus(verdict).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-012 regression: <see cref="DriverState.Reconnecting"/> must aggregate to
|
||||
/// <see cref="ReadinessVerdict.Degraded"/> — the doc remarks state matrix lists this
|
||||
/// mapping (after the Core-012 doc fix that added the Reconnecting row).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Any_Reconnecting_WithoutFaultedOrNotReady_IsDegraded()
|
||||
{
|
||||
var verdict = DriverHealthReport.Aggregate([
|
||||
new DriverHealthSnapshot("a", DriverState.Healthy),
|
||||
new DriverHealthSnapshot("b", DriverState.Reconnecting),
|
||||
]);
|
||||
verdict.ShouldBe(ReadinessVerdict.Degraded,
|
||||
"Reconnecting = driver alive but not serving live data → /readyz stays 200 while operators see the affected driver in the body");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-012 regression: assert the XML <c><remarks></c> on
|
||||
/// <see cref="DriverHealthReport"/> names <see cref="DriverState.Reconnecting"/> in its
|
||||
/// state matrix. Catches a future doc-drift if someone re-aliases Reconnecting without
|
||||
/// updating the matrix.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Doc_State_Matrix_Includes_Reconnecting()
|
||||
{
|
||||
var xmlPath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"ZB.MOM.WW.OtOpcUa.Core.xml");
|
||||
File.Exists(xmlPath).ShouldBeTrue($"expected XML doc file at {xmlPath}");
|
||||
|
||||
var content = File.ReadAllText(xmlPath);
|
||||
var driverHealthReportRemarks = ExtractRemarksFor(content, "T:ZB.MOM.WW.OtOpcUa.Core.Observability.DriverHealthReport");
|
||||
|
||||
driverHealthReportRemarks.ShouldContain("Reconnecting");
|
||||
}
|
||||
|
||||
private static string ExtractRemarksFor(string xml, string member)
|
||||
{
|
||||
var memberStart = xml.IndexOf($"<member name=\"{member}\"", StringComparison.Ordinal);
|
||||
if (memberStart < 0) return string.Empty;
|
||||
var memberEnd = xml.IndexOf("</member>", memberStart, StringComparison.Ordinal);
|
||||
if (memberEnd < 0) return string.Empty;
|
||||
var slice = xml.Substring(memberStart, memberEnd - memberStart);
|
||||
var remarksStart = slice.IndexOf("<remarks>", StringComparison.Ordinal);
|
||||
if (remarksStart < 0) return string.Empty;
|
||||
var remarksEnd = slice.IndexOf("</remarks>", remarksStart, StringComparison.Ordinal);
|
||||
return remarksEnd < 0 ? string.Empty : slice.Substring(remarksStart, remarksEnd - remarksStart);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,4 +148,66 @@ public sealed class CapabilityInvokerTests
|
||||
|
||||
builder.CachedPipelineCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-009 regression: ExecuteWriteAsync's non-idempotent branch must snapshot
|
||||
/// <c>_optionsAccessor</c> exactly once per call. Calling it multiple times allocates
|
||||
/// redundant options objects on the per-write hot path and creates a consistency hazard
|
||||
/// where an Admin edit mid-call could observe two different snapshots.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteWriteAsync_NonIdempotent_Snapshots_Options_Once_Per_Call()
|
||||
{
|
||||
var options = new DriverResilienceOptions
|
||||
{
|
||||
Tier = DriverTier.A,
|
||||
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
|
||||
{
|
||||
[DriverCapability.Write] = new(TimeoutSeconds: 2, RetryCount: 3, BreakerFailureThreshold: 5),
|
||||
},
|
||||
};
|
||||
var accessorCalls = 0;
|
||||
var invoker = new CapabilityInvoker(
|
||||
new DriverResiliencePipelineBuilder(),
|
||||
"drv-test",
|
||||
() => { Interlocked.Increment(ref accessorCalls); return options; });
|
||||
|
||||
await invoker.ExecuteWriteAsync(
|
||||
"host-1",
|
||||
isIdempotent: false,
|
||||
_ => ValueTask.FromResult(0),
|
||||
CancellationToken.None);
|
||||
|
||||
accessorCalls.ShouldBe(1,
|
||||
"ExecuteWriteAsync's non-idempotent branch must capture the options snapshot exactly once per call");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-009 regression — companion consistency assertion: the non-idempotent branch must
|
||||
/// not observe two different option snapshots even if the accessor's returned value changes
|
||||
/// between calls (simulating an Admin edit landing mid-flight). With a single snapshot the
|
||||
/// two derived values (<c>with</c> base + <c>Resolve(Write)</c>) come from the same options
|
||||
/// instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteWriteAsync_NonIdempotent_Uses_Consistent_Options_Snapshot()
|
||||
{
|
||||
var a = new DriverResilienceOptions { Tier = DriverTier.A };
|
||||
var b = new DriverResilienceOptions { Tier = DriverTier.B };
|
||||
var alternating = new[] { a, b, a, b }.AsEnumerable().GetEnumerator();
|
||||
var invoker = new CapabilityInvoker(
|
||||
new DriverResiliencePipelineBuilder(),
|
||||
"drv-test",
|
||||
() => { alternating.MoveNext(); return alternating.Current; });
|
||||
|
||||
// If options is read twice, the with-expression and Resolve(Write) come from
|
||||
// different tier tables (A then B) — the resulting one-entry dictionary is
|
||||
// inconsistent with the snapshot used for the rest of the options. Single-snapshot
|
||||
// semantics guarantee the call sees a coherent view.
|
||||
await Should.NotThrowAsync(async () => await invoker.ExecuteWriteAsync(
|
||||
"host-1",
|
||||
isIdempotent: false,
|
||||
_ => ValueTask.FromResult(0),
|
||||
CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,4 +99,49 @@ public sealed class DriverResilienceOptionsTests
|
||||
options.Resolve(DriverCapability.Write).ShouldBe(
|
||||
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-010 regression: every <see cref="DriverCapability"/> value must successfully resolve
|
||||
/// under every tier with a default <see cref="DriverResilienceOptions"/>. A future
|
||||
/// enum-only addition that forgets to update <c>GetTierDefaults</c> would otherwise blow up
|
||||
/// on the hot path with <see cref="KeyNotFoundException"/>.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(DriverTier.A)]
|
||||
[InlineData(DriverTier.B)]
|
||||
[InlineData(DriverTier.C)]
|
||||
public void Resolve_Returns_NonNull_Policy_For_Every_Capability(DriverTier tier)
|
||||
{
|
||||
var options = new DriverResilienceOptions { Tier = tier };
|
||||
|
||||
foreach (var capability in Enum.GetValues<DriverCapability>())
|
||||
{
|
||||
var policy = options.Resolve(capability);
|
||||
policy.ShouldNotBeNull(
|
||||
$"every DriverCapability must resolve to a non-null policy for tier {tier} — {capability} did not");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-010 regression: when a capability is somehow missing from BOTH the override
|
||||
/// map and the tier defaults (defensive — should be impossible thanks to the
|
||||
/// <c>TierDefaults_Cover_EveryCapability</c> invariant, but is the failure mode the
|
||||
/// finding flagged), <c>Resolve</c> must throw a diagnostic <see cref="KeyNotFoundException"/>
|
||||
/// that names the missing capability and tier — not a bare lookup failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_Throws_Diagnostic_When_Capability_Missing_From_Tier_Defaults()
|
||||
{
|
||||
// Use a CapabilityPolicies dict that purposely omits one capability and use reflection
|
||||
// to confirm the message names the capability when the tier defaults also omit it.
|
||||
// We can't easily mutate GetTierDefaults so we exercise the documented behavior on a
|
||||
// synthetic non-tier-known capability (we cast an out-of-range enum value).
|
||||
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
||||
var bogus = (DriverCapability)int.MaxValue;
|
||||
|
||||
var ex = Should.Throw<KeyNotFoundException>(() => options.Resolve(bogus));
|
||||
ex.Message.ShouldContain(bogus.ToString());
|
||||
ex.Message.ShouldContain(DriverTier.A.ToString());
|
||||
ex.Message.ShouldContain(nameof(DriverResilienceOptions.GetTierDefaults));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,4 +109,40 @@ public sealed class WedgeDetectorTests
|
||||
new DemandSignal(0, 0, 1, Now).HasPendingWork.ShouldBeTrue();
|
||||
new DemandSignal(0, 0, 0, Now).HasPendingWork.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core-012 regression: the XML <c><summary></c> on the <see cref="WedgeDetector"/>
|
||||
/// constructor must accurately describe what the constructor does (take + clamp the
|
||||
/// threshold). The previous text — "Whether the driver reported itself Healthy at
|
||||
/// construction" — referenced behaviour the constructor doesn't perform.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Doc_Constructor_Summary_Describes_Threshold_Clamp()
|
||||
{
|
||||
var xmlPath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"ZB.MOM.WW.OtOpcUa.Core.xml");
|
||||
File.Exists(xmlPath).ShouldBeTrue($"expected XML doc file at {xmlPath}");
|
||||
|
||||
var content = File.ReadAllText(xmlPath);
|
||||
var ctorSummary = ExtractSummaryFor(content,
|
||||
"M:ZB.MOM.WW.OtOpcUa.Core.Stability.WedgeDetector.#ctor(System.TimeSpan)");
|
||||
|
||||
ctorSummary.ShouldNotBeNullOrWhiteSpace();
|
||||
ctorSummary.ShouldNotContain("reported itself");
|
||||
ctorSummary.ShouldContain("threshold");
|
||||
}
|
||||
|
||||
private static string ExtractSummaryFor(string xml, string member)
|
||||
{
|
||||
var memberStart = xml.IndexOf($"<member name=\"{member}\"", StringComparison.Ordinal);
|
||||
if (memberStart < 0) return string.Empty;
|
||||
var memberEnd = xml.IndexOf("</member>", memberStart, StringComparison.Ordinal);
|
||||
if (memberEnd < 0) return string.Empty;
|
||||
var slice = xml.Substring(memberStart, memberEnd - memberStart);
|
||||
var sumStart = slice.IndexOf("<summary>", StringComparison.Ordinal);
|
||||
if (sumStart < 0) return string.Empty;
|
||||
var sumEnd = slice.IndexOf("</summary>", sumStart, StringComparison.Ordinal);
|
||||
return sumEnd < 0 ? string.Empty : slice.Substring(sumStart, sumEnd - sumStart);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user