mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
dbc09b8e22
# 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.
490 lines
19 KiB
C#
490 lines
19 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 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.Tasks;
|
|
using Opc.Ua.Security.Certificates;
|
|
|
|
namespace Opc.Ua.Client
|
|
{
|
|
/// <summary>
|
|
/// Session factory that creates raw <see cref="Session"/> instances
|
|
/// without automatic reconnection or failover.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Use <see cref="ManagedSessionFactory"/> instead to get
|
|
/// <see cref="ManagedSession"/> instances that handle reconnection
|
|
/// and failover automatically.
|
|
/// </remarks>
|
|
public class DefaultSessionFactory : ISessionFactory
|
|
{
|
|
/// <summary>
|
|
/// The default instance of the factory.
|
|
/// </summary>
|
|
// Telemetry is required by the modern ctor; this obsolete singleton forwards null!
|
|
// to preserve the pre-nullable static-instance pattern.
|
|
[Obsolete("Use new DefaultSessionFactory(ITelemetryContext) instead.")]
|
|
public static readonly DefaultSessionFactory Instance = new(null!);
|
|
|
|
/// <inheritdoc/>
|
|
public ITelemetryContext Telemetry { get; init; }
|
|
|
|
/// <inheritdoc/>
|
|
public DiagnosticsMasks ReturnDiagnostics { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional subscription engine factory to use when constructing
|
|
/// a <see cref="Session"/>. When <c>null</c>, the session uses
|
|
/// the classic engine (<see cref="ClassicSubscriptionEngineFactory"/>).
|
|
/// New <see cref="ManagedSession"/> code paths default to the V2
|
|
/// engine (<see cref="DefaultSubscriptionEngineFactory"/>) via
|
|
/// the <c>ManagedSessionBuilder</c>.
|
|
/// </summary>
|
|
public ISubscriptionEngineFactory? SubscriptionEngineFactory { get; init; }
|
|
|
|
/// <summary>
|
|
/// Optional <see cref="TimeProvider"/> forwarded to every
|
|
/// <see cref="Session"/> created by this factory. When
|
|
/// <see langword="null"/>, the session uses
|
|
/// <see cref="TimeProvider.System"/>.
|
|
/// </summary>
|
|
public TimeProvider? TimeProvider { get; init; }
|
|
|
|
/// <summary>
|
|
/// Obsolete default constructor
|
|
/// </summary>
|
|
[Obsolete("Use DefaultSessionFactory(ITelemetryContext) instead.")]
|
|
public DefaultSessionFactory()
|
|
// Telemetry is required by the modern ctor; this obsolete bridge forwards null!
|
|
// to preserve the parameterless instantiation pattern.
|
|
: this(null!)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new instance of the <see cref="DefaultSessionFactory"/>.
|
|
/// </summary>
|
|
public DefaultSessionFactory(ITelemetryContext telemetry)
|
|
{
|
|
Telemetry = telemetry;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual Task<ISession> CreateAsync(
|
|
ApplicationConfiguration configuration,
|
|
ConfiguredEndpoint endpoint,
|
|
bool updateBeforeConnect,
|
|
string sessionName,
|
|
uint sessionTimeout,
|
|
IUserIdentity? identity,
|
|
ArrayOf<string> preferredLocales,
|
|
CancellationToken ct = default)
|
|
{
|
|
return CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
updateBeforeConnect,
|
|
false,
|
|
sessionName,
|
|
sessionTimeout,
|
|
identity,
|
|
preferredLocales,
|
|
ct);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual Task<ISession> CreateAsync(
|
|
ApplicationConfiguration configuration,
|
|
ConfiguredEndpoint endpoint,
|
|
bool updateBeforeConnect,
|
|
bool checkDomain,
|
|
string sessionName,
|
|
uint sessionTimeout,
|
|
IUserIdentity? identity,
|
|
ArrayOf<string> preferredLocales,
|
|
CancellationToken ct = default)
|
|
{
|
|
return CreateAsync(
|
|
configuration,
|
|
null,
|
|
endpoint,
|
|
updateBeforeConnect,
|
|
checkDomain,
|
|
sessionName,
|
|
sessionTimeout,
|
|
identity,
|
|
preferredLocales,
|
|
ReturnDiagnostics,
|
|
ct);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual Task<ISession> CreateAsync(
|
|
ApplicationConfiguration configuration,
|
|
ITransportWaitingConnection connection,
|
|
ConfiguredEndpoint endpoint,
|
|
bool updateBeforeConnect,
|
|
bool checkDomain,
|
|
string sessionName,
|
|
uint sessionTimeout,
|
|
IUserIdentity? identity,
|
|
ArrayOf<string> preferredLocales,
|
|
CancellationToken ct = default)
|
|
{
|
|
return CreateAsync(
|
|
configuration,
|
|
connection,
|
|
endpoint,
|
|
updateBeforeConnect,
|
|
checkDomain,
|
|
sessionName,
|
|
sessionTimeout,
|
|
identity,
|
|
preferredLocales,
|
|
ReturnDiagnostics,
|
|
ct);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual async Task<ISession> CreateAsync(
|
|
ApplicationConfiguration configuration,
|
|
ReverseConnectManager reverseConnectManager,
|
|
ConfiguredEndpoint endpoint,
|
|
bool updateBeforeConnect,
|
|
bool checkDomain,
|
|
string sessionName,
|
|
uint sessionTimeout,
|
|
IUserIdentity? userIdentity,
|
|
ArrayOf<string> preferredLocales,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (reverseConnectManager == null)
|
|
{
|
|
return await CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
updateBeforeConnect,
|
|
checkDomain,
|
|
sessionName,
|
|
sessionTimeout,
|
|
userIdentity,
|
|
preferredLocales,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
ITransportWaitingConnection? connection;
|
|
do
|
|
{
|
|
// Reverse-connect endpoints always carry an EndpointUrl and SecurityPolicyUri
|
|
// populated when the configured endpoint was loaded; the bangs reflect that.
|
|
connection = await reverseConnectManager
|
|
.WaitForConnectionAsync(
|
|
endpoint.EndpointUrl!,
|
|
endpoint.ReverseConnect?.ServerUri,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (updateBeforeConnect)
|
|
{
|
|
await endpoint.UpdateFromServerAsync(
|
|
endpoint.EndpointUrl!,
|
|
connection,
|
|
endpoint.Description.SecurityMode,
|
|
endpoint.Description.SecurityPolicyUri!,
|
|
Telemetry,
|
|
ct).ConfigureAwait(false);
|
|
updateBeforeConnect = false;
|
|
connection = null;
|
|
}
|
|
} while (connection == null);
|
|
|
|
return await CreateAsync(
|
|
configuration,
|
|
connection,
|
|
endpoint,
|
|
false,
|
|
checkDomain,
|
|
sessionName,
|
|
sessionTimeout,
|
|
userIdentity,
|
|
preferredLocales,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual async Task<ITransportChannel> CreateChannelAsync(
|
|
ApplicationConfiguration configuration,
|
|
ITransportWaitingConnection? connection,
|
|
ConfiguredEndpoint endpoint,
|
|
bool updateBeforeConnect,
|
|
bool checkDomain,
|
|
CancellationToken ct = default)
|
|
{
|
|
endpoint.UpdateBeforeConnect = updateBeforeConnect;
|
|
|
|
EndpointDescription endpointDescription = endpoint.Description;
|
|
|
|
// create the endpoint configuration (use the application configuration to provide default values).
|
|
EndpointConfiguration? endpointConfiguration = endpoint.Configuration;
|
|
|
|
if (endpointConfiguration == null)
|
|
{
|
|
endpoint.Configuration = endpointConfiguration = EndpointConfiguration.Create(
|
|
configuration);
|
|
}
|
|
|
|
// create message context.
|
|
ServiceMessageContext messageContext = configuration.CreateMessageContext();
|
|
|
|
// update endpoint description using the discovery endpoint.
|
|
if (endpoint.UpdateBeforeConnect && connection == null)
|
|
{
|
|
await endpoint.UpdateFromServerAsync(messageContext.Telemetry, ct).ConfigureAwait(false);
|
|
endpointDescription = endpoint.Description;
|
|
// UpdateFromServerAsync re-reads Configuration from the discovery response;
|
|
// it is set whenever the description was updated successfully.
|
|
endpointConfiguration = endpoint.Configuration!;
|
|
}
|
|
|
|
// checks the domains in the certificate.
|
|
if (checkDomain && endpoint.Description.ServerCertificate.Length > 0)
|
|
{
|
|
using var certificate = Certificate.FromRawData(endpoint.Description.ServerCertificate);
|
|
ICertificateValidatorEx? validator = configuration.CertificateManager;
|
|
validator?.ValidateDomains(certificate, endpoint);
|
|
}
|
|
|
|
Certificate? clientCertificate = null;
|
|
CertificateCollection? clientCertificateChain = null;
|
|
try
|
|
{
|
|
if (endpointDescription.SecurityPolicyUri is not null and not SecurityPolicies.None)
|
|
{
|
|
clientCertificate = await Session.LoadInstanceCertificateAsync(
|
|
configuration,
|
|
endpointDescription.SecurityPolicyUri,
|
|
messageContext.Telemetry,
|
|
ct).ConfigureAwait(false);
|
|
clientCertificateChain = await Session.LoadCertificateChainAsync(
|
|
configuration,
|
|
clientCertificate,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
// initialize the channel which will be created with the server.
|
|
ITransportChannel channel;
|
|
if (connection != null)
|
|
{
|
|
channel = await UaChannelBase.CreateUaBinaryChannelAsync(
|
|
configuration,
|
|
connection,
|
|
endpointDescription,
|
|
endpointConfiguration,
|
|
clientCertificate,
|
|
clientCertificateChain,
|
|
messageContext,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
channel = await UaChannelBase.CreateUaBinaryChannelAsync(
|
|
configuration,
|
|
endpointDescription,
|
|
endpointConfiguration,
|
|
clientCertificate,
|
|
clientCertificateChain,
|
|
messageContext,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
|
|
// Ownership of the cert and chain has been transferred to the
|
|
// channel's TransportChannelSettings; the channel disposes
|
|
// them when it is disposed, so we must not dispose here.
|
|
clientCertificate = null;
|
|
clientCertificateChain = null;
|
|
return channel;
|
|
}
|
|
finally
|
|
{
|
|
clientCertificateChain?.Dispose();
|
|
clientCertificate?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual async Task<ISession> RecreateAsync(
|
|
ISession sessionTemplate,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (sessionTemplate is not Session template)
|
|
{
|
|
throw new ArgumentException(
|
|
"The ISession provided is not of a supported type.",
|
|
|
|
nameof(sessionTemplate));
|
|
}
|
|
|
|
template.ReturnDiagnostics = ReturnDiagnostics;
|
|
return await template.RecreateAsync(ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual async Task<ISession> RecreateAsync(
|
|
ISession sessionTemplate,
|
|
ITransportWaitingConnection connection,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (sessionTemplate is not Session template)
|
|
{
|
|
throw new ArgumentException(
|
|
"The ISession provided is not of a supported type.",
|
|
|
|
nameof(sessionTemplate));
|
|
}
|
|
|
|
template.ReturnDiagnostics = ReturnDiagnostics;
|
|
return await template.RecreateAsync(connection, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual async Task<ISession> RecreateAsync(
|
|
ISession sessionTemplate,
|
|
ITransportChannel transportChannel,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (sessionTemplate is not Session template)
|
|
{
|
|
throw new ArgumentException(
|
|
"The ISession provided is not of a supported type.",
|
|
|
|
nameof(sessionTemplate));
|
|
}
|
|
template.ReturnDiagnostics = ReturnDiagnostics;
|
|
return await template.RecreateAsync(transportChannel, ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public virtual ISession Create(
|
|
ITransportChannel channel,
|
|
ApplicationConfiguration configuration,
|
|
ConfiguredEndpoint endpoint,
|
|
Certificate? clientCertificate = null,
|
|
CertificateCollection? clientCertificateChain = null,
|
|
ArrayOf<EndpointDescription> availableEndpoints = default,
|
|
ArrayOf<string> discoveryProfileUris = default)
|
|
{
|
|
return new Session(
|
|
channel,
|
|
configuration,
|
|
endpoint,
|
|
clientCertificate,
|
|
clientCertificateChain,
|
|
availableEndpoints,
|
|
discoveryProfileUris,
|
|
SubscriptionEngineFactory,
|
|
TimeProvider)
|
|
{
|
|
ReturnDiagnostics = ReturnDiagnostics
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new communication session with a server using a reverse connection.
|
|
/// </summary>
|
|
/// <param name="configuration">The configuration for the client application.</param>
|
|
/// <param name="connection">The client endpoint for the reverse connect.</param>
|
|
/// <param name="endpoint">The endpoint for the server.</param>
|
|
/// <param name="updateBeforeConnect">If set to <c>true</c> the discovery endpoint is used to
|
|
/// update the endpoint description before connecting.</param>
|
|
/// <param name="checkDomain">If set to <c>true</c> then the domain in the certificate must match
|
|
/// the endpoint used.</param>
|
|
/// <param name="sessionName">The name to assign to the session.</param>
|
|
/// <param name="sessionTimeout">The timeout period for the session.</param>
|
|
/// <param name="identity">The user identity to associate with the session.</param>
|
|
/// <param name="preferredLocales">The preferred locales.</param>
|
|
/// <param name="returnDiagnostics">The return diagnostics to use on this session</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The new session object.</returns>
|
|
private async Task<ISession> CreateAsync(
|
|
ApplicationConfiguration configuration,
|
|
ITransportWaitingConnection? connection,
|
|
ConfiguredEndpoint endpoint,
|
|
bool updateBeforeConnect,
|
|
bool checkDomain,
|
|
string sessionName,
|
|
uint sessionTimeout,
|
|
IUserIdentity? identity,
|
|
ArrayOf<string> preferredLocales,
|
|
DiagnosticsMasks returnDiagnostics,
|
|
CancellationToken ct = default)
|
|
{
|
|
// initialize the channel which will be created with the server.
|
|
ITransportChannel channel = await CreateChannelAsync(
|
|
configuration,
|
|
connection,
|
|
endpoint,
|
|
updateBeforeConnect,
|
|
checkDomain,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
// create the session object.
|
|
ISession session = Create(channel, configuration, endpoint, null);
|
|
session.ReturnDiagnostics = returnDiagnostics;
|
|
|
|
// create the session.
|
|
UserIdentity? tempIdentity = identity == null ? new UserIdentity() : null;
|
|
try
|
|
{
|
|
await session
|
|
.OpenAsync(
|
|
sessionName,
|
|
sessionTimeout,
|
|
// tempIdentity is set when identity is null (line above), so the
|
|
// coalesce always yields a non-null IUserIdentity at runtime.
|
|
identity ?? tempIdentity!,
|
|
preferredLocales,
|
|
checkDomain,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
tempIdentity = null; // ownership transferred to session
|
|
}
|
|
catch (Exception)
|
|
{
|
|
session.Dispose();
|
|
throw;
|
|
}
|
|
|
|
return session;
|
|
}
|
|
}
|
|
}
|