mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 15:11:53 +08:00
# Description Follow-up unit tests for the debounced server-certificate revalidation loop added in PR #3849 (now merged as `15eb55c46`). Codecov flagged `ManagedSession.CertificateChanges.cs` as the lowest-coverage file in that PR (34% patch coverage) — the loop body that drives the debounce / at-most-one-in-flight / re-arm semantics is the heart of the trust/CRL revalidation behaviour and was previously untested. ## Changes * **`Libraries/Opc.Ua.Client/Session/ManagedSession.CertificateChanges.cs`** — extract `RunRevalidationLoopAsync` into a pure `internal static` overload that takes a `ChannelReader<int>`, `TimeProvider`, debounce window, validate callback, logger, and cancellation token. The instance overload now forwards to it. This gives unit tests a seam without standing up a connected `ManagedSession` (whose private constructor + `CreateAsync` triggers a real connect). * **`Tests/Opc.Ua.Client.Tests/Session/ManagedSessionRevalidationLoopTests.cs` (new)** — 5 unit tests covering the loop body in isolation: - `BurstOfSignalsCollapsesToSingleValidationAsync` — 50 signals → exactly 1 validation (bounded-1 channel + DropWrite). - `SignalDuringInflightValidationReArmsExactlyOnceAsync` — re-arm semantics during in-flight validation. - `NoSignalMeansNoValidationAsync` — idle loop never invokes the validator. - `CancellationDuringDebounceAbortsPendingValidationAsync` — cancellation cleanly aborts a pending validation. - `ValidationExceptionsAreSwallowedAndLoopContinuesAsync` — transient validation failures don't break the loop. Uses real `TimeProvider.System` with a 50 ms debounce + 250 ms settle window so each test runs in well under a second. A `FakeTimeProvider` seam was considered but rejected — the loop's `ReadAsync → Delay → drain` sequence is racy to drive with a fake clock because the timer is not created until after the first signal has been consumed. ## Related Issues - Follow-up to #3562 / #3160 (both fixed in #3849, merged). ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. _(None needed — pure test addition + non-public testing seam.)_ - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran focused tests locally using the **UA.slnx** projects against .net **10**, and all 5 new tests passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received.
279 lines
10 KiB
C#
279 lines
10 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Threading;
|
|
using System.Threading.Channels;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NUnit.Framework;
|
|
|
|
using ManagedSessionClass = Opc.Ua.Client.ManagedSession;
|
|
|
|
namespace Opc.Ua.Client.Tests.ManagedSession
|
|
{
|
|
/// <summary>
|
|
/// Unit tests for the static
|
|
/// <c>ManagedSession.RunRevalidationLoopAsync</c> loop body that
|
|
/// drives debounced server-certificate revalidation for trust-list
|
|
/// and CRL changes (issue #3160 follow-up).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// These tests use the real <see cref="TimeProvider.System"/> with
|
|
/// a short debounce window so each test runs in well under a
|
|
/// second. A <c>FakeTimeProvider</c> seam was considered but
|
|
/// rejected because the loop's
|
|
/// <c>ReadAsync → Delay → drain</c> sequence is racy to drive with
|
|
/// a fake clock — the fake timer is not created until after the
|
|
/// first signal has been consumed, so an early <c>Advance</c> is
|
|
/// a no-op.
|
|
/// </remarks>
|
|
[TestFixture]
|
|
[Category("ManagedSession")]
|
|
[Category("CertificateChanges")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
public sealed class ManagedSessionRevalidationLoopTests
|
|
{
|
|
private static readonly TimeSpan s_shortDebounce = TimeSpan.FromMilliseconds(50);
|
|
private static readonly TimeSpan s_settleWindow = TimeSpan.FromMilliseconds(250);
|
|
|
|
[Test]
|
|
public async Task BurstOfSignalsCollapsesToSingleValidationAsync()
|
|
{
|
|
Channel<int> channel = CreateChannel();
|
|
int invocations = 0;
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task loop = ManagedSessionClass.RunRevalidationLoopAsync(
|
|
channel.Reader,
|
|
TimeProvider.System,
|
|
s_shortDebounce,
|
|
_ =>
|
|
{
|
|
Interlocked.Increment(ref invocations);
|
|
return Task.CompletedTask;
|
|
},
|
|
NullLogger.Instance,
|
|
cts.Token);
|
|
|
|
// 50 events in a tight burst — the bounded-1 channel plus
|
|
// DropWrite means only one signal is observable to the
|
|
// reader.
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
channel.Writer.TryWrite(0);
|
|
}
|
|
|
|
// Wait through the debounce + a safety window.
|
|
await Task.Delay(s_shortDebounce + s_settleWindow).ConfigureAwait(false);
|
|
|
|
await CancelAndAwaitAsync(cts, loop).ConfigureAwait(false);
|
|
|
|
Assert.That(Volatile.Read(ref invocations), Is.EqualTo(1),
|
|
"A burst of 50 signals must collapse to exactly one validation.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task SignalDuringInflightValidationReArmsExactlyOnceAsync()
|
|
{
|
|
Channel<int> channel = CreateChannel();
|
|
int invocations = 0;
|
|
var firstStarted = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var firstCanComplete = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task loop = ManagedSessionClass.RunRevalidationLoopAsync(
|
|
channel.Reader,
|
|
TimeProvider.System,
|
|
s_shortDebounce,
|
|
async _ =>
|
|
{
|
|
int n = Interlocked.Increment(ref invocations);
|
|
if (n == 1)
|
|
{
|
|
firstStarted.TrySetResult(true);
|
|
await firstCanComplete.Task.ConfigureAwait(false);
|
|
}
|
|
},
|
|
NullLogger.Instance,
|
|
cts.Token);
|
|
|
|
channel.Writer.TryWrite(0);
|
|
await firstStarted.Task.ConfigureAwait(false);
|
|
|
|
// While the first validation is in-flight, more signals
|
|
// arrive. The bounded channel collapses them to one
|
|
// observable signal.
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
channel.Writer.TryWrite(0);
|
|
}
|
|
|
|
// Release the first validation; the loop must observe the
|
|
// pending signal and run validation exactly once more.
|
|
firstCanComplete.TrySetResult(true);
|
|
await Task.Delay(s_shortDebounce + s_settleWindow).ConfigureAwait(false);
|
|
|
|
await CancelAndAwaitAsync(cts, loop).ConfigureAwait(false);
|
|
|
|
Assert.That(Volatile.Read(ref invocations), Is.EqualTo(2),
|
|
"Re-arm must fire exactly once for events received during the in-flight validation.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task NoSignalMeansNoValidationAsync()
|
|
{
|
|
Channel<int> channel = CreateChannel();
|
|
int invocations = 0;
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task loop = ManagedSessionClass.RunRevalidationLoopAsync(
|
|
channel.Reader,
|
|
TimeProvider.System,
|
|
s_shortDebounce,
|
|
_ =>
|
|
{
|
|
Interlocked.Increment(ref invocations);
|
|
return Task.CompletedTask;
|
|
},
|
|
NullLogger.Instance,
|
|
cts.Token);
|
|
|
|
// Idle: no signals at all, wait a few debounce windows.
|
|
await Task.Delay(TimeSpan.FromTicks(s_shortDebounce.Ticks * 4)).ConfigureAwait(false);
|
|
|
|
await CancelAndAwaitAsync(cts, loop).ConfigureAwait(false);
|
|
|
|
Assert.That(Volatile.Read(ref invocations), Is.Zero,
|
|
"Loop must not invoke validation when no signals arrive.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CancellationDuringDebounceAbortsPendingValidationAsync()
|
|
{
|
|
Channel<int> channel = CreateChannel();
|
|
int invocations = 0;
|
|
|
|
// Use a longer debounce so we can reliably cancel while
|
|
// the loop is still inside Delay.
|
|
TimeSpan debounce = TimeSpan.FromSeconds(2);
|
|
|
|
var cts = new CancellationTokenSource();
|
|
Task loop = ManagedSessionClass.RunRevalidationLoopAsync(
|
|
channel.Reader,
|
|
TimeProvider.System,
|
|
debounce,
|
|
_ =>
|
|
{
|
|
Interlocked.Increment(ref invocations);
|
|
return Task.CompletedTask;
|
|
},
|
|
NullLogger.Instance,
|
|
cts.Token);
|
|
|
|
channel.Writer.TryWrite(0);
|
|
|
|
// Give the loop a moment to read the signal and start the
|
|
// debounce Delay.
|
|
await Task.Delay(50).ConfigureAwait(false);
|
|
|
|
await CancelAndAwaitAsync(cts, loop).ConfigureAwait(false);
|
|
|
|
Assert.That(Volatile.Read(ref invocations), Is.Zero,
|
|
"Cancellation during the debounce window must abort the pending validation.");
|
|
cts.Dispose();
|
|
}
|
|
|
|
[Test]
|
|
public async Task ValidationExceptionsAreSwallowedAndLoopContinuesAsync()
|
|
{
|
|
Channel<int> channel = CreateChannel();
|
|
int invocations = 0;
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
Task loop = ManagedSessionClass.RunRevalidationLoopAsync(
|
|
channel.Reader,
|
|
TimeProvider.System,
|
|
s_shortDebounce,
|
|
_ =>
|
|
{
|
|
int n = Interlocked.Increment(ref invocations);
|
|
if (n == 1)
|
|
{
|
|
throw new InvalidOperationException("simulated transient failure");
|
|
}
|
|
return Task.CompletedTask;
|
|
},
|
|
NullLogger.Instance,
|
|
cts.Token);
|
|
|
|
channel.Writer.TryWrite(0);
|
|
await Task.Delay(s_shortDebounce + s_settleWindow).ConfigureAwait(false);
|
|
|
|
// The first validation threw; the next signal must still
|
|
// drive a fresh run.
|
|
channel.Writer.TryWrite(0);
|
|
await Task.Delay(s_shortDebounce + s_settleWindow).ConfigureAwait(false);
|
|
|
|
await CancelAndAwaitAsync(cts, loop).ConfigureAwait(false);
|
|
|
|
Assert.That(Volatile.Read(ref invocations), Is.EqualTo(2),
|
|
"Loop must survive a transient validation exception and process subsequent signals.");
|
|
}
|
|
|
|
private static Channel<int> CreateChannel()
|
|
{
|
|
return Channel.CreateBounded<int>(new BoundedChannelOptions(1)
|
|
{
|
|
FullMode = BoundedChannelFullMode.DropWrite,
|
|
SingleReader = true,
|
|
SingleWriter = false,
|
|
AllowSynchronousContinuations = false
|
|
});
|
|
}
|
|
|
|
private static async Task CancelAndAwaitAsync(
|
|
CancellationTokenSource cts,
|
|
Task loop)
|
|
{
|
|
cts.Cancel();
|
|
try
|
|
{
|
|
await loop.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
}
|