Files
Marc Schier dbc09b8e22 Adopt System.TimeProvider across the stack (fixes #2458) (#3829)
# 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.
2026-06-03 22:38:32 +02:00

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);
}
}
}