mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
# Description Adopt [`System.TimeProvider`](https://learn.microsoft.com/dotnet/api/system.timeprovider) across the stack so that every place that creates timers, runs duration math, or schedules periodic work is mockable, deterministic in tests, and immune to wall-clock changes. The migration touches Core, Core.Types, Server, Client, PubSub, Configuration, Gds (server + client), Lds, and the Quickstarts/Console reference apps. Per-component changes follow the same pattern: 1. Add a `private readonly TimeProvider m_timeProvider;` field. 2. Accept a nullable `TimeProvider? timeProvider = null` as the **last** constructor parameter (new overload; legacy constructor stays for binary compatibility and delegates with `timeProvider: null`). 3. Initialise the field with `m_timeProvider = timeProvider ??= TimeProvider.System;`. 4. Replace `new Timer(...)`, `Stopwatch`, `HiResClock.*`, `DateTime.UtcNow`-based duration math, and `Task.Delay(delay, ct)` in timing loops with the matching `m_timeProvider` API (`CreateTimer`, `GetTimestamp` / `GetElapsedTime`, `GetUtcNow().UtcDateTime`, `Task.Delay(delay, m_timeProvider, ct)`). 5. Wire the provider through DI: `AddOpcUaServerBuilder` / `AddOpcUaClientBuilder` register `TimeProvider.System` via `TryAddSingleton<TimeProvider>()` so tests can swap in `Microsoft.Extensions.Time.Testing.FakeTimeProvider`. `HiResClock` itself is kept (so the `ApplicationConfiguration.DisableHiResClock` schema flag still round-trips), but **every public member** is now `[Obsolete]` with a single shared message pointing to the new migration-guide section. The few internal call sites that still need to set/read `HiResClock` are wrapped with `#pragma warning disable CS0618`. ### Highlights per component - **Core / Stack** — `Opc.Ua.Core`, `Opc.Ua.Core.Types`, channel pipeline (`UaSCBinaryClientChannel`, `ChannelToken`, `UaSCBinaryChannel`), `ApplicationConfiguration`, certificate validators, `JwksIssuerKeyResolver`, `TimeProviderExtensions` polyfills (`GetTimestampMilliseconds`, `GetTickCount`, `CancellationTokenSource(timeout, timeProvider)`, `Task.Delay(delay, provider, ct)` polyfill for older TFMs). - **Server** — session lockout, subscription pipeline (publish/sample/keep-alive/lifetime), `MonitoredItemQueue`, alarm sampling rate, `IntervalRunner`, server diagnostics, configuration state machines, hosted services. - **Client** — `Session`, managed/V2 subscription engines, reconnect handlers, channel keep-alive, periodic publishing, complex-type discovery polling. - **PubSub** — `IntervalRunner` pipeline, message-receive timers, MQTT/UDP keep-alive, publishers/readers. - **Gds / Lds** — `LdsServer`, `LinqApplicationsDatabase` expiry housekeeping, GDS clients (push, pull), `DirectoryCertificateStore`, `ConnectionStateMachine`, classic subscription engine. - **Quickstarts / Console samples** — `SampleNodeManager`, `ReferenceNodeManager`, `AlarmNodeManager`, `BoilerState`, `MemoryBufferState`, `PublishedValuesWrites`. Samples resolve via `(Server as ITimeProviderProvider)?.TimeProvider ?? TimeProvider.System` so existing ctor signatures stay binary-compatible. - **Tests** — `Opc.Ua.PubSub.Tests` adds `IntervalRunnerTests`; existing `SubscriptionTests`, `UaPublisherTests`, and `TestDataSystem` move off `HiResClock`. Added `Microsoft.Extensions.TimeProvider.Testing` package for `FakeTimeProvider` (project-load warning surfaces on net48/net472 only — runtime is .NET 8+). ### Migration guide New section [**Time and Timer abstraction (`TimeProvider`)**](Docs/MigrationGuide.md#time-and-timer-abstraction-timeprovider) documents: - The canonical replacement table (`HiResClock.UtcNow` → `timeProvider.GetUtcNow().UtcDateTime`, `HiResClock.TickCount64` → `timeProvider.GetTimestamp()`, `new Timer` → `timeProvider.CreateTimer`, `Task.Delay(delay, ct)` → `Task.Delay(delay, timeProvider, ct)`, …). - The `TimeProvider? timeProvider = null` + `??=` constructor pattern. - DI wiring and how to swap in `FakeTimeProvider` in tests. - Before/after snippets for `HiResClock.TickCount64`, `HiResClock.UtcNow`, and `new Timer(...)`. ## Related Issues - Fixes #2458 ## Checklist - [x] 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. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received.
334 lines
15 KiB
C#
334 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
[TestFixture]
|
|
[Category("Subscription")]
|
|
[Parallelizable]
|
|
public class SubscriptionTests
|
|
{
|
|
private Mock<IServerInternal> m_serverMock;
|
|
private Mock<ISession> m_sessionMock;
|
|
private Mock<IDiagnosticsNodeManager> m_diagnosticsNodeManagerMock;
|
|
private Mock<IMonitoredItemQueueFactory> m_queueFactoryMock;
|
|
private ITelemetryContext m_telemetry;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
m_telemetry = NUnitTelemetryContext.Create();
|
|
m_serverMock = new Mock<IServerInternal>();
|
|
m_sessionMock = new Mock<ISession>();
|
|
m_diagnosticsNodeManagerMock = new Mock<IDiagnosticsNodeManager>();
|
|
m_queueFactoryMock = new Mock<IMonitoredItemQueueFactory>();
|
|
|
|
m_serverMock.Setup(s => s.Telemetry).Returns(m_telemetry);
|
|
m_serverMock.Setup(s => s.DiagnosticsNodeManager).Returns(m_diagnosticsNodeManagerMock.Object);
|
|
m_serverMock.Setup(s => s.MonitoredItemQueueFactory).Returns(m_queueFactoryMock.Object);
|
|
|
|
var namespaceUris = new NamespaceTable();
|
|
m_serverMock.Setup(s => s.NamespaceUris).Returns(namespaceUris);
|
|
m_serverMock.Setup(s => s.ServerUris).Returns(new StringTable());
|
|
m_serverMock.Setup(s => s.TypeTree).Returns(new TypeTable(namespaceUris));
|
|
m_serverMock.Setup(s => s.Factory).Returns(new Mock<IEncodeableFactory>().Object);
|
|
|
|
// ServerSystemContext requires invoked server mock to have properties setup
|
|
m_serverMock.Setup(s => s.DefaultSystemContext).Returns(new ServerSystemContext(m_serverMock.Object));
|
|
|
|
m_sessionMock.Setup(s => s.Id).Returns(new NodeId(Guid.NewGuid()));
|
|
|
|
m_diagnosticsNodeManagerMock
|
|
.Setup(d => d.CreateSubscriptionDiagnosticsAsync(
|
|
It.IsAny<ServerSystemContext>(),
|
|
It.IsAny<SubscriptionDiagnosticsDataType>(),
|
|
It.IsAny<NodeValueSimpleEventHandler>()))
|
|
.ReturnsAsync(new NodeId(1));
|
|
}
|
|
|
|
private Subscription CreateSubscription(double publishingInterval = 1000)
|
|
{
|
|
return new Subscription(
|
|
m_serverMock.Object,
|
|
m_sessionMock.Object,
|
|
subscriptionId: 1,
|
|
publishingInterval: publishingInterval,
|
|
maxLifetimeCount: 10,
|
|
maxKeepAliveCount: 5,
|
|
maxNotificationsPerPublish: 0,
|
|
priority: 0,
|
|
publishingEnabled: true,
|
|
maxMessageCount: 10);
|
|
}
|
|
|
|
private static void SetExpiryTime(Subscription subscription, long expiryTime)
|
|
{
|
|
FieldInfo field = typeof(Subscription).GetField("m_publishTimerExpiry", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException("Field m_publishTimerExpiry not found");
|
|
field.SetValue(subscription, expiryTime);
|
|
}
|
|
|
|
private static void ResetKeepAlive(Subscription subscription)
|
|
{
|
|
FieldInfo field = typeof(Subscription).GetField("m_keepAliveCounter", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException("Field m_keepAliveCounter not found");
|
|
field.SetValue(subscription, (uint)0);
|
|
}
|
|
|
|
private static void AddMonitoredItem(Subscription subscription, IMonitoredItem item)
|
|
{
|
|
// Subscription has:
|
|
// private readonly Dictionary<uint, LinkedListNode<IMonitoredItem>> m_monitoredItems;
|
|
// private readonly LinkedList<IMonitoredItem> m_itemsToCheck;
|
|
|
|
FieldInfo monitoredItemsField = typeof(Subscription).GetField("m_monitoredItems", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException("Field m_monitoredItems not found");
|
|
FieldInfo itemsToCheckField = typeof(Subscription).GetField("m_itemsToCheck", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException("Field m_itemsToCheck not found");
|
|
|
|
var monitoredItems = (System.Collections.IDictionary)monitoredItemsField.GetValue(subscription);
|
|
var itemsToCheck = (LinkedList<IMonitoredItem>)itemsToCheckField.GetValue(subscription);
|
|
|
|
// Add to itemsToCheck first to get the node
|
|
LinkedListNode<IMonitoredItem> node = itemsToCheck.AddLast(item);
|
|
// Add to dictionary
|
|
monitoredItems.Add(item.Id, node);
|
|
}
|
|
|
|
private static void AddTriggerLink(Subscription subscription, uint triggeringId, ITriggeredMonitoredItem triggeredItem)
|
|
{
|
|
// private readonly Dictionary<uint, List<ITriggeredMonitoredItem>> m_itemsToTrigger;
|
|
FieldInfo itemsToTriggerField = typeof(Subscription).GetField("m_itemsToTrigger", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException("Field m_itemsToTrigger not found");
|
|
var itemsToTrigger = (System.Collections.IDictionary)itemsToTriggerField.GetValue(subscription);
|
|
|
|
if (!itemsToTrigger.Contains(triggeringId))
|
|
{
|
|
itemsToTrigger.Add(triggeringId, new List<ITriggeredMonitoredItem>());
|
|
}
|
|
var list = (List<ITriggeredMonitoredItem>)itemsToTrigger[triggeringId];
|
|
list.Add(triggeredItem);
|
|
}
|
|
|
|
private static int GetItemsToPublishCount(Subscription subscription)
|
|
{
|
|
FieldInfo itemsToPublishField = typeof(Subscription).GetField("m_itemsToPublish", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException("Field m_itemsToPublish not found");
|
|
var itemsToPublish = (LinkedList<IMonitoredItem>)itemsToPublishField.GetValue(subscription);
|
|
return itemsToPublish.Count;
|
|
}
|
|
|
|
[Test]
|
|
public void PublishTimerExpired_NotExpired_ReturnsIdle()
|
|
{
|
|
using Subscription subscription = CreateSubscription(1000);
|
|
ResetKeepAlive(subscription);
|
|
|
|
// Set expiry in far future
|
|
SetExpiryTime(subscription, TimeProvider.System.GetTimestampMilliseconds() + 100000);
|
|
|
|
PublishingState state = subscription.PublishTimerExpired();
|
|
|
|
Assert.That(state, Is.EqualTo(PublishingState.Idle));
|
|
}
|
|
|
|
[Test]
|
|
public void PublishTimerExpired_Expired_ReturnsNotificationsAvailable_ForKeepAlive()
|
|
{
|
|
using Subscription subscription = CreateSubscription(1000);
|
|
// Don't reset keepalive, it should be maxKeepAliveCount initially.
|
|
|
|
SetExpiryTime(subscription, TimeProvider.System.GetTimestampMilliseconds() - 100);
|
|
|
|
PublishingState state = subscription.PublishTimerExpired();
|
|
|
|
Assert.That(state, Is.EqualTo(PublishingState.NotificationsAvailable));
|
|
}
|
|
|
|
[Test]
|
|
public void PublishTimerExpired_Expired_ItemsReady_ReturnsNotificationsAvailable()
|
|
{
|
|
using Subscription subscription = CreateSubscription(1000);
|
|
ResetKeepAlive(subscription);
|
|
SetExpiryTime(subscription, TimeProvider.System.GetTimestampMilliseconds() - 100);
|
|
|
|
// Mock Monitored Item
|
|
var itemMock = new Mock<IMonitoredItem>();
|
|
itemMock.Setup(i => i.Id).Returns(1);
|
|
itemMock.Setup(i => i.IsReadyToPublish).Returns(true);
|
|
|
|
AddMonitoredItem(subscription, itemMock.Object);
|
|
|
|
PublishingState state = subscription.PublishTimerExpired();
|
|
|
|
Assert.That(state, Is.EqualTo(PublishingState.NotificationsAvailable));
|
|
Assert.That(GetItemsToPublishCount(subscription), Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void PublishTimerExpired_Expired_ItemsNotReady_ReturnsIdle()
|
|
{
|
|
using Subscription subscription = CreateSubscription(1000);
|
|
ResetKeepAlive(subscription);
|
|
SetExpiryTime(subscription, TimeProvider.System.GetTimestampMilliseconds() - 100);
|
|
|
|
// Mock Monitored Item
|
|
var itemMock = new Mock<IMonitoredItem>();
|
|
itemMock.Setup(i => i.Id).Returns(1);
|
|
itemMock.Setup(i => i.IsReadyToPublish).Returns(false);
|
|
|
|
AddMonitoredItem(subscription, itemMock.Object);
|
|
|
|
PublishingState state = subscription.PublishTimerExpired();
|
|
|
|
Assert.That(state, Is.EqualTo(PublishingState.Idle));
|
|
Assert.That(GetItemsToPublishCount(subscription), Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void PublishTimerExpired_Expired_IncrementsKeepAlive()
|
|
{
|
|
using Subscription subscription = CreateSubscription(1000);
|
|
ResetKeepAlive(subscription);
|
|
SetExpiryTime(subscription, TimeProvider.System.GetTimestampMilliseconds() - 100);
|
|
|
|
Assert.That(subscription.Diagnostics.CurrentKeepAliveCount, Is.Zero);
|
|
|
|
subscription.PublishTimerExpired();
|
|
|
|
Assert.That(subscription.Diagnostics.CurrentKeepAliveCount, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void PublishTimerExpired_Triggering_CorrectlyTriggersAndPublishes()
|
|
{
|
|
using Subscription subscription = CreateSubscription(1000);
|
|
ResetKeepAlive(subscription);
|
|
SetExpiryTime(subscription, TimeProvider.System.GetTimestampMilliseconds() - 100);
|
|
|
|
// Item A: Triggering item. Ready to publish, Ready to trigger.
|
|
var itemAMock = new Mock<IMonitoredItem>();
|
|
itemAMock.Setup(i => i.Id).Returns(1);
|
|
itemAMock.Setup(i => i.IsReadyToPublish).Returns(true);
|
|
itemAMock.SetupProperty(i => i.IsReadyToTrigger, true); // Use property behavior so it can be set to false by Subscription
|
|
|
|
// Item B: Triggered item. Initially NOT ready to publish.
|
|
// B must implement ITriggeredMonitoredItem as well.
|
|
var itemBMock = new Mock<IMonitoredItem>();
|
|
itemBMock.As<ITriggeredMonitoredItem>();
|
|
Mock<ITriggeredMonitoredItem> triggeredItemB = itemBMock.As<ITriggeredMonitoredItem>();
|
|
|
|
itemBMock.Setup(i => i.Id).Returns(2);
|
|
triggeredItemB.Setup(i => i.Id).Returns(2);
|
|
|
|
// "State" of ready
|
|
bool bIsReady = false;
|
|
itemBMock.Setup(i => i.IsReadyToPublish).Returns(() => bIsReady);
|
|
|
|
// SetTriggered updates state
|
|
triggeredItemB.Setup(i => i.SetTriggered()).Returns(() =>
|
|
{
|
|
bIsReady = true;
|
|
return true; // True indicates it has something to publish
|
|
});
|
|
|
|
// Add both items
|
|
AddMonitoredItem(subscription, itemAMock.Object);
|
|
AddMonitoredItem(subscription, itemBMock.Object);
|
|
|
|
// Add trigger link A -> B
|
|
AddTriggerLink(subscription, 1, triggeredItemB.Object);
|
|
|
|
PublishingState state = subscription.PublishTimerExpired();
|
|
|
|
Assert.That(state, Is.EqualTo(PublishingState.NotificationsAvailable));
|
|
|
|
// Both items should be in publish queue
|
|
Assert.That(GetItemsToPublishCount(subscription), Is.EqualTo(2), "Both items should be ready to publish");
|
|
|
|
// Verify trigger was called
|
|
triggeredItemB.Verify(i => i.SetTriggered(), Times.Once);
|
|
|
|
// Verify IsReadyToTrigger on A was reset to false
|
|
Assert.That(itemAMock.Object.IsReadyToTrigger, Is.False, "IsReadyToTrigger should be reset");
|
|
}
|
|
|
|
private static void AddMonitoredItemToPublish(Subscription subscription, IMonitoredItem item)
|
|
{
|
|
FieldInfo itemsToPublishField = typeof(Subscription).GetField("m_itemsToPublish", BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new InvalidOperationException("Field m_itemsToPublish not found");
|
|
var itemsToPublish = (LinkedList<IMonitoredItem>)itemsToPublishField.GetValue(subscription);
|
|
itemsToPublish.AddLast(item);
|
|
}
|
|
|
|
[Test]
|
|
public void Publish_MultipleTimes_WithMaxMessageCount()
|
|
{
|
|
using var subscription = new Subscription(m_serverMock.Object, m_sessionMock.Object, 1, 100, 1000, 10, 1, 0, true, 2);
|
|
var itemMock = new Mock<IDataChangeMonitoredItem2>();
|
|
|
|
var values = new List<MonitoredItemNotification>
|
|
{
|
|
new() { Value = new DataValue(1) },
|
|
new() { Value = new DataValue(2) },
|
|
new() { Value = new DataValue(3) }
|
|
};
|
|
|
|
int counter = 0;
|
|
itemMock.Setup(i => i.Publish(
|
|
It.IsAny<OperationContext>(),
|
|
It.IsAny<Queue<MonitoredItemNotification>>(),
|
|
It.IsAny<Queue<DiagnosticInfo>>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<Microsoft.Extensions.Logging.ILogger>()))
|
|
.Returns<OperationContext, Queue<MonitoredItemNotification>, Queue<DiagnosticInfo>, uint, Microsoft.Extensions.Logging.ILogger>(
|
|
(ctx, nq, dq, max, logger) =>
|
|
{
|
|
if (counter < values.Count)
|
|
{
|
|
nq.Enqueue(values[counter++]);
|
|
dq.Enqueue(new DiagnosticInfo());
|
|
itemMock.SetupGet(x => x.IsReadyToPublish).Returns(counter < values.Count);
|
|
return counter < values.Count;
|
|
}
|
|
return false;
|
|
});
|
|
itemMock.SetupGet(i => i.Id).Returns(1);
|
|
itemMock.SetupGet(i => i.IsReadyToPublish).Returns(true);
|
|
itemMock.SetupGet(i => i.AttributeId).Returns(Attributes.Value);
|
|
itemMock.SetupGet(i => i.MonitoredItemType).Returns(MonitoredItemTypeMask.DataChange);
|
|
|
|
AddMonitoredItem(subscription, itemMock.Object);
|
|
SetExpiryTime(subscription, TimeProvider.System.GetTimestampMilliseconds() - 100);
|
|
PublishingState state = subscription.PublishTimerExpired();
|
|
|
|
AddMonitoredItemToPublish(subscription, itemMock.Object);
|
|
|
|
var messages = new List<NotificationMessage>();
|
|
|
|
// First publish
|
|
var ctx1 = new OperationContext(m_sessionMock.Object, new DiagnosticsMasks());
|
|
NotificationMessage message = subscription.Publish(ctx1, out ArrayOf<uint> availableSequenceNumbers, out bool moreNotifications1);
|
|
messages.Add(message);
|
|
|
|
// Should be more because we generated multiple notifications and limit the max per publish to 1 for tests.
|
|
Assert.That(moreNotifications1, Is.True);
|
|
|
|
// Second publish
|
|
NotificationMessage message2 = subscription.Publish(ctx1, out availableSequenceNumbers, out bool moreNotifications2);
|
|
|
|
// third publish
|
|
NotificationMessage message3 = subscription.Publish(ctx1, out availableSequenceNumbers, out bool moreNotifications3);
|
|
|
|
Assert.That(message2, Is.Not.Null);
|
|
Assert.That(message3, Is.Not.Null);
|
|
Assert.That(moreNotifications2, Is.True);
|
|
Assert.That(moreNotifications3, Is.False);
|
|
}
|
|
}
|
|
}
|