- Server-004: pass the role-derived display name to UserIdentity's base ctor (the SDK's DisplayName has no public setter) and drop the dead Display property; make RoleBasedIdentity internal sealed. - Server-006: derive a bounded CancellationToken from the SDK's OperationContext.OperationDeadline in OnReadValue / OnWriteValue so a stalled driver call can no longer pin the request thread. - Server-008: mark handled slots via CallMethodRequest.Processed = true in RouteScriptedAlarmMethodCalls (the SDK skips on Processed, not on a Good error slot). - Server-012: PeerHttpProbeLoop.ProbeAsync stops mutating client.Timeout per call; uses a per-request CancellationTokenSource linked to the shutdown token instead. - Server-014: wire SealedBootstrap into Program.cs via AddSealedBootstrap + OpcUaServerService so the generation-sealed cache + stale-config flag + resilient reader actually run; /healthz now reflects cache-fallback state. - Server-015: replace the stale 'PR 16 / PR 17 minimum-viable scope' class summaries on OtOpcUaServer and OpcUaServerOptions with the shipped LDAP + anonymous-role + configurable security-profile prose. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
5.3 KiB
C#
117 lines
5.3 KiB
C#
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression for Server-006 — synchronous OnReadValue / OnWriteValue stack hooks must
|
|
/// derive a <see cref="CancellationToken"/> from the operation deadline so a stalled
|
|
/// driver call doesn't pin a request thread for the full pipeline timeout. The shared
|
|
/// helper <see cref="DriverNodeManager.DeriveOperationCancellation"/> turns the
|
|
/// <see cref="ISystemContext"/>'s <c>OperationDeadline</c> into a linked CTS.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class DriverNodeManagerCancellationTests
|
|
{
|
|
/// <summary>
|
|
/// Build a SystemContext bound to the supplied IOperationContext. SystemContext's
|
|
/// OperationContext setter is protected, so we use the public <c>Copy</c> method
|
|
/// which clones the context onto the supplied operation context.
|
|
/// </summary>
|
|
private static ISystemContext ContextWithDeadline(DateTime deadline)
|
|
=> new SystemContext().Copy(new StubOperationContext(deadline));
|
|
|
|
[Fact]
|
|
public void Future_deadline_produces_uncancelled_token()
|
|
{
|
|
var ctx = ContextWithDeadline(DateTime.UtcNow.AddSeconds(30));
|
|
|
|
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(10));
|
|
|
|
cts.Token.IsCancellationRequested.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Past_deadline_produces_already_cancelled_token()
|
|
{
|
|
var ctx = ContextWithDeadline(DateTime.UtcNow.AddSeconds(-5));
|
|
|
|
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(10));
|
|
|
|
cts.Token.IsCancellationRequested.ShouldBeTrue(
|
|
"an expired OperationDeadline must surface as an immediately-cancelled token so the "
|
|
+ "stalled driver call returns without burning a request thread");
|
|
}
|
|
|
|
[Fact]
|
|
public void Missing_deadline_uses_fallback_timeout()
|
|
{
|
|
// No OperationContext attached → no deadline plumbed; helper falls back to the
|
|
// supplied timeout so an OnReadValue hook into a stalled driver can't hang the
|
|
// request thread indefinitely.
|
|
var ctx = new SystemContext();
|
|
|
|
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromMilliseconds(20));
|
|
|
|
cts.Token.WaitHandle.WaitOne(TimeSpan.FromMilliseconds(500)).ShouldBeTrue(
|
|
"fallback timeout must fire so a missing deadline cannot hang the request thread");
|
|
cts.Token.IsCancellationRequested.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void DateTime_MinValue_deadline_uses_fallback_timeout()
|
|
{
|
|
// IOperationContext.OperationDeadline is `DateTime.MinValue` when the stack hasn't
|
|
// plumbed a deadline through. The helper treats that as "no deadline" and falls
|
|
// back to the supplied timeout, otherwise an MinValue would surface as
|
|
// already-cancelled and short-circuit every read.
|
|
var ctx = ContextWithDeadline(DateTime.MinValue);
|
|
|
|
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromMilliseconds(20));
|
|
|
|
cts.Token.WaitHandle.WaitOne(TimeSpan.FromMilliseconds(500)).ShouldBeTrue();
|
|
cts.Token.IsCancellationRequested.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void DateTime_MaxValue_deadline_uses_fallback_timeout_not_overflow()
|
|
{
|
|
// OperationContext sets OperationDeadline = DateTime.MaxValue when the client's
|
|
// RequestHeader.TimeoutHint is zero (the default). DateTime.MaxValue - UtcNow
|
|
// overflows CancellationTokenSource(TimeSpan)'s Int32.MaxValue-ms cap, so the
|
|
// helper must collapse it to the fallback — otherwise the read throws
|
|
// ArgumentOutOfRangeException from inside DeriveOperationCancellation and surfaces
|
|
// as BadInternalError on every read (regression that broke OpcUaServerIntegrationTests).
|
|
var ctx = ContextWithDeadline(DateTime.MaxValue);
|
|
|
|
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(30));
|
|
|
|
cts.Token.IsCancellationRequested.ShouldBeFalse("MaxValue deadline + 30 s fallback must produce a live token");
|
|
}
|
|
|
|
[Fact]
|
|
public void Null_context_returns_uncancelled_token_with_fallback()
|
|
{
|
|
// Defensive — OnReadValue receives an ISystemContext from the stack so the helper
|
|
// shouldn't NRE if a future override passes through a null context.
|
|
using var cts = DriverNodeManager.DeriveOperationCancellation(context: null!, fallback: TimeSpan.FromSeconds(30));
|
|
|
|
cts.Token.IsCancellationRequested.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Minimal IOperationContext for deadline testing.</summary>
|
|
private sealed class StubOperationContext(DateTime deadline) : IOperationContext
|
|
{
|
|
public DateTime OperationDeadline { get; } = deadline;
|
|
public NodeId? SessionId => null;
|
|
public IUserIdentity? UserIdentity => null;
|
|
public IList<string>? PreferredLocales => null;
|
|
public DiagnosticsMasks DiagnosticsMask => DiagnosticsMasks.None;
|
|
public StringTable StringTable { get; } = new StringTable();
|
|
public StatusCode OperationStatus => StatusCodes.Good;
|
|
public string? AuditEntryId => null;
|
|
}
|
|
}
|