diff --git a/docs/HighAvailability.md b/docs/HighAvailability.md index 7cd39ba29f..65b64c6dee 100644 --- a/docs/HighAvailability.md +++ b/docs/HighAvailability.md @@ -453,6 +453,8 @@ ArrayOf results = await coordinator.TransferActiveSubscriptionsA ct); ``` +A transferred subscription needs a client-side owner on the backup session, otherwise the backup's publish engine sees an unknown `SubscriptionId` and deletes it (or never publishes and it expires). Prepare the backup before the takeover: restore the active client's subscriptions with `ISession.Load` (or add `Subscription` objects whose `TransferId` is the active client's subscription id and whose monitored items carry the same client handles). The coordinator then transfers those through the session, which binds them and resumes publishing; ids without a prepared subscription are still moved with the raw service and are left to the caller. + OPC UA does not standardize how active and backup clients exchange `SessionId` or subscription ids. In this stack the client replica set coordinates through the registered client-side shared store (`AddRedundantClientSharedStore` / `AddRaftClientSharedStore` — a CRDT- or [Raft](https://raft.github.io/)-backed `ISharedKeyValueStore` that the `ClientReplicaCoordinator` consumes). ## Network redundancy (as per Part 4 §6.6.4) diff --git a/samples/Reference/ConsoleReferenceClient/UAClient.cs b/samples/Reference/ConsoleReferenceClient/UAClient.cs index 8c0918bd95..a894d9fcfc 100644 --- a/samples/Reference/ConsoleReferenceClient/UAClient.cs +++ b/samples/Reference/ConsoleReferenceClient/UAClient.cs @@ -240,7 +240,8 @@ public async Task ConnectAsync( useSecurity, m_telemetry, ct - ).ConfigureAwait(false); + ).ConfigureAwait(false) ?? + throw NoMatchingEndpoint(useSecurity); connection = null; } } while (connection == null); @@ -253,7 +254,8 @@ public async Task ConnectAsync( serverUrl, useSecurity, m_telemetry, - ct).ConfigureAwait(false); + ct).ConfigureAwait(false) ?? + throw NoMatchingEndpoint(useSecurity); } // Get the endpoint by connecting to server's discovery endpoint. @@ -322,6 +324,21 @@ public async Task ConnectAsync( } } + /// + /// The error reported when discovery found no endpoint that satisfies + /// the security requirement (SelectEndpoint returned null). + /// + private static ServiceResultException NoMatchingEndpoint(bool useSecurity) + { + return useSecurity + ? new ServiceResultException( + StatusCodes.BadSecurityPolicyRejected, + "The server offers no secure endpoint supported by this client.") + : new ServiceResultException( + StatusCodes.BadNotFound, + "The server offers no matching endpoint."); + } + private ValueTask CreateUserIdentityAsync( EndpointDescription endpointDescription, CancellationToken ct) diff --git a/src/Opc.Ua.Client.ComplexTypes/Types/UnionComplexType.cs b/src/Opc.Ua.Client.ComplexTypes/Types/UnionComplexType.cs index de41dde048..8e2895802f 100644 --- a/src/Opc.Ua.Client.ComplexTypes/Types/UnionComplexType.cs +++ b/src/Opc.Ua.Client.ComplexTypes/Types/UnionComplexType.cs @@ -138,13 +138,15 @@ public override void Decode(IDecoder decoder) bool isJsonDecoder = decoder.EncodingType == EncodingType.Json; if (unionSelector == 0 && isJsonDecoder) { + // The Verbose JSON encoding writes no SwitchField, so the + // selector has to be recovered from the member name. Every + // union field is a candidate: union fields are never + // IsOptional (Part 3 §8.51), so filtering by it left the list + // empty and every such union decoded as null. var fields = new List(); foreach (ComplexTypePropertyInfo property in GetPropertyEnumerator()) { - if (property.IsOptional) - { - fields.Add(property.Name); - } + fields.Add(property.Name); } unionSelector = decoder.ReadSwitchField(fields, out _); diff --git a/src/Opc.Ua.Client/AliasNames/AliasNameClientErrors.cs b/src/Opc.Ua.Client/AliasNames/AliasNameClientErrors.cs index 7d8a84d273..5fecf7f570 100644 --- a/src/Opc.Ua.Client/AliasNames/AliasNameClientErrors.cs +++ b/src/Opc.Ua.Client/AliasNames/AliasNameClientErrors.cs @@ -55,8 +55,12 @@ public static Exception Translate( : string.Empty) + "."); } + // BadMethodInvalid is what a server returns when the type-level + // MethodId is called on a category that does not expose the + // (optional) method (Part 4 §5.12.2.4), so it means "not supported". if (code == StatusCodes.BadNotSupported || - code == StatusCodes.BadNotImplemented) + code == StatusCodes.BadNotImplemented || + code == StatusCodes.BadMethodInvalid) { return new NotSupportedException( $"OPC UA Part 17 {operation} is not supported by this " + diff --git a/src/Opc.Ua.Client/AliasNames/AliasNameResolver.cs b/src/Opc.Ua.Client/AliasNames/AliasNameResolver.cs index 48439da239..61128e0c1c 100644 --- a/src/Opc.Ua.Client/AliasNames/AliasNameResolver.cs +++ b/src/Opc.Ua.Client/AliasNames/AliasNameResolver.cs @@ -31,6 +31,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Opc.Ua.Client.AliasNames.Refresh; namespace Opc.Ua.Client.AliasNames @@ -81,6 +82,8 @@ public AliasNameResolver( Options = (options ?? new AliasNameResolverOptions()).Clone(); m_strategy = Options.RefreshStrategy ?? BuildBuiltInStrategy(Options); + m_logger = client.Session.MessageContext.Telemetry + .CreateLogger(); } /// The wrapped . @@ -104,7 +107,19 @@ public async Task EnsureLoadedAsync(CancellationToken ct = default) { return; } - await EnsureStrategyStartedAsync(ct).ConfigureAwait(false); + try + { + await EnsureStrategyStartedAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException || + !ct.IsCancellationRequested) + { + // The refresh strategy only drives invalidation; a server that + // rejects its subscription (or lacks the optional LastChange) + // must not make alias resolution fail. Run degraded (manual + // refresh) and retry the start on the next load. + m_logger.AliasRefreshStrategyStartFailed(ex, m_strategy.GetType().Name); + } await RefreshAsync(ct).ConfigureAwait(false); } @@ -391,10 +406,29 @@ private static void PopulateFromNonVerbose( arr[i] = a.ReferencedNodes[i]; reverse[arr[i]] = key; } - forward[key] = arr; + forward[key] = Merge(forward, key, arr); } } + /// + /// The cache is keyed by only, but + /// FindAlias may legally return the same name more than once (other + /// namespace, or defined in several sub-categories). Append the + /// targets of a repeated name instead of replacing the earlier ones. + /// + /// The element type of the cached arrays. + private static T[] Merge(Dictionary map, string key, T[] values) + { + if (!map.TryGetValue(key, out T[]? existing) || existing.Length == 0) + { + return values; + } + var merged = new T[existing.Length + values.Length]; + Array.Copy(existing, merged, existing.Length); + Array.Copy(values, 0, merged, existing.Length, values.Length); + return merged; + } + private static void PopulateFromVerbose( IReadOnlyList aliases, Dictionary forward, @@ -413,14 +447,17 @@ private static void PopulateFromVerbose( uris[i] = i < a.ServerUris.Count ? a.ServerUris[i] : null; reverse[arr[i]] = key; } - forward[key] = arr; - serverUris[key] = uris; + // Merge both maps the same way so the ServerUris stay + // parallel to the targets. + forward[key] = Merge(forward, key, arr); + serverUris[key] = Merge(serverUris, key, uris); } } private readonly SemaphoreSlim m_semaphore = new(1, 1); private readonly SemaphoreSlim m_strategyStartLock = new(1, 1); private readonly IAliasNameRefreshStrategy m_strategy; + private readonly ILogger m_logger; private Dictionary m_forward = new(StringComparer.Ordinal); @@ -446,4 +483,18 @@ private Dictionary m_forward private long m_invalidationGeneration; private int m_strategyStarted; } + + /// + /// Source-generated logging for . + /// + internal static partial class AliasNameResolverLog + { + [LoggerMessage(EventId = ClientEventIds.AliasNameResolver + 0, Level = LogLevel.Warning, + Message = "Alias refresh strategy {Strategy} failed to start; resolving without " + + "automatic invalidation and retrying the start on the next load.")] + public static partial void AliasRefreshStrategyStartFailed( + this ILogger logger, + Exception exception, + string strategy); + } } diff --git a/src/Opc.Ua.Client/Browser.cs b/src/Opc.Ua.Client/Browser.cs index 4ab3ddea72..c4d6eaa572 100644 --- a/src/Opc.Ua.Client/Browser.cs +++ b/src/Opc.Ua.Client/Browser.cs @@ -298,6 +298,7 @@ public async ValueTask> BrowseAsync( // fetch initial set of references. ByteString continuationPoint = results[0].ContinuationPoint; ArrayOf references = results[0].References; + int emptyRounds = references.IsEmpty ? 1 : 0; try { @@ -342,23 +343,32 @@ public async ValueTask> BrowseAsync( if (!additionalReferences.IsEmpty) { references = references.AddItems(additionalReferences); + emptyRounds = 0; } - else + else if (!continuationPoint.IsEmpty && + ++emptyRounds >= kMaxEmptyBrowseNextRounds) { - m_logger.BrowserContinuationPointExistsButBrowse(); - break; + // An empty page with a continuation point is a legal + // "not done yet" answer (Part 4 §5.9.2.1), so it is + // followed; a server that never makes progress would + // otherwise keep this loop running forever. The catch + // below releases the point. + m_logger.BrowserServerReturnedEmptyReferencesBut(emptyRounds); + throw new ServiceResultException( + StatusCodes.BadNoData, + "The server kept returning a continuation point without references."); } } } - catch (OperationCanceledException) when (!continuationPoint.IsEmpty) + catch (Exception) when (!continuationPoint.IsEmpty) { // Release the continuation point before propagating: returning // the partial list here would hand the caller a silently // truncated result that looks like a complete browse. The - // release is best effort - cancellation is often caused by the - // very session or channel it would travel on, and letting that - // failure out would replace the cancellation the caller is - // waiting to see. + // release is best effort - cancellation or a failure is often + // caused by the very session or channel it would travel on, + // and letting that failure out would replace the exception the + // caller is waiting to see. session = Session; if (session != null) { @@ -459,7 +469,11 @@ public async ValueTask>> BrowseAsync( } } - // split input into batches + // split input into batches. MaxNodesPerBrowse is a UInt32 and + // servers may advertise values above int.MaxValue (e.g. + // 0xFFFFFFFF as "unlimited"); a batch that large is a single + // batch, and casting it would yield a negative batch size. + int batchSize = maxNodesPerBrowse > int.MaxValue ? 0 : (int)maxNodesPerBrowse; int batchOffset = 0; var nodesToBrowseForNextPass = new List(); @@ -469,7 +483,7 @@ var referenceDescriptionsForNextPass // loop over the batches foreach (ArrayOf nodesToBrowseBatch in - nodesToBrowseForPass.ToArrayOf().Batch((int)maxNodesPerBrowse)) + nodesToBrowseForPass.ToArrayOf().Batch(batchSize)) { int nodesToBrowseBatchCount = nodesToBrowseBatch.Count; @@ -483,6 +497,7 @@ var referenceDescriptionsForNextPass state.ReferenceTypeId, state.IncludeSubtypes, state.NodeClassMask, + m_logger, ct) .ConfigureAwait(false); @@ -624,6 +639,18 @@ var referenceDescriptionsForNextPass /// the browse description (NodeId, BrowseDirection, ReferenceTypeId, /// IncludeSubtypes, NodeClassMask, ResultMask). /// + /// + /// The stream is a sequence of pages. The first page holds one result + /// per entry of , in request order. + /// Every following page holds one result per result of the previous + /// page that carried a non-empty ContinuationPoint, in the order of + /// those results, so a consumer correlates follow-up references with + /// their node by tracking which results carried a continuation point. + /// A result with a continuation point but no references is a legal + /// "not done yet" answer and is followed; after too many such pages in + /// a row the point is released and a + /// result takes its place. + /// /// The request header. /// The view to browse. /// The set of browse operations to perform. @@ -647,84 +674,87 @@ public async IAsyncEnumerable BrowseStreamAsync( ClientBase.ValidateResponse(first.Results, nodesToBrowse); ClientBase.ValidateDiagnosticInfos(first.DiagnosticInfos, nodesToBrowse); + // Continuation points of the page being handed out. They are + // collected before the first result of the page is yielded, so a + // consumer that stops early releases every one of them, not just + // those of the results it already saw. var continuationPoints = new List(); - for (int i = 0; i < first.Results.Count; i++) - { - BrowseResult result = first.Results[i]; - if (StatusCode.IsGood(result.StatusCode) && - !result.ContinuationPoint.IsNull && - result.ContinuationPoint.Length != 0) - { - if (result.References.Count > 0) - { - continuationPoints.Add(result.ContinuationPoint); - } - else - { - m_logger.BrowserServerReturnedEmptyReferencesBut(); - yield return new BrowseResult - { - StatusCode = StatusCodes.BadNoData - }; - continue; - } - } - yield return result; - } - try { - while (continuationPoints.Count > 0) + ArrayOf page = first.Results; + List? pageEmptyRounds = null; + while (true) { - BrowseNextResponse next = await session.BrowseNextAsync( - requestHeader, false, - continuationPoints.ToArrayOf(), ct).ConfigureAwait(false); - ClientBase.ValidateResponse( - next.Results, continuationPoints.ToArrayOf()); - ClientBase.ValidateDiagnosticInfos( - next.DiagnosticInfos, continuationPoints.ToArrayOf()); - - continuationPoints = []; - for (int i = 0; i < next.Results.Count; i++) + var pageResults = new BrowseResult[page.Count]; + var nextEmptyRounds = new List(); + List? abandoned = null; + for (int i = 0; i < page.Count; i++) { - BrowseResult result = next.Results[i]; + BrowseResult result = page[i]; if (StatusCode.IsGood(result.StatusCode) && !result.ContinuationPoint.IsNull && result.ContinuationPoint.Length != 0) { - if (result.References.Count > 0) - { - continuationPoints.Add(result.ContinuationPoint); - } - else + // An empty page with a continuation point is a + // legal "not done yet" answer (Part 4 §5.9.2.1), + // so it is followed, but only for a bounded number + // of consecutive rounds. + int emptyRounds = result.References.Count == 0 + ? (pageEmptyRounds?[i] ?? 0) + 1 + : 0; + if (emptyRounds >= kMaxEmptyBrowseNextRounds) { - m_logger.BrowserServerReturnedEmptyReferencesBut(); - yield return new BrowseResult + m_logger.BrowserServerReturnedEmptyReferencesBut(emptyRounds); + (abandoned ??= []).Add(result.ContinuationPoint); + result = new BrowseResult { StatusCode = StatusCodes.BadNoData }; - continue; + } + else + { + continuationPoints.Add(result.ContinuationPoint); + nextEmptyRounds.Add(emptyRounds); } } + pageResults[i] = result; + } + + if (abandoned != null) + { + await ReleaseContinuationPointsAsync(session, abandoned, m_logger) + .ConfigureAwait(false); + } + + foreach (BrowseResult result in pageResults) + { yield return result; } + + if (continuationPoints.Count == 0) + { + break; + } + + ArrayOf requested = continuationPoints.ToArrayOf(); + BrowseNextResponse next = await session.BrowseNextAsync( + requestHeader, false, requested, ct).ConfigureAwait(false); + + // The server consumed the points it answered; the revised + // ones are in the results. + continuationPoints = []; + ClientBase.ValidateResponse(next.Results, requested); + ClientBase.ValidateDiagnosticInfos(next.DiagnosticInfos, requested); + page = next.Results; + pageEmptyRounds = nextEmptyRounds; } } finally { if (continuationPoints.Count > 0) { - try - { - await session.BrowseNextAsync( - requestHeader, true, - continuationPoints.ToArrayOf(), - default).ConfigureAwait(false); - } - catch (Exception ex) - { - m_logger.BrowserFailedReleaseContinuationPoints(ex); - } + await ReleaseContinuationPointsAsync(session, continuationPoints, m_logger) + .ConfigureAwait(false); } } } @@ -745,6 +775,7 @@ private static async ValueTask>> BrowseA NodeId referenceTypeId, bool includeSubtypes, int nodeClassMask, + ILogger logger, CancellationToken ct = default) { requestHeader?.RequestHandle = 0; @@ -782,6 +813,13 @@ ArrayOf errors var nextResults = new List>(); var nextErrors = new List>(); + // Consecutive pages without references per followed point. An + // empty page with a continuation point is a legal "not done yet" + // answer (Part 4 §5.9.2.1) and is followed, but only for a bounded + // number of rounds so a server that never makes progress cannot + // keep this loop running forever. + var nextEmptyRounds = new List(); + for (int ii = 0; ii < nodeIds.Count; ii++) { if (!continuationPoints[ii].IsEmpty && @@ -790,6 +828,7 @@ ArrayOf errors nextContinuationPoints.Add(continuationPoints[ii]); nextResults.Add(previousResults[ii]); nextErrors.Add(previousErrors[ii]); + nextEmptyRounds.Add(referenceDescriptions[ii].IsEmpty ? 1 : 0); } } while (nextContinuationPoints.Count > 0) @@ -814,21 +853,44 @@ ArrayOf browseNextErrors previousResults = nextResults; previousErrors = nextErrors; + List previousEmptyRounds = nextEmptyRounds; nextResults = []; nextErrors = []; nextContinuationPoints = []; + nextEmptyRounds = []; + List? abandoned = null; for (int ii = 0; ii < revisedContinuationPoints.Count; ii++) { if (!revisedContinuationPoints[ii].IsEmpty && !StatusCode.IsBad(browseNextErrors[ii].StatusCode)) { + int emptyRounds = browseNextResults[ii].IsEmpty + ? previousEmptyRounds[ii] + 1 + : 0; + if (emptyRounds >= kMaxEmptyBrowseNextRounds) + { + logger.BrowserServerReturnedEmptyReferencesBut(emptyRounds); + previousErrors[ii].Reference = new ServiceResult( + StatusCodes.BadNoData, + LocalizedText.From( + "The server kept returning a continuation point without references.")); + (abandoned ??= []).Add(revisedContinuationPoints[ii]); + continue; + } nextContinuationPoints.Add(revisedContinuationPoints[ii]); nextResults.Add(previousResults[ii]); nextErrors.Add(previousErrors[ii]); + nextEmptyRounds.Add(emptyRounds); } } + + if (abandoned != null) + { + await ReleaseContinuationPointsAsync(session, abandoned, logger) + .ConfigureAwait(false); + } } var finalErrors = new List(errorAnchors.Count); foreach (ReferenceWrapper errorReference in errorAnchors) @@ -839,6 +901,30 @@ ArrayOf browseNextErrors return ResultSet.From(result.ConvertAll(l => (ArrayOf)l), finalErrors); } + /// + /// Releases continuation points the browse stops following. Best + /// effort: a failure is logged, the server reclaims the points when + /// the session closes (Part 4 §7.9). + /// + private static async ValueTask ReleaseContinuationPointsAsync( + ISessionClient session, + List continuationPoints, + ILogger logger) + { + try + { + await session.BrowseNextAsync( + null, + true, + continuationPoints.ToArrayOf(), + default).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.BrowserFailedReleaseContinuationPoints(ex); + } + } + /// /// Fetches the next batch of references. /// @@ -945,6 +1031,43 @@ private class ReferenceWrapper /// private const int kMaxManagedBrowsePassesWithoutProgress = 100; + /// + /// Consecutive pages without references a browse follows for one + /// continuation point before it gives up and releases the point. A + /// server that runs out of time may legally answer with zero + /// references and a continuation point (Part 4 §5.9.2.1), so one + /// empty page is not the end of the data; each such page already + /// took the server's processing time, so no extra back off is added. + /// + private const int kMaxEmptyBrowseNextRounds = 10; + + /// + /// Guards a hand-written Browse/BrowseNext loop against a server that + /// never makes progress: counts consecutive pages without references + /// that still carry a continuation point and throws BadNoData after + /// of them. An empty page with + /// a continuation point is legal (Part 4 §5.9.2.1) and is followed + /// until then. The caller must release the continuation point. + /// + /// + internal static void ThrowIfNoBrowseProgress( + ArrayOf references, + ByteString continuationPoint, + ref int emptyRounds) + { + if (!references.IsEmpty) + { + emptyRounds = 0; + } + else if (!continuationPoint.IsEmpty && + ++emptyRounds >= kMaxEmptyBrowseNextRounds) + { + throw new ServiceResultException( + StatusCodes.BadNoData, + "The server kept returning a continuation point without references."); + } + } + /// /// Delay before the first retry of a managed browse pass that /// completed nothing. @@ -1006,10 +1129,6 @@ internal BrowserEventArgs(ArrayOf references) /// internal static partial class BrowserLog { - [LoggerMessage(EventId = ClientEventIds.Browser + 0, Level = LogLevel.Warning, - Message = "Browser: Continuation point exists, but the browse results are null/empty.")] - public static partial void BrowserContinuationPointExistsButBrowse(this ILogger logger); - [LoggerMessage(EventId = ClientEventIds.Browser + 1, Level = LogLevel.Debug, Message = "ManagedBrowse: in pass {Pass}, {Count} error(s) occured with a status code {StatusCode}.")] public static partial void ManagedBrowsePassPassCountErrorS( @@ -1023,9 +1142,9 @@ public static partial void ManagedBrowsePassPassCountErrorS( public static partial void ManagedBrowseCompletedNoErrors(this ILogger logger); [LoggerMessage(EventId = ClientEventIds.Browser + 3, Level = LogLevel.Warning, - Message = "Browser: Server returned empty references but a continuation point. Stopping to prevent" + - " denial of service.")] - public static partial void BrowserServerReturnedEmptyReferencesBut(this ILogger logger); + Message = "Browser: Server returned no references but a continuation point {Rounds} times in a" + + " row. Releasing the continuation point to prevent denial of service.")] + public static partial void BrowserServerReturnedEmptyReferencesBut(this ILogger logger, int rounds); [LoggerMessage(EventId = ClientEventIds.Browser + 4, Level = LogLevel.Error, Message = "Browser: Failed to release continuation points.")] diff --git a/src/Opc.Ua.Client/CoreClientUtils.cs b/src/Opc.Ua.Client/CoreClientUtils.cs index d663820567..d81b24976e 100644 --- a/src/Opc.Ua.Client/CoreClientUtils.cs +++ b/src/Opc.Ua.Client/CoreClientUtils.cs @@ -101,7 +101,14 @@ public static async ValueTask> DiscoverServersAsync( for (int jj = 0; jj < servers[ii].DiscoveryUrls.Count; jj++) { - string discoveryUrl = servers[ii].DiscoveryUrls[jj]; + string? discoveryUrl = servers[ii].DiscoveryUrls[jj]; + + // a null entry is encodable but meaningless; skip it + // rather than failing the discovery of every server. + if (string.IsNullOrEmpty(discoveryUrl)) + { + continue; + } // Many servers will use the '/discovery' suffix for the discovery endpoint. // The URL without this prefix should be the base URL for the server. @@ -283,6 +290,11 @@ public static async ValueTask> DiscoverServersAsync( /// /// Select the best supported endpoint using the specified security policies. /// + /// The best matching endpoint, or null if none matches. + /// With set, an endpoint without + /// message security is never returned, except an HTTPS endpoint with + /// SecurityMode None when the discovery URL is HTTPS (TLS protects it) + /// and no endpoint with message security matches. public static EndpointDescription? SelectEndpoint( ApplicationConfiguration configuration, Uri url, @@ -306,7 +318,7 @@ public static async ValueTask> DiscoverServersAsync( // check if security was requested. if (useSecurity) { - if (endpoint.SecurityMode == MessageSecurityMode.None) + if (!IsSecureMode(endpoint.SecurityMode)) { continue; } @@ -360,17 +372,44 @@ public static async ValueTask> DiscoverServersAsync( } } - // pick the first available endpoint by default. + // pick the first available endpoint by default. When security was + // requested the fallback must never hand out a None endpoint over a + // transport without TLS: the endpoint list comes over an unsecured + // discovery channel, so doing so would let a rogue server silently + // downgrade the connection. HTTPS endpoints are the exception: the + // discovery ran over TLS and an HTTPS endpoint with SecurityMode None + // is still TLS protected (Part 6, 7.4.1). if (selectedEndpoint == null && endpoints.Count > 0) { + bool tlsDiscovery = IsHttpsScheme(url.Scheme); selectedEndpoint = endpoints.Find(e => - e.EndpointUrl?.StartsWith(url.Scheme, StringComparison.Ordinal) == true); + e.EndpointUrl?.StartsWith(url.Scheme, StringComparison.Ordinal) == true && + (!useSecurity || + IsSecureMode(e.SecurityMode) || + (tlsDiscovery && Utils.IsUriHttpsScheme(e.EndpointUrl)))); } // return the selected endpoint. return selectedEndpoint; } + /// + /// Whether the mode signs (and possibly encrypts) messages. + /// + private static bool IsSecureMode(MessageSecurityMode mode) + { + return mode is MessageSecurityMode.Sign or MessageSecurityMode.SignAndEncrypt; + } + + /// + /// Whether the URI scheme is carried over TLS (https or opc.https). + /// + private static bool IsHttpsScheme(string scheme) + { + return string.Equals(scheme, Utils.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + string.Equals(scheme, Utils.UriSchemeOpcHttps, StringComparison.OrdinalIgnoreCase); + } + /// /// Convert the discoveryUrl to a Uri and modify endpoint as per connection scheme if required. /// diff --git a/src/Opc.Ua.Client/EventIds.cs b/src/Opc.Ua.Client/EventIds.cs index 49285a18c5..42f78afa7b 100644 --- a/src/Opc.Ua.Client/EventIds.cs +++ b/src/Opc.Ua.Client/EventIds.cs @@ -64,6 +64,7 @@ internal static class ClientEventIds public const int SessionClientExtensions = 500; public const int ManagedSessionPool = 510; public const int DefaultSessionFactory = 520; + public const int AliasNameResolver = 560; /// /// The category name that diff --git a/src/Opc.Ua.Client/FileSystem/FileSystemClient.cs b/src/Opc.Ua.Client/FileSystem/FileSystemClient.cs index 229388e67e..b697211a53 100644 --- a/src/Opc.Ua.Client/FileSystem/FileSystemClient.cs +++ b/src/Opc.Ua.Client/FileSystem/FileSystemClient.cs @@ -189,8 +189,47 @@ public async ValueTask GetFileAsync( { return Root; } - ResolvedNode? resolved = await ResolveSegmentsAsync(segments, throwOnMissing: false, ct) - .ConfigureAwait(false); + var usedCache = new StrongBox(); + try + { + UaFileSystemInfo? info = await TryGetInfoAsync(segments, usedCache, ct) + .ConfigureAwait(false); + if (info != null || !usedCache.Value) + { + return info; + } + } + catch (ServiceResultException ex) when (usedCache.Value && IsStaleNodeError(ex)) + { + // Fall through: retry below without the cache. + } + + // A cached parent -> child NodeId is only a hint: another client + // (or the server itself) may have deleted, moved or recreated the + // node since. Resolve once more straight from the server; that + // also replaces or evicts the stale cache entries. + segments = UaPath.Parse(path); + try + { + return await TryGetInfoAsync(segments, usedCache: null, ct).ConfigureAwait(false); + } + catch (ServiceResultException ex) when (IsStaleNodeError(ex)) + { + // Deleted between the resolution and the classification. + return null; + } + } + + private async ValueTask TryGetInfoAsync( + QualifiedName[] segments, + StrongBox? usedCache, + CancellationToken ct) + { + ResolvedNode? resolved = await ResolveSegmentsAsync( + segments, + throwOnMissing: false, + usedCache, + ct).ConfigureAwait(false); if (resolved == null) { return null; @@ -198,6 +237,12 @@ public async ValueTask GetFileAsync( return await BuildInfoAsync(resolved.Value, segments, ct).ConfigureAwait(false); } + private static bool IsStaleNodeError(ServiceResultException ex) + { + uint code = ex.StatusCode.Code; + return code == StatusCodes.BadNodeIdUnknown || code == StatusCodes.BadNoMatch; + } + /// /// Returns true when something (file or directory) /// exists at . @@ -253,9 +298,10 @@ public async ValueTask CreateDirectoryAsync( { bool isLast = i == segments.Length - 1; QualifiedName inputSegment = segments[i]; - QualifiedName segment = Qualify(inputSegment, current.NodeId); - NodeId? childId = await TryResolveSingleAsync(current.NodeId, segment, ct) - .ConfigureAwait(false); + (NodeId? childId, QualifiedName segment) = await TryResolveChildAsync( + current.NodeId, + inputSegment, + ct).ConfigureAwait(false); if (childId != null) { segments[i] = segment; @@ -559,10 +605,12 @@ internal async IAsyncEnumerable EnumerateChildrenAsync( (uint)NodeClass.Object, ct).ConfigureAwait(false); + int emptyRounds = 0; try { while (true) { + Browser.ThrowIfNoBrowseProgress(references, continuation, ref emptyRounds); // Materialise to an array first — ReadOnlySpan.Enumerator // (returned by ArrayOf.GetEnumerator) cannot cross an // async iterator's `yield return` boundary. @@ -605,6 +653,69 @@ await Session.ReleaseContinuationPointAsync(continuation, m_logger) } } + /// + /// Returns true when has any + /// FileType or FileDirectoryType (or subtype) child, regardless of the + /// subtype filters. + /// + private async ValueTask HasFileSystemChildrenAsync( + UaDirectoryInfo directory, + CancellationToken ct) + { + await EnsureTypeTreeFetchedAsync(ct).ConfigureAwait(false); + ITypeTable typeTree = Session.TypeTree; + + ByteString continuation; + ArrayOf references; + (_, continuation, references) = await Session.BrowseAsync( + requestHeader: null, + view: null, + directory.NodeId, + maxResultsToReturn: 0, + BrowseDirection.Forward, + ReferenceTypeIds.HierarchicalReferences, + includeSubtypes: true, + (uint)NodeClass.Object, + ct).ConfigureAwait(false); + int emptyRounds = 0; + try + { + while (true) + { + Browser.ThrowIfNoBrowseProgress(references, continuation, ref emptyRounds); + for (int i = 0; i < references.Count; i++) + { + var typeDef = ExpandedNodeId.ToNodeId( + references[i].TypeDefinition, + Session.MessageContext.NamespaceUris); + if (!typeDef.IsNull && + (typeDef.Equals(kFileTypeId) || + typeDef.Equals(kFileDirectoryTypeId) || + typeTree.IsTypeOf(typeDef, kFileTypeId) || + typeTree.IsTypeOf(typeDef, kFileDirectoryTypeId))) + { + return true; + } + } + if (continuation.IsNull || continuation.Length == 0) + { + return false; + } + (_, continuation, references) = await Session.BrowseNextAsync( + requestHeader: null, + releaseContinuationPoint: false, + continuation, + ct).ConfigureAwait(false); + } + } + finally + { + // Part 4 §5.9.3.2: release a point we stop following. + await Session.ReleaseContinuationPointAsync(continuation, m_logger) + .ConfigureAwait(false); + } + } + internal async ValueTask CreateDirectoryInAsync( UaDirectoryInfo parent, string name, @@ -669,14 +780,15 @@ internal async ValueTask DeleteCoreAsync( throw new IOException("Cannot delete the root directory."); } - if (target is UaDirectoryInfo dir && !recursive) + // Empty-check before delegating to the server, whose Delete on a + // directory always removes everything below it (Part 20 §4.3.5). + // The check must see every file / directory child, including the + // subtypes EnumerateAsync filters out through the options. + if (target is UaDirectoryInfo dir && !recursive && + await HasFileSystemChildrenAsync(dir, ct).ConfigureAwait(false)) { - // Empty-check before delegating to server. - await foreach (UaFileSystemInfo _ in dir.EnumerateAsync(ct).ConfigureAwait(false)) - { - throw new IOException( - $"Directory '{target.FullPath}' is not empty; pass recursive: true to delete recursively."); - } + throw new IOException( + $"Directory '{target.FullPath}' is not empty; pass recursive: true to delete recursively."); } try @@ -933,9 +1045,20 @@ private async ValueTask GetOrCreateFileAsync(string path, Cancellati return file; } + /// + /// Resolves to a node. + /// + /// The path segments; qualified in place. + /// Throw instead of returning null. + /// When supplied, cached entries are used and + /// the flag records whether any was; when null every segment is + /// resolved from the server (bypassing, refreshing and evicting stale + /// cache entries). + /// Cancellation token. private async ValueTask ResolveSegmentsAsync( QualifiedName[] segments, bool throwOnMissing, + StrongBox? usedCache, CancellationToken ct) { // Walk segment-by-segment, leveraging the path cache when @@ -949,22 +1072,29 @@ private async ValueTask GetOrCreateFileAsync(string path, Cancellati for (int i = 0; i < segments.Length; i++) { bool isLast = i == segments.Length - 1; - QualifiedName segment = Qualify(segments[i], currentParent); - NodeId? cached = m_pathCache.TryGet(currentParent, segment); + QualifiedName input = segments[i]; + (NodeId Id, QualifiedName Name)? cached = usedCache != null + ? TryGetCachedChild(currentParent, Qualify(input, currentParent), input) + : null; if (cached != null) { - segments[i] = segment; + usedCache!.Value = true; + segments[i] = cached.Value.Name; if (isLast) { - return new ResolvedNode(cached.Value, segment); + return new ResolvedNode(cached.Value.Id, cached.Value.Name); } - currentParent = cached.Value; + currentParent = cached.Value.Id; continue; } - NodeId? resolved = await TryResolveSingleAsync(currentParent, segment, ct) - .ConfigureAwait(false); + (NodeId? resolved, QualifiedName segment) = await TryResolveChildAsync( + currentParent, + input, + ct).ConfigureAwait(false); if (resolved == null) { + m_pathCache.Invalidate(currentParent, segment); + m_pathCache.Invalidate(currentParent, input); if (throwOnMissing) { throw FileSystemErrors.NotFound( @@ -1000,26 +1130,58 @@ private static QualifiedName Qualify(QualifiedName segment, NodeId parent) return new QualifiedName(segment.Name, parent.NamespaceIndex); } - private async ValueTask TryResolveSingleAsync( + /// + /// Looks up the cached child for (already + /// qualified) or, failing that, for the namespace-zero + /// (see ). + /// + private (NodeId Id, QualifiedName Name)? TryGetCachedChild( NodeId parent, QualifiedName segment, - CancellationToken ct) + QualifiedName input) { - var element = new RelativePathElement + NodeId? cached = m_pathCache.TryGet(parent, segment); + if (cached != null) { - ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, - IsInverse = false, - IncludeSubtypes = true, - TargetName = segment - }; - ArrayOf browsePaths = new[] + return (cached.Value, segment); + } + if (!segment.Equals(input)) { - new BrowsePath + cached = m_pathCache.TryGet(parent, input); + if (cached != null) { - StartingNode = parent, - RelativePath = new RelativePath { Elements = [element] } + return (cached.Value, input); } - }.ToArrayOf(); + } + return null; + } + + /// + /// Resolves the child of named by the path + /// segment . An unprefixed (namespace-zero) + /// segment is first looked up in the parent's namespace + /// () and, when nothing matches there, as the + /// namespace-zero name itself, so a namespace-zero child of a + /// namespaced directory stays addressable by the path its + /// reports. Both candidates go + /// out in one TranslateBrowsePathsToNodeIds call. + /// + private async ValueTask<(NodeId? Id, QualifiedName Name)> TryResolveChildAsync( + NodeId parent, + QualifiedName input, + CancellationToken ct) + { + QualifiedName segment = Qualify(input, parent); + bool withFallback = !segment.Equals(input); + var browsePathList = new List(2) + { + CreateChildBrowsePath(parent, segment) + }; + if (withFallback) + { + browsePathList.Add(CreateChildBrowsePath(parent, input)); + } + ArrayOf browsePaths = browsePathList.ToArrayOf(); TranslateBrowsePathsToNodeIdsResponse response = await Session .TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct) @@ -1027,7 +1189,38 @@ private static QualifiedName Qualify(QualifiedName segment, NodeId parent) ClientBase.ValidateResponse(response.Results, browsePaths); ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, browsePaths); - BrowsePathResult result = response.Results[0]; + NodeId? resolved = GetSingleTarget(response.Results[0], segment); + if (resolved != null || !withFallback) + { + return (resolved, segment); + } + resolved = GetSingleTarget(response.Results[1], input); + return resolved != null ? (resolved, input) : (null, segment); + } + + private static BrowsePath CreateChildBrowsePath(NodeId parent, QualifiedName segment) + { + return new BrowsePath + { + StartingNode = parent, + RelativePath = new RelativePath + { + Elements = + [ + new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = segment + } + ] + } + }; + } + + private NodeId? GetSingleTarget(BrowsePathResult result, QualifiedName segment) + { uint code = result.StatusCode.Code; if (code == StatusCodes.BadNoMatch || code == StatusCodes.BadNodeIdUnknown) @@ -1068,6 +1261,7 @@ private async ValueTask BuildInfoAsync( ResolvedNode parent = (await ResolveSegmentsAsync( parentSegments, throwOnMissing: true, + usedCache: new StrongBox(), ct).ConfigureAwait(false))!.Value; parentInfo = new UaDirectoryInfo( this, diff --git a/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs b/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs index 30ff11cbcb..25200a6fc3 100644 --- a/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs +++ b/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.IO; using System.Threading; using System.Threading.Tasks; @@ -108,14 +109,24 @@ public TemporaryFileTransferClient( /// handle; disposing the stream issues Close per the /// regular FileType lifecycle. /// + /// + /// When the server generates the file asynchronously it returns a + /// FileTransferStateMachineType object, and Read fails + /// until its state reaches ReadTransfer (Part 20 §4.4.3). This + /// method waits for that transition (bounded by + /// ) before returning the stream. + /// /// Server-defined generation /// options; pass default for "no options". /// Cancellation token. + /// The server reported that + /// generating the file failed (the state machine entered + /// Error). public async ValueTask GenerateFileForReadAsync( Variant generateOptions = default, CancellationToken ct = default) { - (NodeId fileNodeId, uint handle, _) = await Proxy + (NodeId fileNodeId, uint handle, NodeId completionStateMachine) = await Proxy .GenerateFileForReadAsync(generateOptions, ct) .ConfigureAwait(false); @@ -126,6 +137,11 @@ public async ValueTask GenerateFileForReadAsync( try { + if (!completionStateMachine.IsNull) + { + await WaitForReadTransferAsync(completionStateMachine, ct) + .ConfigureAwait(false); + } return new UaFileStream( fileProxy, handle, @@ -149,6 +165,103 @@ await fileProxy.CloseAsync(handle, CancellationToken.None) } } + /// + /// Polls CurrentState/Id of the completion state machine until + /// the asynchronous generation reaches ReadTransfer. + /// + private async ValueTask WaitForReadTransferAsync( + NodeId stateMachine, + CancellationToken ct) + { + ArrayOf browsePaths = new[] + { + new BrowsePath + { + StartingNode = stateMachine, + RelativePath = new RelativePath + { + Elements = + [ + new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HasComponent, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName(BrowseNames.CurrentState) + }, + new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HasProperty, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName(BrowseNames.Id) + } + ] + } + } + }.ToArrayOf(); + TranslateBrowsePathsToNodeIdsResponse response = await Session + .TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct) + .ConfigureAwait(false); + ClientBase.ValidateResponse(response.Results, browsePaths); + BrowsePathResult result = response.Results[0]; + if (StatusCode.IsBad(result.StatusCode) || result.Targets.Count == 0) + { + // The state cannot be observed; Read reports the outcome. + return; + } + + ArrayOf nodesToRead = new[] + { + new ReadValueId + { + NodeId = ExpandedNodeId.ToNodeId( + result.Targets[0].TargetId, + Session.MessageContext.NamespaceUris), + AttributeId = Attributes.Value + } + }.ToArrayOf(); + TimeSpan delay = TimeSpan.FromMilliseconds(50); + while (true) + { + ReadResponse read = await Session.ReadAsync( + null, + 0.0, + TimestampsToReturn.Neither, + nodesToRead, + ct).ConfigureAwait(false); + ClientBase.ValidateResponse(read.Results, nodesToRead); + DataValue value = read.Results[0]; + if (StatusCode.IsBad(value.StatusCode) || + !value.WrappedValue.TryGetValue(out NodeId state)) + { + return; + } + // Part 20 4.4.3: Read fails until the state reaches + // ReadTransfer. Idle means generation has not started yet, + // so keep waiting (bounded by the caller's token). + if (state.Equals(ObjectIds.FileTransferStateMachineType_ReadTransfer)) + { + return; + } + if (state.Equals(ObjectIds.FileTransferStateMachineType_Error)) + { + throw new IOException( + "The server failed to generate the temporary file for reading " + + "(the FileTransferStateMachine entered the Error state)."); + } + await Task.Delay(delay, ct).ConfigureAwait(false); + if (delay < s_maxStatePollDelay) + { + delay = TimeSpan.FromTicks(Math.Min( + delay.Ticks * 2, + s_maxStatePollDelay.Ticks)); + } + } + } + + private static readonly TimeSpan s_maxStatePollDelay = TimeSpan.FromSeconds(1); + /// /// Asks the server to allocate a temporary file for writing. /// The returned owns the diff --git a/src/Opc.Ua.Client/FileSystem/UaPath.cs b/src/Opc.Ua.Client/FileSystem/UaPath.cs index b5593dde41..4a5dbe732c 100644 --- a/src/Opc.Ua.Client/FileSystem/UaPath.cs +++ b/src/Opc.Ua.Client/FileSystem/UaPath.cs @@ -50,6 +50,15 @@ namespace Opc.Ua.Client.FileSystem /// into a single one and an empty middle segment is rejected. /// /// + /// A '/' or ':' that is part of a name is escaped with + /// '&' ("&/", "&:", and "&&" + /// for a literal '&' in front of one of these or at the end of + /// a name), in the manner + /// of the Part 4 Annex A.2 RelativePath text format, so + /// output always parses back to the same segments. + /// An '&' followed by any other character is literal. + /// + /// /// Path comparisons are namespace aware: siblings /// "1:foo" and "2:foo" are different paths and produce /// different cache keys, even though their @@ -97,7 +106,9 @@ public static QualifiedName[] Parse(string path) { remaining = remaining[1..]; } - if (remaining.Length > 0 && remaining[^1] == Separator) + // An escaped final '/' ("a&/") belongs to the last name. + if (remaining.Length > 0 && remaining[^1] == Separator && + !IsEscaped(remaining, remaining.Length - 1)) { remaining = remaining[..^1]; } @@ -111,6 +122,13 @@ public static QualifiedName[] Parse(string path) int start = 0; for (int i = 0; i <= remaining.Length; i++) { + if (i < remaining.Length - 1 && remaining[i] == Escape) + { + // Skip the escaped character (an escaped '/' does not + // separate segments). + i++; + continue; + } if (i == remaining.Length || remaining[i] == Separator) { if (i == start) @@ -170,13 +188,103 @@ public static string FormatSegment(QualifiedName segment) "Path segment must have a non-empty Name.", nameof(segment)); } + string name = EscapeName(segment.Name!); if (segment.NamespaceIndex == 0) { - return segment.Name!; + return name; + } + return $"{segment.NamespaceIndex}:{name}"; + } + + private static string EscapeName(string name) + { + if (name.IndexOfAny(kReserved) < 0) + { + return name; + } + var sb = new StringBuilder(name.Length + 4); + for (int i = 0; i < name.Length; i++) + { + char c = name[i]; + // A '&' is only ambiguous in front of a reserved character + // (or another '&') and at the end of the name, where the + // separator that follows in a path would otherwise read as + // escaped; elsewhere it stays literal so existing paths keep + // their form. + if (c is Separator or NamespaceDelimiter || + (c == Escape && (i + 1 == name.Length || IsReserved(name[i + 1])))) + { + sb.Append(Escape); + } + sb.Append(c); + } + return sb.ToString(); + } + + private static string UnescapeName(string name) + { + if (name.IndexOf(Escape, StringComparison.Ordinal) < 0) + { + return name; + } + var sb = new StringBuilder(name.Length); + for (int i = 0; i < name.Length; i++) + { + char c = name[i]; + if (c == Escape && i + 1 < name.Length && IsReserved(name[i + 1])) + { + i++; + c = name[i]; + } + sb.Append(c); + } + return sb.ToString(); + } + + /// + /// Whether the character at is escaped, i.e. + /// preceded by an odd-length run of '&' (each pair is an + /// escaped '&', a remaining one escapes the character). + /// + private static bool IsEscaped(ReadOnlySpan text, int index) + { + int run = 0; + for (int i = index - 1; i >= 0 && text[i] == Escape; i--) + { + run++; } - return $"{segment.NamespaceIndex}:{segment.Name}"; + return (run & 1) == 1; + } + + private static bool IsReserved(char c) + { + return c is Separator or NamespaceDelimiter or Escape; } + /// + /// Index of the first ':' that is not escaped, or -1. + /// + private static int IndexOfUnescapedColon(string segment) + { + for (int i = 0; i < segment.Length; i++) + { + if (segment[i] == Escape && i + 1 < segment.Length) + { + i++; + continue; + } + if (segment[i] == NamespaceDelimiter) + { + return i; + } + } + return -1; + } + + private const char Escape = '&'; + private const char NamespaceDelimiter = ':'; + private static readonly char[] kReserved = [Separator, NamespaceDelimiter, Escape]; + /// /// Combines two paths in the manner of /// : if @@ -269,10 +377,10 @@ public static string Normalize(string path) private static QualifiedName ParseSegment(string segment, string fullPath) { - int colon = segment.IndexOf(':', StringComparison.Ordinal); + int colon = IndexOfUnescapedColon(segment); if (colon < 0) { - return new QualifiedName(segment); + return new QualifiedName(UnescapeName(segment)); } if (colon == 0 || colon == segment.Length - 1) { @@ -287,7 +395,7 @@ private static QualifiedName ParseSegment(string segment, string fullPath) $"Invalid path '{fullPath}': segment '{segment}' has a non-numeric namespace prefix.", nameof(fullPath)); } - return new QualifiedName(segment[(colon + 1)..], ns); + return new QualifiedName(UnescapeName(segment[(colon + 1)..]), ns); } } } diff --git a/src/Opc.Ua.Client/Fluent/ManagedSessionBuilder.cs b/src/Opc.Ua.Client/Fluent/ManagedSessionBuilder.cs index f5527dae12..f59d064e21 100644 --- a/src/Opc.Ua.Client/Fluent/ManagedSessionBuilder.cs +++ b/src/Opc.Ua.Client/Fluent/ManagedSessionBuilder.cs @@ -542,6 +542,23 @@ public ManagedSessionBuilder WithServerRedundancy(IServerRedundancyHandler handl return this; } + /// + /// Sets the timeouts the default server redundancy handler uses for the + /// redundancy metadata read and peer endpoint lookup. Raise them on a + /// high-latency link. Takes effect when server redundancy is enabled and + /// no custom handler is supplied. + /// + /// is null. + public ManagedSessionBuilder WithServerRedundancyOptions(ServerRedundancyOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + m_options = m_options with { ServerRedundancy = options }; + return this; + } + /// /// Use a specific subscription engine factory. Defaults to the V2 /// engine when not specified. diff --git a/src/Opc.Ua.Client/Fluent/OpcUaClientBuilderExtensions.cs b/src/Opc.Ua.Client/Fluent/OpcUaClientBuilderExtensions.cs index 466bcb2dbe..34cc36289e 100644 --- a/src/Opc.Ua.Client/Fluent/OpcUaClientBuilderExtensions.cs +++ b/src/Opc.Ua.Client/Fluent/OpcUaClientBuilderExtensions.cs @@ -600,6 +600,8 @@ private static async Task SelectDiscoveredEndpointAsync( ArrayOf endpoints = await discovery .GetEndpointsAsync(options.DiscoveryUrl, ct: ct) .ConfigureAwait(false); + Uri? discoveryUri = Utils.ParseUri(options.DiscoveryUrl); + EndpointDescription? selected = null; foreach (EndpointDescription endpoint in endpoints) { if (endpoint.SecurityMode == options.SecurityMode && @@ -610,12 +612,44 @@ private static async Task SelectDiscoveredEndpointAsync( options.TransportProfileUri, StringComparison.Ordinal))) { - return endpoint; + // Prefer an endpoint reachable over the discovery URL's scheme. + if (discoveryUri == null || HasScheme(endpoint, discoveryUri.Scheme)) + { + selected = endpoint; + break; + } + selected ??= endpoint; } } - throw new InvalidOperationException( - "No discovered endpoint matched the configured security policy and mode."); + if (selected == null) + { + throw new InvalidOperationException( + "No discovered endpoint matched the configured security policy and mode."); + } + + // Part 4 §5.6.2.1: a server behind NAT or in a container may advertise a + // host name the client cannot reach. Use the host and port the client + // reached for GetEndpoints, as CoreClientUtils.SelectEndpointAsync does. + Uri? endpointUri = Utils.ParseUri(selected.EndpointUrl); + if (discoveryUri != null && endpointUri != null && endpointUri.Scheme == discoveryUri.Scheme) + { + var uriBuilder = new UriBuilder(endpointUri) + { + Host = discoveryUri.IdnHost, + Port = discoveryUri.Port + }; + selected = CoreUtils.Clone(selected)!; + selected.EndpointUrl = uriBuilder.ToString(); + } + return selected; + } + + private static bool HasScheme(EndpointDescription endpoint, string scheme) + { + Uri? endpointUri = Utils.ParseUri(endpoint.EndpointUrl); + return endpointUri != null && + string.Equals(endpointUri.Scheme, scheme, StringComparison.OrdinalIgnoreCase); } private static OpcUaClientIdentityOptions BindIdentityOptions( @@ -961,8 +995,11 @@ private static void RegisterCoreServices(IServiceCollection services) services.TryAddSingleton(); services.TryAddSingleton(); + // The accessor is a container-owned singleton so the shared session is + // closed when the host shuts down. + services.TryAddSingleton(sp => new ManagedSessionAccessor(sp)); services.TryAddSingleton>>( - sp => new ManagedSessionAccessor(sp).ConnectAsync); + sp => sp.GetRequiredService().ConnectAsync); services.TryAddSingleton(); services.TryAddSingleton /// Lazily creates and caches the connected . /// Multiple awaiters of the factory delegate share the single - /// connection task. + /// connection task. A cached session a caller has disposed is replaced + /// by a fresh connection; the session still cached when the container + /// is disposed is disposed with it. /// - private sealed class ManagedSessionAccessor + internal sealed class ManagedSessionAccessor : IAsyncDisposable, IDisposable { public ManagedSessionAccessor(IServiceProvider sp) + : this(ct => ConnectCoreAsync(sp, ct)) { - m_sp = sp; + } + + internal ManagedSessionAccessor(Func> connect) + { + m_connect = connect; } public Task ConnectAsync(CancellationToken ct) @@ -1572,13 +1620,25 @@ public Task ConnectAsync(CancellationToken ct) Task connectTask; lock (m_gate) { + if (m_disposed) + { + throw new ObjectDisposedException(nameof(ManagedSessionAccessor)); + } + if (m_connectTask != null && + m_connectTask.Status == TaskStatus.RanToCompletion && + m_connectTask.Result.Disposed) + { + // A consumer disposed the shared session; connect anew + // instead of handing out the dead instance forever. + m_connectTask = null; + } if (m_connectTask != null) { connectTask = m_connectTask; } else { - connectTask = ConnectCoreAsync(CancellationToken.None); + connectTask = m_connect(CancellationToken.None); m_connectTask = connectTask; // One observer owns eviction even when every caller cancels its own wait. _ = connectTask.ContinueWith( @@ -1607,15 +1667,81 @@ public Task ConnectAsync(CancellationToken ct) return connectTask.WaitAsync(ct); } - private Task ConnectCoreAsync(CancellationToken ct) + private static Task ConnectCoreAsync(IServiceProvider sp, CancellationToken ct) { OpcUaClientOptions options = - m_sp.GetRequiredService(); - return m_sp.ConnectManagedSessionAsync(options.Session, _ => { }, ct); + sp.GetRequiredService(); + return sp.ConnectManagedSessionAsync(options.Session, _ => { }, ct); + } + + public async ValueTask DisposeAsync() + { + Task? connectTask = TakeForDispose(); + if (connectTask?.Status == TaskStatus.RanToCompletion) + { + await connectTask.Result.DisposeAsync().ConfigureAwait(false); + } + else if (connectTask != null) + { + DisposeWhenConnected(connectTask); + } + } + + public void Dispose() + { + Task? connectTask = TakeForDispose(); + if (connectTask?.Status == TaskStatus.RanToCompletion) + { + connectTask.Result.Dispose(); + } + else if (connectTask != null) + { + DisposeWhenConnected(connectTask); + } + } + + /// + /// Shutdown does not wait for a connect still in flight (it may be + /// retrying against an unreachable server); the session it yields + /// is closed once it arrives. + /// + private static void DisposeWhenConnected(Task connectTask) + { + _ = connectTask.ContinueWith( + static task => + { + if (task.Status == TaskStatus.RanToCompletion) + { + _ = task.Result.DisposeAsync().AsTask(); + } + else + { + _ = task.Exception; + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private Task? TakeForDispose() + { + lock (m_gate) + { + if (m_disposed) + { + return null; + } + m_disposed = true; + Task? connectTask = m_connectTask; + m_connectTask = null; + return connectTask; + } } - private readonly IServiceProvider m_sp; + private readonly Func> m_connect; private Task? m_connectTask; + private bool m_disposed; private readonly Lock m_gate = new(); } diff --git a/src/Opc.Ua.Client/Historian/HistoryClient.Extras.cs b/src/Opc.Ua.Client/Historian/HistoryClient.Extras.cs index 47f42e36b5..cd5e55147d 100644 --- a/src/Opc.Ua.Client/Historian/HistoryClient.Extras.cs +++ b/src/Opc.Ua.Client/Historian/HistoryClient.Extras.cs @@ -826,7 +826,8 @@ private static ArrayOf FilterHistoricalProfiles( for (int i = 0; i < profiles.Count; i++) { string profile = profiles[i]; - if (profile.Contains( + // A null element is wire-legal in a String array; skip it. + if (profile != null && profile.Contains( "Historical", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Opc.Ua.Client/ModelChange/ModelChangeTracker.cs b/src/Opc.Ua.Client/ModelChange/ModelChangeTracker.cs index 8b38d63491..f8bab39257 100644 --- a/src/Opc.Ua.Client/ModelChange/ModelChangeTracker.cs +++ b/src/Opc.Ua.Client/ModelChange/ModelChangeTracker.cs @@ -61,6 +61,14 @@ public sealed class ModelChangeTracker : IModelChangeTracker /// the current one. /// private long m_trackingEpoch; + + /// + /// Callers of still waiting for the + /// current generation to become ready. The pump is torn down on an + /// abandoned start only when the last of them gives up, so one + /// caller's cancellation does not fail another that joined the start. + /// + private int m_startWaiters; private bool m_disposed; /// @@ -101,8 +109,7 @@ public async ValueTask StartTrackingAsync(CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - bool ownsStart = false; - long startedEpoch = 0; + long waitEpoch; Task readyTask; lock (m_stateLock) @@ -125,80 +132,117 @@ public async ValueTask StartTrackingAsync(CancellationToken ct = default) // StopTrackingAsync (which nulls m_cts) cannot NRE the lambda. CancellationToken pumpToken = m_cts.Token; long epoch = ++m_trackingEpoch; - startedEpoch = epoch; + m_startWaiters = 0; m_startReadyTask = ready.Task; m_pumpTask = Task.Run( () => PumpAsync(ready, epoch, pumpToken), CancellationToken.None); IsTracking = true; - ownsStart = true; readyTask = ready.Task; } + + waitEpoch = m_trackingEpoch; + m_startWaiters++; } try { // The caller's token bounds this wait; the catch below stops - // the pump it started when the wait is abandoned. + // the pump when the last waiter abandons it. await readyTask.WaitAsync(ct).ConfigureAwait(false); } catch { - if (ownsStart) + CancellationTokenSource? cts = null; + Task? pumpTask = null; + bool detached = false; + lock (m_stateLock) { - // Only tear down the pump this call started: a failing pump - // clears IsTracking from its own finally, so a concurrent - // caller can have installed a replacement by the time this - // cleanup runs, and stopping unconditionally would cancel - // that replacement instead. - await StopTrackingCoreAsync(startedEpoch, CancellationToken.None) - .ConfigureAwait(false); + // Only tear down the generation this call waited for: a + // failing pump clears IsTracking from its own finally, so + // a concurrent caller can have installed a replacement by + // the time this cleanup runs, and stopping unconditionally + // would cancel that replacement instead. A caller that + // joined the start and is still waiting keeps it alive. + // The last-waiter decision and the detach share this lock + // so a caller cannot join a generation that is being torn + // down. + if (LeaveStartWaitLocked(waitEpoch)) + { + detached = TryDetachLocked(out cts, out pumpTask); + } + } + if (detached) + { + await CompleteStopAsync(cts, pumpTask).ConfigureAwait(false); } throw; } + lock (m_stateLock) + { + LeaveStartWaitLocked(waitEpoch); + } } - /// - public ValueTask StopTrackingAsync(CancellationToken ct = default) + /// + /// Leaves the wait for to become ready. + /// Returns true when this was the last waiter of the current + /// generation. Must be called under . + /// + private bool LeaveStartWaitLocked(long epoch) { - return StopTrackingCoreAsync(null, ct); + return m_trackingEpoch == epoch && --m_startWaiters == 0; } - /// - /// Stops tracking. When is given the stop - /// applies only while that generation is still the current one, so an - /// owner cleaning up after its own failed start cannot tear down a - /// replacement started in the meantime. - /// - private async ValueTask StopTrackingCoreAsync(long? epoch, CancellationToken ct) + /// + public async ValueTask StopTrackingAsync(CancellationToken ct = default) { - Task? pumpTask; CancellationTokenSource? cts; - + Task? pumpTask; lock (m_stateLock) { - if (epoch.HasValue && m_trackingEpoch != epoch.Value) - { - return; - } - - // Not gated on IsTracking alone: a pump that ended or faulted - // on its own already cleared the flag while leaving its token - // source and task behind, and returning here would leak them - - // the next StartTrackingAsync would overwrite the fields. - if (!IsTracking && m_cts == null && m_pumpTask == null) + if (!TryDetachLocked(out cts, out pumpTask)) { return; } + } + await CompleteStopAsync(cts, pumpTask).ConfigureAwait(false); + } - IsTracking = false; - cts = m_cts; - pumpTask = m_pumpTask; - m_cts = null; - m_pumpTask = null; - m_startReadyTask = null; + /// + /// Detaches the current generation so no later caller can join it. + /// Must be called under . + /// + private bool TryDetachLocked(out CancellationTokenSource? cts, out Task? pumpTask) + { + cts = null; + pumpTask = null; + + // Not gated on IsTracking alone: a pump that ended or faulted + // on its own already cleared the flag while leaving its token + // source and task behind, and returning here would leak them - + // the next StartTrackingAsync would overwrite the fields. + if (!IsTracking && m_cts == null && m_pumpTask == null) + { + return false; } + IsTracking = false; + cts = m_cts; + pumpTask = m_pumpTask; + m_cts = null; + m_pumpTask = null; + m_startReadyTask = null; + return true; + } + + /// + /// Cancels and awaits a detached generation. + /// + private static async ValueTask CompleteStopAsync( + CancellationTokenSource? cts, + Task? pumpTask) + { if (cts != null) { try diff --git a/src/Opc.Ua.Client/NodeCache/NodeCache.cs b/src/Opc.Ua.Client/NodeCache/NodeCache.cs index e3a2d6d6b9..3ab5382da0 100644 --- a/src/Opc.Ua.Client/NodeCache/NodeCache.cs +++ b/src/Opc.Ua.Client/NodeCache/NodeCache.cs @@ -259,7 +259,11 @@ public ValueTask> GetValuesAsync( var result = new List(nodeIds.Count); if (count != 0) { + // Track the slots to fill explicitly: an empty DataValue is a + // legal cached or returned value, so it cannot double as the + // marker of a missing slot. var notFound = new List(); + var missingIndices = new List(); foreach (NodeId nodeId in nodeIds) { if (m_values.TryGet(nodeId, out DataValue dataValue)) @@ -268,14 +272,14 @@ public ValueTask> GetValuesAsync( continue; } notFound.Add(nodeId); + missingIndices.Add(result.Count); result.Add(default); } if (notFound.Count != 0) { - return FetchRemainingAsync(notFound, result, ct); + return FetchRemainingAsync(notFound, missingIndices, result, ct); } } - Debug.Assert(!result.Any(r => r.IsNull)); // None now should be null return new ValueTask>(result.ToArrayOf()); } @@ -643,7 +647,7 @@ public async ValueTask ExistsAsync(ExpandedNodeId nodeId, CancellationToke { return await GetNodeAsync(localId, ct).ConfigureAwait(false); } - catch (Exception e) + catch (Exception e) when (e is not OperationCanceledException) { m_logger.CouldNotFindNodeNodeIdError( nodeId, @@ -727,7 +731,7 @@ private async ValueTask PopulateReferenceTableAsync( targetId); } } - catch (Exception e) + catch (Exception e) when (e is not OperationCanceledException) { m_logger.CouldNotFetchReferencesNodeNodeId( localId, @@ -755,7 +759,7 @@ private async ValueTask PopulateReferenceTableAsync( await PopulateReferenceTableAsync(localId, node, ct).ConfigureAwait(false); return node; } - catch (Exception e) + catch (Exception e) when (e is not OperationCanceledException) { m_logger.CouldNotFetchNodeNodeIdError( nodeId, @@ -830,7 +834,7 @@ public async ValueTask IsKnownAsync(NodeId typeId, CancellationToken ct) await GetNodeAsync(typeId, ct).ConfigureAwait(false); return true; } - catch + catch (Exception e) when (e is not OperationCanceledException) { return false; } @@ -943,7 +947,7 @@ public async ValueTask FindReferenceTypeNameAsync( INode node = await GetNodeAsync(referenceTypeId, ct).ConfigureAwait(false); return node.BrowseName; } - catch + catch (Exception e) when (e is not OperationCanceledException) { return QualifiedName.Null; } @@ -1126,7 +1130,7 @@ private async ValueTask FindReferenceTypeInHierarchyAsync( { node = await GetNodeAsync(startNodeId, ct).ConfigureAwait(false); } - catch + catch (Exception e) when (e is not OperationCanceledException) { return NodeId.Null; } @@ -1266,10 +1270,11 @@ await GetOrAddReferencesLenientAsync( /// private async ValueTask> FetchRemainingAsync( List remainingIds, + List missingIndices, List result, CancellationToken ct) { - Debug.Assert(result.Count(r => r.IsNull) == remainingIds.Count); + Debug.Assert(missingIndices.Count == remainingIds.Count); // fetch nodes and references from server. (ArrayOf values, ArrayOf readErrors) = @@ -1278,7 +1283,6 @@ await m_context.FetchValuesAsync(null, remainingIds.ToArrayOf(), ct: ct) Debug.Assert(values.Count == remainingIds.Count); Debug.Assert(readErrors.Count == remainingIds.Count); - int resultMissingIndex = 0; for (int index = 0; index < remainingIds.Count; index++) { if (ServiceResult.IsBad(readErrors[index])) @@ -1293,14 +1297,8 @@ await m_context.FetchValuesAsync(null, remainingIds.ToArrayOf(), ct: ct) // Add to cache m_values.AddOrUpdate(remainingIds[index], values[index]); } - while (!result[resultMissingIndex].IsNull) - { - resultMissingIndex++; - Debug.Assert(resultMissingIndex < result.Count); - } - result[resultMissingIndex] = values[index]; + result[missingIndices[index]] = values[index]; } - Debug.Assert(!result.Any(r => r.IsNull)); // None now should be null return result.ToArrayOf(); } diff --git a/src/Opc.Ua.Client/NodeCache/NodeCacheContext.cs b/src/Opc.Ua.Client/NodeCache/NodeCacheContext.cs index ac0da20836..f9841e3487 100644 --- a/src/Opc.Ua.Client/NodeCache/NodeCacheContext.cs +++ b/src/Opc.Ua.Client/NodeCache/NodeCacheContext.cs @@ -416,9 +416,22 @@ private static void CreateAttributesReadNodesRequest( } node.NodeClass = nodeClass; - Dictionary attributes = CreateAttributes( - node.NodeClass, - skipOptionalAttributes); + Dictionary attributes; + try + { + attributes = CreateAttributes( + node.NodeClass, + skipOptionalAttributes); + } + catch (ServiceResultException sre) + { + // An undefined NodeClass value fails this node only, not + // every other node of the batch. + nodeCollection.Add(node); + errors.Add(sre.Result); + attributesPerNodeId.Add(null); + continue; + } foreach (uint attributeId in attributes.Keys) { var itemToRead = new ReadValueId @@ -488,6 +501,25 @@ private static void ProcessAttributesReadNodesResponse( } } + /// + /// Returns a read attribute. The attribute set was built for the + /// NodeClass the caller expected; when the server reports another + /// NodeClass the attributes that class needs were never read. + /// + /// + private static DataValue GetAttribute( + IDictionary attributes, + uint attributeId) + { + if (!attributes.TryGetValue(attributeId, out DataValue value)) + { + throw new ServiceResultException( + StatusCodes.BadNodeClassInvalid, + "The NodeClass of the node does not match the requested NodeClass."); + } + return value; + } + /// /// Creates a Node based on the read response. /// @@ -573,7 +605,7 @@ or Attributes.InverseName case NodeClass.Object: var objectNode = new ObjectNode(); - value = attributes[Attributes.EventNotifier]; + value = GetAttribute(attributes, Attributes.EventNotifier); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -586,7 +618,7 @@ or Attributes.InverseName case NodeClass.ObjectType: var objectTypeNode = new ObjectTypeNode(); - value = attributes[Attributes.IsAbstract]; + value = GetAttribute(attributes, Attributes.IsAbstract); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -600,7 +632,7 @@ or Attributes.InverseName var variableNode = new VariableNode(); // DataType Attribute - value = attributes[Attributes.DataType]; + value = GetAttribute(attributes, Attributes.DataType); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -610,7 +642,7 @@ or Attributes.InverseName variableNode.DataType = value.WrappedValue.GetNodeId(); // ValueRank Attribute - value = attributes[Attributes.ValueRank]; + value = GetAttribute(attributes, Attributes.ValueRank); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -620,7 +652,7 @@ or Attributes.InverseName variableNode.ValueRank = value.WrappedValue.GetInt32(); // ArrayDimensions Attribute - value = attributes[Attributes.ArrayDimensions]; + value = GetAttribute(attributes, Attributes.ArrayDimensions); if (!value.IsNull) { @@ -635,7 +667,7 @@ or Attributes.InverseName } // AccessLevel Attribute - value = attributes[Attributes.AccessLevel]; + value = GetAttribute(attributes, Attributes.AccessLevel); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -645,7 +677,7 @@ or Attributes.InverseName variableNode.AccessLevel = value.WrappedValue.GetByte(); // UserAccessLevel Attribute - value = attributes[Attributes.UserAccessLevel]; + value = GetAttribute(attributes, Attributes.UserAccessLevel); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -655,7 +687,7 @@ or Attributes.InverseName variableNode.UserAccessLevel = value.WrappedValue.GetByte(); // Historizing Attribute - value = attributes[Attributes.Historizing]; + value = GetAttribute(attributes, Attributes.Historizing); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -665,7 +697,7 @@ or Attributes.InverseName variableNode.Historizing = value.WrappedValue.GetBoolean(); // MinimumSamplingInterval Attribute - value = attributes[Attributes.MinimumSamplingInterval]; + value = GetAttribute(attributes, Attributes.MinimumSamplingInterval); if (!value.IsNull) { variableNode.MinimumSamplingInterval = @@ -673,7 +705,7 @@ or Attributes.InverseName } // AccessLevelEx Attribute - value = attributes[Attributes.AccessLevelEx]; + value = GetAttribute(attributes, Attributes.AccessLevelEx); if (!value.IsNull) { variableNode.AccessLevelEx = value.WrappedValue.GetUInt32(); @@ -685,7 +717,7 @@ or Attributes.InverseName var variableTypeNode = new VariableTypeNode(); // IsAbstract Attribute - value = attributes[Attributes.IsAbstract]; + value = GetAttribute(attributes, Attributes.IsAbstract); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -695,7 +727,7 @@ or Attributes.InverseName variableTypeNode.IsAbstract = value.WrappedValue.GetBoolean(); // DataType Attribute - value = attributes[Attributes.DataType]; + value = GetAttribute(attributes, Attributes.DataType); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -705,7 +737,7 @@ or Attributes.InverseName variableTypeNode.DataType = value.WrappedValue.GetNodeId(); // ValueRank Attribute - value = attributes[Attributes.ValueRank]; + value = GetAttribute(attributes, Attributes.ValueRank); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -715,7 +747,7 @@ or Attributes.InverseName variableTypeNode.ValueRank = value.WrappedValue.GetInt32(); // ArrayDimensions Attribute - value = attributes[Attributes.ArrayDimensions]; + value = GetAttribute(attributes, Attributes.ArrayDimensions); if (!value.IsNull && value.WrappedValue.TryGetValue(out ArrayOf arrayDimensions2)) @@ -729,7 +761,7 @@ or Attributes.InverseName var methodNode = new MethodNode(); // Executable Attribute - value = attributes[Attributes.Executable]; + value = GetAttribute(attributes, Attributes.Executable); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -739,7 +771,7 @@ or Attributes.InverseName methodNode.Executable = value.WrappedValue.GetBoolean(); // UserExecutable Attribute - value = attributes[Attributes.UserExecutable]; + value = GetAttribute(attributes, Attributes.UserExecutable); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -754,7 +786,7 @@ or Attributes.InverseName var dataTypeNode = new DataTypeNode(); // IsAbstract Attribute - value = attributes[Attributes.IsAbstract]; + value = GetAttribute(attributes, Attributes.IsAbstract); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -764,7 +796,7 @@ or Attributes.InverseName dataTypeNode.IsAbstract = value.WrappedValue.GetBoolean(); // DataTypeDefinition Attribute - value = attributes[Attributes.DataTypeDefinition]; + value = GetAttribute(attributes, Attributes.DataTypeDefinition); if (!value.IsNull) { @@ -778,7 +810,7 @@ or Attributes.InverseName var referenceTypeNode = new ReferenceTypeNode(); // IsAbstract Attribute - value = attributes[Attributes.IsAbstract]; + value = GetAttribute(attributes, Attributes.IsAbstract); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -788,7 +820,7 @@ or Attributes.InverseName referenceTypeNode.IsAbstract = value.WrappedValue.GetBoolean(); // Symmetric Attribute - value = attributes[Attributes.Symmetric]; + value = GetAttribute(attributes, Attributes.Symmetric); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -798,7 +830,7 @@ or Attributes.InverseName referenceTypeNode.Symmetric = value.WrappedValue.GetBoolean(); // InverseName Attribute - value = attributes[Attributes.InverseName]; + value = GetAttribute(attributes, Attributes.InverseName); if (!value.IsNull && value.WrappedValue.TryGetValue(out LocalizedText inverseName)) @@ -812,7 +844,7 @@ or Attributes.InverseName var viewNode = new ViewNode(); // EventNotifier Attribute - value = attributes[Attributes.EventNotifier]; + value = GetAttribute(attributes, Attributes.EventNotifier); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -822,7 +854,7 @@ or Attributes.InverseName viewNode.EventNotifier = value.WrappedValue.GetByte(); // ContainsNoLoops Attribute - value = attributes[Attributes.ContainsNoLoops]; + value = GetAttribute(attributes, Attributes.ContainsNoLoops); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -843,7 +875,7 @@ or Attributes.InverseName } // NodeId Attribute - value = attributes[Attributes.NodeId]; + value = GetAttribute(attributes, Attributes.NodeId); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -854,7 +886,7 @@ or Attributes.InverseName node.NodeClass = nodeClass; // BrowseName Attribute - value = attributes[Attributes.BrowseName]; + value = GetAttribute(attributes, Attributes.BrowseName); if (value.IsNull) { throw ServiceResultException.Unexpected( @@ -864,7 +896,7 @@ or Attributes.InverseName node.BrowseName = value.WrappedValue.GetQualifiedName(); // DisplayName Attribute - value = attributes[Attributes.DisplayName]; + value = GetAttribute(attributes, Attributes.DisplayName); if (value.IsNull) { throw ServiceResultException.Unexpected( diff --git a/src/Opc.Ua.Client/ReverseConnectManager.cs b/src/Opc.Ua.Client/ReverseConnectManager.cs index 6494e34cf6..26346a3be9 100644 --- a/src/Opc.Ua.Client/ReverseConnectManager.cs +++ b/src/Opc.Ua.Client/ReverseConnectManager.cs @@ -710,6 +710,7 @@ public void AddEndpoint(Uri endpointUrl, ApplicationConfiguration? configuration ThrowIfDisposed(); + ReverseConnectInfo? replaced; lock (m_lock) { if (!CanMutateEndpoints(m_state)) @@ -722,11 +723,21 @@ public void AddEndpoint(Uri endpointUrl, ApplicationConfiguration? configuration // activated one) so CreateEndpointInfo can plumb the // CertificateManager into the listener when the endpoint URL // needs TLS termination (WSS). Instance state is not mutated. + m_manualEndpoints.TryGetValue(endpointUrl, out replaced); m_manualEndpoints[endpointUrl] = CreateEndpointInfo( endpointUrl, false, configuration ?? m_appConfig); } + + if (replaced != null) + { + // The replaced host is no longer reachable from the map, so + // teardown would never dispose it. The manager is not serving + // in the states that allow mutation, so the host is not bound; + // DisposeHostAsync logs and swallows failures. + _ = DisposeHostAsync(replaced).AsTask(); + } } /// @@ -2194,6 +2205,12 @@ private int RegisterWaitingConnectionCore( ReverseConnectStrategy reverseConnectStrategy, bool verifyServing = false) { + // Undefined (or Any without Once/Always) is documented to default to + // Once; without this it would never be removed and behave as Always. + if ((reverseConnectStrategy & ~ReverseConnectStrategy.Any) == 0) + { + reverseConnectStrategy |= ReverseConnectStrategy.Once; + } var registration = new Registration(serverUri, endpointUrl, onConnectionWaiting) { ReverseConnectStrategy = reverseConnectStrategy diff --git a/src/Opc.Ua.Client/Roles/RoleManagementClient.cs b/src/Opc.Ua.Client/Roles/RoleManagementClient.cs index efd2929518..81c64e0890 100644 --- a/src/Opc.Ua.Client/Roles/RoleManagementClient.cs +++ b/src/Opc.Ua.Client/Roles/RoleManagementClient.cs @@ -31,6 +31,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace Opc.Ua.Client.Roles { @@ -92,16 +93,60 @@ public async ValueTask> ListRolesAsync( ClientBase.ValidateResponse( browseResponse.Results, browseDescriptions); - ArrayOf references = browseResponse.Results[0].References; - var roles = new List(references.Count); // Materialize references to a regular list so the async loop below - // does not capture a span enumerator across await boundaries. - var materializedRefs = new List(references.Count); - foreach (ReferenceDescription reference in references) + // does not capture a span enumerator across await boundaries. The + // server may page the RoleSet (Part 4 §5.9.2) even without a client + // limit, so follow the continuation point to the end. + var materializedRefs = new List(); + BrowseResult result = browseResponse.Results[0]; + if (StatusCode.IsBad(result.StatusCode)) + { + throw new ServiceResultException(result.StatusCode); + } + ByteString continuationPoint = result.ContinuationPoint; + int emptyRounds = 0; + try + { + while (true) + { + foreach (ReferenceDescription reference in result.References) + { + materializedRefs.Add(reference); + } + Browser.ThrowIfNoBrowseProgress( + result.References, + continuationPoint, + ref emptyRounds); + if (continuationPoint.IsEmpty) + { + break; + } + result = await BrowseNextAsync(continuationPoint, cancellationToken) + .ConfigureAwait(false); + if (StatusCode.IsBad(result.StatusCode)) + { + // The server has already dropped the point; there is + // nothing left to release. + continuationPoint = default; + throw new ServiceResultException(result.StatusCode); + } + continuationPoint = result.ContinuationPoint; + } + } + catch (Exception) when (!continuationPoint.IsEmpty) { - materializedRefs.Add(reference); + // Part 4 §5.9.3.2: release the point we stop following on any + // failure (cancellation, transport fault, no progress), best + // effort so the original exception is the one propagated. + await Session.ReleaseContinuationPointAsync( + continuationPoint, + Session.MessageContext.Telemetry.CreateLogger()) + .ConfigureAwait(false); + throw; } + + var roles = new List(materializedRefs.Count); foreach (ReferenceDescription reference in materializedRefs) { var roleId = ExpandedNodeId.ToNodeId(reference.NodeId, Session.NamespaceUris); @@ -125,6 +170,20 @@ public async ValueTask> ListRolesAsync( return roles; } + private async ValueTask BrowseNextAsync( + ByteString continuationPoint, + CancellationToken cancellationToken) + { + ArrayOf continuationPoints = [continuationPoint]; + BrowseNextResponse response = await Session.BrowseNextAsync( + null, + false, + continuationPoints, + cancellationToken).ConfigureAwait(false); + ClientBase.ValidateResponse(response.Results, continuationPoints); + return response.Results[0]; + } + /// public async ValueTask ReadRoleAsync( NodeId roleId, @@ -177,6 +236,12 @@ public async ValueTask ReadRoleAsync( { resolved[i] = ExpandedNodeId.ToNodeId( pathResult.Targets[0].TargetId, Session.NamespaceUris); + if (resolved[i].IsNull) + { + // Not a local node (unknown namespace or remote server): + // skipped here and in the decode loop, keeping them aligned. + continue; + } nodesToRead.Add(new ReadValueId { NodeId = resolved[i], diff --git a/src/Opc.Ua.Client/Session/ManagedSession.cs b/src/Opc.Ua.Client/Session/ManagedSession.cs index f67167b05a..1cc889f0eb 100644 --- a/src/Opc.Ua.Client/Session/ManagedSession.cs +++ b/src/Opc.Ua.Client/Session/ManagedSession.cs @@ -89,7 +89,7 @@ private ManagedSession( { m_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - ConfiguredEndpoint = endpoint + m_configuredEndpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); SessionFactory = sessionFactory ?? throw new ArgumentNullException(nameof(sessionFactory)); @@ -534,7 +534,13 @@ internal async Task EnsureRefreshAsync(CancellationToken ct) public ISessionFactory SessionFactory { get; } /// - public ConfiguredEndpoint ConfiguredEndpoint { get; } + /// + /// Reports the endpoint of the current inner session, so it follows a + /// server failover or network-path rotation like ; + /// before the first connect it is the endpoint passed at creation. + /// + public ConfiguredEndpoint ConfiguredEndpoint + => m_session?.ConfiguredEndpoint ?? m_configuredEndpoint; /// public RedundancySupport RedundancySupport => m_redundancyInfo?.Mode ?? RedundancySupport.None; @@ -985,12 +991,43 @@ public async Task UpdateSessionAsync( ArrayOf preferredLocales, CancellationToken ct = default) { - using (await m_serviceLock.WriterLockAsync(ct) - .ConfigureAwait(false)) + // An explicit identity replaces the identity provider: a refresh + // loop still bound to the provider would reactivate its identity + // on the next tick and silently undo this call. Stop the loop + // before taking the writer lock (the loop takes it too) and + // restart it if the update does not go through. + IClientIdentityProvider? provider = identity != null ? m_identityProvider : null; + if (provider != null) { - await InnerSession.UpdateSessionAsync( - identity, preferredLocales, ct) - .ConfigureAwait(false); + await StopIdentityRefreshLoopAsync().ConfigureAwait(false); + } + + bool updated = false; + try + { + using (await m_serviceLock.WriterLockAsync(ct) + .ConfigureAwait(false)) + { + await InnerSession.UpdateSessionAsync( + identity, preferredLocales, ct) + .ConfigureAwait(false); + } + updated = true; + } + finally + { + if (provider != null && ReferenceEquals(m_identityProvider, provider)) + { + if (updated) + { + m_identityProvider = null; + m_identity = identity; + } + else + { + StartIdentityRefreshLoop(); + } + } } } @@ -1025,6 +1062,8 @@ public async Task CloseAsync( m_closeTimeout = timeout; m_closeChannel = closeChannel; + // A Notification handler may be the caller and wait for the close. + m_session?.NoteCloseFromBackgroundWork(); StateMachine.RequestClose(); if (timeout > 0) @@ -1252,7 +1291,7 @@ private async Task HandleCompleteRecoveryAsync(CancellationToken private async Task HandleConnectAsync( CancellationToken ct) { - ConfiguredEndpoint endpoint = ConfiguredEndpoint; + ConfiguredEndpoint endpoint = m_configuredEndpoint; var attempted = new HashSet { endpoint }; while (true) { @@ -1531,6 +1570,40 @@ await DiscardIncompleteSessionAsync(session) } } + /// + /// Takes the service writer lock for reconnect / failover. Service + /// calls hold the reader lock for their whole round trip, so on a + /// silently dead channel they would keep recovery waiting until each + /// hits its OperationTimeout. Every keep-alive interval, check the + /// channel: once it is unhealthy, stop waiting for them; the recovery + /// then replaces the channel, which fails the stale calls, while new + /// calls stay excluded. On a healthy channel (e.g. a reconnect after + /// a certificate change) the calls complete normally, so the writer + /// stays exclusive and does not fail requests the server may have + /// already executed. + /// + private ValueTask RecoveryWriterLockAsync(CancellationToken ct) + { + TimeSpan drainTimeout = TimeSpan.FromMilliseconds( + Math.Max(m_session?.KeepAliveInterval ?? 0, MinRecoveryDrainTimeoutMs)); + return m_serviceLock.WriterLockAsync(drainTimeout, m_timeProvider, IsChannelUnhealthy, ct); + } + + /// + /// Whether service calls in flight can no longer complete normally: + /// the keep-alive stopped or the managed channel is not ready. + /// + private bool IsChannelUnhealthy() + { + Session? session = m_session; + if (session == null || session.KeepAliveStopped) + { + return true; + } + IManagedTransportChannel? channel = session.ManagedChannel; + return channel != null && channel.State != ChannelState.Ready; + } + private async Task HandleManualReconnectAsync( ITransportWaitingConnection? connection, ITransportChannel? channel, @@ -1541,7 +1614,7 @@ private async Task HandleManualReconnectAsync( try { using IDisposable recovery = InnerSession.DeferSubscriptionRecovery(); - using (await m_serviceLock.WriterLockAsync(linked.Token).ConfigureAwait(false)) + using (await RecoveryWriterLockAsync(linked.Token).ConfigureAwait(false)) { if (connection == null && channel == null) { @@ -1589,7 +1662,7 @@ private async Task HandleReconnectAsync( m_logger.ManagedSessionReconnecting(); using IDisposable recovery = session.DeferSubscriptionRecovery(); - using (await m_serviceLock.WriterLockAsync(ct) + using (await RecoveryWriterLockAsync(ct) .ConfigureAwait(false)) { try @@ -1852,7 +1925,7 @@ private async Task HandleFailoverAsync( m_logger.ManagedSessionFailingOverEndpoint(failoverEndpoint.EndpointUrl); - using (await m_serviceLock.WriterLockAsync(ct) + using (await RecoveryWriterLockAsync(ct) .ConfigureAwait(false)) { Session? session = m_session; @@ -1982,7 +2055,7 @@ private Task StartRedundancyEndpointRefresh( var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); m_redundancyEndpointRefreshTask = completion.Task; - ConfiguredEndpoint current = m_session?.ConfiguredEndpoint ?? ConfiguredEndpoint; + ConfiguredEndpoint current = ConfiguredEndpoint; if (!m_backgroundWork.Run("RefreshRedundantEndpoints", async shutdown => { using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, shutdown); @@ -2182,10 +2255,26 @@ private void RebindManagedChannelEvents( previousChannel?.StateChanged -= OnManagedChannelStateChanged; currentChannel?.StateChanged += OnManagedChannelStateChanged; - bool reconnecting = currentChannel?.State is - ChannelState.TransportReconnecting or ChannelState.TransportConnectedSessionReactivating; + SeedChannelReconnectTracking(currentChannel); + } + + /// + /// Re-derives the channel-reconnect suppression from the lease now in + /// use. The Ready/Faulted/Closed transition that would have cleared a + /// reconnect tracked on the previous lease is never observed once its + /// handler is removed, and the new lease's attach-time state is raised + /// before the handler is added, so a stale count would otherwise + /// suppress every later keep-alive-triggered reconnect. + /// + private void SeedChannelReconnectTracking(IManagedTransportChannel? channel) + { + bool reconnecting = channel?.State is + ChannelState.TransportReconnecting or + ChannelState.TransportConnectedSessionReactivating; Interlocked.Exchange(ref m_channelReconnectInProgress, reconnecting ? 1 : 0); - Volatile.Write(ref m_channelReconnectStartedAt, reconnecting ? m_timeProvider.GetTimestamp() : 0); + Volatile.Write( + ref m_channelReconnectStartedAt, + reconnecting ? m_timeProvider.GetTimestamp() : 0); } private void WireSessionEvents(Session session) @@ -2204,6 +2293,7 @@ private void WireSessionEvents(Session session) OnInnerRenewUserIdentity; session.ManagedChannel?.StateChanged += OnManagedChannelStateChanged; + SeedChannelReconnectTracking(session.ManagedChannel); } private void UnwireSessionEvents(Session session) @@ -2403,6 +2493,13 @@ private async Task RunIdentityRefreshLoopAsync( TimeSpan delay = GetIdentityRefreshDelay(provider.ExpiresAt, retryAttempt == 0); try { + // Timers reject a due time above ~49.7 days; wait for a + // far-away expiry in chunks and re-evaluate after each. + while (delay > MaxTimerDueTime) + { + await DelayAsync(MaxTimerDueTime, ct).ConfigureAwait(false); + delay = GetIdentityRefreshDelay(provider.ExpiresAt, retryAttempt == 0); + } await DelayAsync(delay, ct).ConfigureAwait(false); await RefreshIdentityOnceAsync(provider, ct).ConfigureAwait(false); retryAttempt = 0; @@ -2634,6 +2731,11 @@ private async ValueTask DisposeAsyncCoreAsync() return; } + // Disposed from a Notification handler of the inner session: the + // state machine worker disposes the inner session and must not wait + // for the drain of the handler that waits for this dispose. + m_session?.NoteCloseFromBackgroundWork(); + try { await DisposeSessionResourcesAsync().ConfigureAwait(false); @@ -2654,8 +2756,30 @@ private async ValueTask DisposeAsyncCoreAsync() } } + /// + /// Disposes this session like but leaves the + /// server session (and its subscriptions) alive: the keep-alive, the + /// connection state machine and the reconnect machinery are stopped and + /// the channel is released, but no CloseSession is sent. Used by a demoted + /// client replica whose server session is taken over by the new leader + /// through token reuse. + /// + internal async ValueTask AbandonServerSessionAsync() + { + Volatile.Write(ref m_abandonServerSession, true); + await DisposeAsync().ConfigureAwait(false); + } + private async ValueTask DisposeSessionResourcesAsync() { + if (Volatile.Read(ref m_abandonServerSession)) + { + // First: from here on neither the identity refresh, the + // background work, the state machine close nor the inner + // dispose can reach the server session with its token. + m_session?.AbandonServerSession(); + } + await StopIdentityRefreshLoopAsync().ConfigureAwait(false); UnsubscribeCertificateChanges(); await StopRevalidationLoopAsync().ConfigureAwait(false); @@ -2733,20 +2857,31 @@ public void Dispose() private RenewUserIdentityEventHandler? m_renewUserIdentity; private volatile Session? m_session; private ClientChannelManager? m_ownedChannelManager; + private bool m_abandonServerSession; private IDisposable? m_ownedTransportResources; private readonly AsyncReaderWriterLock m_serviceLock = new(); private readonly ApplicationConfiguration m_configuration; + private readonly ConfiguredEndpoint m_configuredEndpoint; private readonly IReconnectPolicy m_reconnectPolicy; private readonly IServerRedundancyHandler? m_redundancyHandler; private static readonly TimeSpan IdentityRefreshSafetyMargin = TimeSpan.FromSeconds(60); + private static readonly TimeSpan MaxTimerDueTime = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + private const int MinRecoveryDrainTimeoutMs = 1000; private readonly ILogger m_logger; - private readonly IUserIdentity? m_identity; + + /// + /// The eager identity used to connect. Replaced by an explicit + /// + /// on a provider-backed session, which drops the provider. + /// + private IUserIdentity? m_identity; /// /// The provider that materializes user identities. Replaced by /// - /// so the refresh loop follows the identity that is actually active. + /// so the refresh loop follows the identity that is actually active, + /// and cleared by an explicit identity update. /// private volatile IClientIdentityProvider? m_identityProvider; private readonly TimeProvider m_timeProvider; diff --git a/src/Opc.Ua.Client/Session/Reconnect/ConnectionStateMachine.cs b/src/Opc.Ua.Client/Session/Reconnect/ConnectionStateMachine.cs index 966e979d05..5fd231723a 100644 --- a/src/Opc.Ua.Client/Session/Reconnect/ConnectionStateMachine.cs +++ b/src/Opc.Ua.Client/Session/Reconnect/ConnectionStateMachine.cs @@ -76,10 +76,23 @@ internal sealed class ConnectionStateMachine : IAsyncDisposable private readonly AsyncManualResetEvent m_closed = new(false); /// - /// True inside the worker loop and everything it calls, notably the - /// synchronously raised handlers. + /// Set to an active scope while the worker runs one state step and + /// everything it calls, notably the synchronously raised + /// handlers. Work spawned during the step + /// (keep-alive, publish and identity loops, timers) captures the scope + /// with the execution context, so it is deactivated once the step ends: + /// otherwise that long-lived work would pass as the worker for the life + /// of the session. /// - private readonly AsyncLocal m_inWorkerFlow = new(); + private readonly AsyncLocal m_inWorkerFlow = new(); + + /// + /// Marks one worker step, see . + /// + private sealed class WorkerScope + { + public volatile bool Active = true; + } /// /// Set once the current connect cycle has settled, i.e. the machine @@ -169,8 +182,7 @@ public ConnectionStateMachine( m_logger = logger ?? throw new ArgumentNullException(nameof(logger)); m_timeProvider = timeProvider ?? TimeProvider.System; - m_maxTotalReconnectTime = maxTotalReconnectTime - ?? ReconnectPolicy.DefaultMaxTotalReconnectTime; + m_maxTotalReconnectTime = NormalizeMaxTotalReconnectTime(maxTotalReconnectTime); m_recoveryTimer = m_timeProvider.CreateTimer( static state => ((ConnectionStateMachine)state!).TriggerReconnect(), this, @@ -178,6 +190,29 @@ public ConnectionStateMachine( Timeout.InfiniteTimeSpan); } + /// + /// Validates the reconnect budget up front so an invalid value fails at + /// construction instead of crashing the worker on the first reconnect. + /// Zero means unlimited, consistent with . + /// + /// + private static TimeSpan NormalizeMaxTotalReconnectTime(TimeSpan? maxTotalReconnectTime) + { + TimeSpan value = maxTotalReconnectTime ?? ReconnectPolicy.DefaultMaxTotalReconnectTime; + if (value == TimeSpan.Zero) + { + return Timeout.InfiniteTimeSpan; + } + if (value < TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException( + nameof(maxTotalReconnectTime), + value, + "The maximum total reconnect time must be positive, zero or infinite."); + } + return value; + } + /// /// How long waits for the clean session /// close to complete before it cancels the worker. Set to @@ -198,7 +233,7 @@ public ConnectionStateMachine( /// /// Whether the caller is executing from the state-machine worker. /// - internal bool IsWorkerFlow => m_inWorkerFlow.Value; + internal bool IsWorkerFlow => m_inWorkerFlow.Value is { Active: true }; /// /// Event raised when the state changes. @@ -408,7 +443,7 @@ public async ValueTask DisposeAsync() RequestClose(); m_trigger.Set(); - if (m_inWorkerFlow.Value) + if (IsWorkerFlow) { // Disposed from a StateChanged handler (or work it spawned): // the worker is the caller, so waiting for the close or for @@ -512,10 +547,6 @@ private CancellationTokenSource CreateCloseAwareTokenSource(CancellationToken ct /// private async Task WorkerLoopAsync(CancellationToken ct) { - // Flows into every StateChanged handler the loop raises, so - // DisposeAsync can tell when it is being called by its own worker. - m_inWorkerFlow.Value = true; - m_logger.ConnectionStateMachineWorkerStarted(); try @@ -530,20 +561,32 @@ private async Task WorkerLoopAsync(CancellationToken ct) current = m_state; } - switch (current) + // Flows into every StateChanged handler the step raises, so + // DisposeAsync can tell when it is being called by its own + // worker. Deactivated when the step ends, see m_inWorkerFlow. + var scope = new WorkerScope(); + m_inWorkerFlow.Value = scope; + try { - case ConnectionState.Connecting: - await HandleConnectingAsync(ct).ConfigureAwait(false); - break; - case ConnectionState.Reconnecting: - await HandleReconnectingAsync(ct).ConfigureAwait(false); - break; - case ConnectionState.Failover: - await HandleFailoverAsync(ct).ConfigureAwait(false); - break; - case ConnectionState.Closing: - await HandleClosingAsync(ct).ConfigureAwait(false); - return; + switch (current) + { + case ConnectionState.Connecting: + await HandleConnectingAsync(ct).ConfigureAwait(false); + break; + case ConnectionState.Reconnecting: + await HandleReconnectingAsync(ct).ConfigureAwait(false); + break; + case ConnectionState.Failover: + await HandleFailoverAsync(ct).ConfigureAwait(false); + break; + case ConnectionState.Closing: + await HandleClosingAsync(ct).ConfigureAwait(false); + return; + } + } + finally + { + scope.Active = false; } } } @@ -570,7 +613,17 @@ private async Task WorkerLoopAsync(CancellationToken ct) m_closed.Set(); } - DispatchStateChanges(); + // The final Closed notification is raised by the worker as well. + var finalScope = new WorkerScope(); + m_inWorkerFlow.Value = finalScope; + try + { + DispatchStateChanges(); + } + finally + { + finalScope.Active = false; + } m_logger.ConnectionStateMachineWorkerExiting(); } } @@ -826,10 +879,16 @@ private void TransitionToFailover( lock (m_lock) { - TransitionTo( - ConnectionState.Failover, - error: null, - reconnectAttempt: attempt); + // A close requested while the policy ran out wins: overwriting + // Closing would skip HandleClosingAsync and lose the close. + if (m_state is not ConnectionState.Closing + and not ConnectionState.Closed) + { + TransitionTo( + ConnectionState.Failover, + error: null, + reconnectAttempt: attempt); + } } m_trigger.Set(); diff --git a/src/Opc.Ua.Client/Session/Reconnect/ReconnectPolicy.cs b/src/Opc.Ua.Client/Session/Reconnect/ReconnectPolicy.cs index 40d4115e0a..7640ff725d 100644 --- a/src/Opc.Ua.Client/Session/Reconnect/ReconnectPolicy.cs +++ b/src/Opc.Ua.Client/Session/Reconnect/ReconnectPolicy.cs @@ -134,6 +134,8 @@ public ReconnectPolicy(ReconnectPolicyOptions options) /// /// Maximum total elapsed time for one reconnect cycle across /// outer ManagedSession retries and channel-manager retries. + /// or + /// means unlimited; other negative values are rejected. /// public TimeSpan MaxTotalReconnectTime { get; set; } = DefaultMaxTotalReconnectTime; diff --git a/src/Opc.Ua.Client/Session/Reconnect/ReconnectPolicyOptions.cs b/src/Opc.Ua.Client/Session/Reconnect/ReconnectPolicyOptions.cs index c767b40936..fe893e5543 100644 --- a/src/Opc.Ua.Client/Session/Reconnect/ReconnectPolicyOptions.cs +++ b/src/Opc.Ua.Client/Session/Reconnect/ReconnectPolicyOptions.cs @@ -67,6 +67,8 @@ public sealed record ReconnectPolicyOptions /// /// Maximum total elapsed time for one reconnect cycle across /// outer ManagedSession retries and channel-manager retries. + /// or + /// means unlimited; other negative values are rejected. /// public TimeSpan MaxTotalReconnectTime { get; init; } = TimeSpan.FromMinutes(5); } diff --git a/src/Opc.Ua.Client/Session/Reconnect/SessionReconnectHandler.cs b/src/Opc.Ua.Client/Session/Reconnect/SessionReconnectHandler.cs index c93a7380f8..fcf49b6fee 100644 --- a/src/Opc.Ua.Client/Session/Reconnect/SessionReconnectHandler.cs +++ b/src/Opc.Ua.Client/Session/Reconnect/SessionReconnectHandler.cs @@ -32,6 +32,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua.Redaction; +using Opc.Ua.Security; namespace Opc.Ua.Client { @@ -655,8 +656,9 @@ await UpdateEndpointFromServerAsync( /// /// Updates the configured endpoint from the server discovery endpoint. - /// Falls back to the best available endpoint if the original security - /// configuration is no longer supported by the server. + /// If the original security configuration is no longer offered by the + /// server, falls back only to an endpoint that is at least as secure as + /// the original one; the security is never downgraded. /// /// The configured endpoint to update. /// The optional transport connection for reverse connect. @@ -666,62 +668,130 @@ protected virtual async Task UpdateEndpointFromServerAsync( { // EndpointUrl and SecurityPolicyUri are nullable on ConfiguredEndpoint but every // session entering reconnect was created with both values populated, so the - // bangs reflect that lifecycle invariant. The null! arguments in the catch block - // are intentional sentinels for "no security policy" passed to the modern API - // which retains a non-nullable parameter for backward compatibility. + // bangs reflect that lifecycle invariant. + MessageSecurityMode originalMode = endpoint.Description.SecurityMode; + string originalPolicyUri = endpoint.Description.SecurityPolicyUri!; try { - if (connection != null) - { - await endpoint - .UpdateFromServerAsync( - endpoint.EndpointUrl!, - connection, - endpoint.Description.SecurityMode, - endpoint.Description.SecurityPolicyUri!, - m_telemetry) - .ConfigureAwait(false); - } - else - { - await endpoint - .UpdateFromServerAsync( - endpoint.EndpointUrl!, - endpoint.Description.SecurityMode, - endpoint.Description.SecurityPolicyUri!, - m_telemetry) - .ConfigureAwait(false); - } + await UpdateFromServerAsync(endpoint, connection, originalMode, originalPolicyUri) + .ConfigureAwait(false); } catch (ServiceResultException sre) when ( sre.StatusCode == StatusCodes.BadSecurityPolicyRejected || sre.StatusCode == StatusCodes.BadSecurityModeRejected) { - // The original endpoint security configuration is no longer available on the server. - // Fall back to the best available endpoint without security constraints. + // The original endpoint security configuration is no longer available on the + // server. The discovery reply is unauthenticated (Part 4 5.5.1), so never relax + // the configured security: only accept an endpoint that is at least as secure. m_logger.OriginalEndpointSecurityConfigurationNotAvailable(sre.Message); - if (connection != null) + EndpointDescription? fallback = SelectFallbackEndpoint( + endpoint.Description, + endpoint.DiscoveryEndpoints, + m_logger); + if (fallback == null) { - await endpoint - .UpdateFromServerAsync( - endpoint.EndpointUrl!, - connection, - MessageSecurityMode.Invalid, - null!, - m_telemetry) - .ConfigureAwait(false); + m_logger.NoEndpointWithEqualOrStrongerSecurity(originalMode, originalPolicyUri); + throw; } - else + + await UpdateFromServerAsync( + endpoint, + connection, + fallback.SecurityMode, + fallback.SecurityPolicyUri!).ConfigureAwait(false); + } + } + + /// + /// Selects an endpoint from the discovered endpoints to fall back to when + /// the original security configuration is not offered anymore. Only an + /// endpoint with a security mode at least as strong as the original one + /// and a locally computed security level that is non-zero and not lower + /// than the original one qualifies. The server advertised security level + /// is not trusted because the discovery reply is not authenticated. + /// + /// The fallback endpoint or null if none qualifies. + internal static EndpointDescription? SelectFallbackEndpoint( + EndpointDescription original, + ArrayOf discoveryEndpoints, + ILogger logger) + { + MessageSecurityMode originalMode = original.SecurityMode; + byte originalLevel = SecuredApplication.CalculateSecurityLevel( + originalMode, + original.SecurityPolicyUri!, + logger); + bool originalSecured = + originalMode is MessageSecurityMode.Sign or MessageSecurityMode.SignAndEncrypt || + (original.SecurityPolicyUri != null && + original.SecurityPolicyUri != SecurityPolicies.None); + + EndpointDescription? best = null; + byte bestLevel = 0; + bool bestSameProfile = false; + foreach (EndpointDescription candidate in discoveryEndpoints) + { + if (candidate == null || + candidate.SecurityPolicyUri == null || + candidate.SecurityMode < originalMode || + candidate.SecurityMode > MessageSecurityMode.SignAndEncrypt) { - await endpoint - .UpdateFromServerAsync( - endpoint.EndpointUrl!, - MessageSecurityMode.Invalid, - null!, - m_telemetry) - .ConfigureAwait(false); + continue; + } + + if (originalSecured && + (candidate.SecurityMode == MessageSecurityMode.None || + candidate.SecurityPolicyUri == SecurityPolicies.None)) + { + continue; + } + + byte level = SecuredApplication.CalculateSecurityLevel( + candidate.SecurityMode, + candidate.SecurityPolicyUri, + logger); + if (level == 0 || level < originalLevel) + { + continue; + } + + bool sameProfile = string.Equals( + candidate.TransportProfileUri, + original.TransportProfileUri, + StringComparison.Ordinal); + if (best == null || + level > bestLevel || + (level == bestLevel && sameProfile && !bestSameProfile)) + { + best = candidate; + bestLevel = level; + bestSameProfile = sameProfile; } } + + return best; + } + + private Task UpdateFromServerAsync( + ConfiguredEndpoint endpoint, + ITransportWaitingConnection? connection, + MessageSecurityMode securityMode, + string securityPolicyUri) + { + if (connection != null) + { + return endpoint.UpdateFromServerAsync( + endpoint.EndpointUrl!, + connection, + securityMode, + securityPolicyUri, + m_telemetry); + } + return endpoint.UpdateFromServerAsync( + endpoint.EndpointUrl!, + securityMode, + securityPolicyUri, + m_telemetry); } /// @@ -816,11 +886,19 @@ public static partial void CouldNotReconnectSessionRequestEndpoint( public static partial void CouldNotReconnectSessionErrorMessage(this ILogger logger, RedactionWrapper errorMessage); [LoggerMessage(EventId = ClientEventIds.SessionReconnectHandler + 12, Level = LogLevel.Warning, - Message = "Original endpoint security configuration not available on server, falling back to best" + - " available endpoint. {Message}")] + Message = "Original endpoint security configuration not available on server, looking for an" + + " endpoint with equal or stronger security. {Message}")] public static partial void OriginalEndpointSecurityConfigurationNotAvailable( this ILogger logger, string message); + + [LoggerMessage(EventId = ClientEventIds.SessionReconnectHandler + 13, Level = LogLevel.Error, + Message = "Server offers no endpoint with security equal to or stronger than {SecurityMode}" + + " {SecurityPolicyUri}, refusing to downgrade the endpoint security.")] + public static partial void NoEndpointWithEqualOrStrongerSecurity( + this ILogger logger, + MessageSecurityMode securityMode, + string securityPolicyUri); } } diff --git a/src/Opc.Ua.Client/Session/Redundancy/ClientFailoverCoordinator.cs b/src/Opc.Ua.Client/Session/Redundancy/ClientFailoverCoordinator.cs index 9a648f0f4c..1e02c06bda 100644 --- a/src/Opc.Ua.Client/Session/Redundancy/ClientFailoverCoordinator.cs +++ b/src/Opc.Ua.Client/Session/Redundancy/ClientFailoverCoordinator.cs @@ -91,6 +91,36 @@ public async ValueTask> TransferActiveSubscriptionsAsync return []; } + // A transferred subscription needs a client-side owner on the backup + // session, otherwise its publish engine treats the incoming ids as + // unknown and deletes them (or never publishes and they expire). + // Subscriptions the backup has prepared for the takeover (not yet + // created, TransferId = the active client's subscription id, e.g. + // restored with ISession.Load) are transferred through the session so + // it binds them and resumes publishing. + var discovered = new HashSet(); + foreach (uint id in subscriptionIds) + { + discovered.Add(id); + } + var templates = new SubscriptionCollection(); + foreach (Subscription subscription in backupSession.Subscriptions) + { + if (!subscription.Created && discovered.Contains(subscription.TransferId)) + { + templates.Add(subscription); + } + } + if (templates.Count > 0) + { + return await TransferPreparedSubscriptionsAsync( + backupSession, + subscriptionIds, + templates, + options.SendInitialValues, + ct).ConfigureAwait(false); + } + TransferSubscriptionsResponse response = await backupSession.TransferSubscriptionsAsync( null, subscriptionIds, @@ -102,6 +132,66 @@ public async ValueTask> TransferActiveSubscriptionsAsync return response.Results; } + private static async ValueTask> TransferPreparedSubscriptionsAsync( + ISession backupSession, + ArrayOf subscriptionIds, + SubscriptionCollection templates, + bool sendInitialValues, + CancellationToken ct) + { + var byId = new Dictionary(); + foreach (Subscription template in templates) + { + byId[template.TransferId] = template; + } + + await backupSession.TransferSubscriptionsAsync(templates, sendInitialValues, ct) + .ConfigureAwait(false); + + // Ids the backup has no prepared owner for are still moved with the raw + // service, as before; the caller sees them in the results. + var unowned = new List(); + foreach (uint id in subscriptionIds) + { + if (!byId.ContainsKey(id)) + { + unowned.Add(id); + } + } + var unownedResults = new Dictionary(); + if (unowned.Count > 0) + { + var unownedIds = new ArrayOf(unowned.ToArray()); + TransferSubscriptionsResponse response = await backupSession.TransferSubscriptionsAsync( + null, + unownedIds, + sendInitialValues, + ct) + .ConfigureAwait(false); + ClientBase.ValidateResponse(response.Results, unownedIds); + ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, unownedIds); + for (int ii = 0; ii < unownedIds.Count; ii++) + { + unownedResults[unownedIds[ii]] = response.Results[ii]; + } + } + + var results = new TransferResult[subscriptionIds.Count]; + for (int ii = 0; ii < subscriptionIds.Count; ii++) + { + uint id = subscriptionIds[ii]; + results[ii] = byId.TryGetValue(id, out Subscription? template) + ? new TransferResult + { + StatusCode = template.Created + ? StatusCodes.Good + : StatusCodes.BadSubscriptionIdInvalid + } + : unownedResults[id]; + } + return new ArrayOf(results); + } + private static async ValueTask FindSessionIdByNameAsync( ISession session, string sessionName, diff --git a/src/Opc.Ua.Client/Session/Redundancy/ClientReplicaCoordinator.cs b/src/Opc.Ua.Client/Session/Redundancy/ClientReplicaCoordinator.cs index 07f5f241bd..7337aecd33 100644 --- a/src/Opc.Ua.Client/Session/Redundancy/ClientReplicaCoordinator.cs +++ b/src/Opc.Ua.Client/Session/Redundancy/ClientReplicaCoordinator.cs @@ -130,17 +130,56 @@ private async Task HandleRoleChangeAsync(bool isLeader) if (isLeader) { await PromoteToLeaderAsync(m_cts.Token).ConfigureAwait(false); + if (m_election.IsLeader) + { + RaiseRoleChanged(true); + } + return; } - else if (m_options.Mode == ClientStandbyMode.Cold && m_session != null) + + ManagedSession? demoted = null; + bool abandon = false; + if (m_session != null && + (m_options.Mode == ClientStandbyMode.Cold || m_sessionServedLeader)) { - ManagedSession demoted = m_session; + // A Warm/Hot session that served as leader still carries the leader's + // subscriptions and (with token reuse) the shared AuthenticationToken. + // Kept alive, it would keep publishing next to the new leader and its + // reconnects would take the mirrored session back. Tear it down and, + // for Warm/Hot, reconnect a genuine standby session. + demoted = m_session; + // With token reuse the new leader reactivates this very server + // session: stop using it locally but never CloseSession it. + abandon = m_options.EnableTokenReuse && m_sessionServedLeader; m_session = null; + m_sessionServedLeader = false; + m_adoptedSession = null; demoted.SessionConfigurationChanged -= OnSessionConfigurationChanged; - await demoted.DisposeAsync().ConfigureAwait(false); + if (ReferenceEquals(m_publishedSession, demoted)) + { + m_publishedSession = null; + } } - if (isLeader == m_election.IsLeader) + + if (demoted != null) { - RoleChanged?.Invoke(isLeader); + await DisposeDemotedSessionAsync(demoted, abandon).ConfigureAwait(false); + } + + // The demotion is reported once the leader session is stopped, before and + // independent of the standby connect: a slow or failing connect must not + // keep the application acting as leader. + if (!m_election.IsLeader) + { + RaiseRoleChanged(false); + } + + if (demoted != null && + m_options.Mode != ClientStandbyMode.Cold && + !m_election.IsLeader && + m_session == null) + { + await ConnectStandbySessionAsync(m_cts.Token).ConfigureAwait(false); } } catch (Exception ex) @@ -151,6 +190,51 @@ private async Task HandleRoleChangeAsync(bool isLeader) } } + private void RaiseRoleChanged(bool isLeader) + { + try + { + RoleChanged?.Invoke(isLeader); + } + catch (Exception ex) + { + m_logger.ClientReplicaRoleChangeLeaderIsLeader(ex, isLeader); + } + } + + private async ValueTask DisposeDemotedSessionAsync(ManagedSession demoted, bool abandon) + { + try + { + if (abandon) + { + await demoted.AbandonServerSessionAsync().ConfigureAwait(false); + } + else + { + await demoted.DisposeAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + m_logger.DisposingDemotedLeaderSessionFailed(ex); + } + } + + private async ValueTask ConnectStandbySessionAsync(CancellationToken ct) + { + try + { + m_session = await m_options.CreateSessionAsync!(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // The replica stays without a standby session; the next promotion + // creates one (EnsureLeaderSessionAsync) like a cold standby would. + m_logger.ConnectingStandbySessionAfterDemotionFailed(ex); + } + } + private async Task PromoteToLeaderAsync(CancellationToken ct) { int attempt = 0; @@ -160,6 +244,7 @@ private async Task PromoteToLeaderAsync(CancellationToken ct) try { bool fastActivated = await EnsureLeaderSessionAsync(ct).ConfigureAwait(false); + m_sessionServedLeader = m_session != null; if (m_options.ConfigureLeaderAsync != null && m_session != null) { await m_options.ConfigureLeaderAsync(m_session, fastActivated, ct) @@ -193,6 +278,13 @@ private async ValueTask EnsureLeaderSessionAsync(CancellationToken ct) { return false; } + if (ReferenceEquals(m_session, m_adoptedSession)) + { + // A retry after a failed configure/publish step: this session already + // is the reactivated mirrored leader session. Closing it as the + // follower's own session would delete it with all its subscriptions. + return true; + } bool mutatedForReuse = false; try @@ -218,13 +310,23 @@ private async ValueTask EnsureLeaderSessionAsync(CancellationToken ct) { config.Identity = m_session.Identity; } - if (m_session.ApplySessionConfiguration(config)) + + // Adopting the mirrored configuration replaces this session's own + // SessionId/AuthenticationToken; close that server session first so it + // (and a Hot standby's sampling subscriptions) is not orphaned until it + // times out. + await CloseOwnSessionQuietlyAsync(m_session, ct).ConfigureAwait(false); + mutatedForReuse = true; + if (!m_session.ApplySessionConfiguration(config)) { - mutatedForReuse = true; - await m_session.ReactivateMirroredSessionAsync(m_session.ConfiguredEndpoint, ct) - .ConfigureAwait(false); - return true; + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "The mirrored session configuration could not be applied."); } + await m_session.ReactivateMirroredSessionAsync(m_session.ConfiguredEndpoint, ct) + .ConfigureAwait(false); + m_adoptedSession = m_session; + return true; } } catch (Exception ex) @@ -246,6 +348,26 @@ await m_session.ReactivateMirroredSessionAsync(m_session.ConfiguredEndpoint, ct) return false; } + private async ValueTask CloseOwnSessionQuietlyAsync(ManagedSession session, CancellationToken ct) + { + if (session.SessionId.IsNull) + { + return; + } + try + { + // Straight to the inner session: the managed service gate waits for the + // connection state machine, which would stall promotion while the standby + // session is reconnecting. A failed close only leaves it to time out. + await session.InnerSession.CloseSessionAsync(null, deleteSubscriptions: true, ct) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + m_logger.ClosingStandbySessionBeforeTokenReuseFailed(ex); + } + } + private async ValueTask DisposeQuietlyAsync(ManagedSession session) { try @@ -334,6 +456,8 @@ public async ValueTask DisposeAsync() private readonly BackgroundTaskScope m_backgroundWork; private ManagedSession? m_session; private ManagedSession? m_publishedSession; + private bool m_sessionServedLeader; + private ManagedSession? m_adoptedSession; } /// @@ -363,5 +487,23 @@ public static partial void ClientReplicaPromotionAttemptAttemptFailed( [LoggerMessage(EventId = ClientEventIds.ClientReplicaCoordinator + 3, Level = LogLevel.Information, Message = "Disposing a token-reuse session that failed to reactivate threw; ignoring.")] public static partial void DisposingTokenReuseSessionThatFailed(this ILogger logger, Exception? exception); + + [LoggerMessage(EventId = ClientEventIds.ClientReplicaCoordinator + 4, Level = LogLevel.Information, + Message = "Closing the standby's own session before token reuse failed; it expires on the server.")] + public static partial void ClosingStandbySessionBeforeTokenReuseFailed( + this ILogger logger, + Exception? exception); + + [LoggerMessage(EventId = ClientEventIds.ClientReplicaCoordinator + 5, Level = LogLevel.Warning, + Message = "Tearing down the demoted leader session failed; ignoring.")] + public static partial void DisposingDemotedLeaderSessionFailed( + this ILogger logger, + Exception? exception); + + [LoggerMessage(EventId = ClientEventIds.ClientReplicaCoordinator + 6, Level = LogLevel.Warning, + Message = "Connecting the standby session after demotion failed; the next promotion connects.")] + public static partial void ConnectingStandbySessionAfterDemotionFailed( + this ILogger logger, + Exception? exception); } } diff --git a/src/Opc.Ua.Client/Session/Redundancy/IServerRedundancyHandler.cs b/src/Opc.Ua.Client/Session/Redundancy/IServerRedundancyHandler.cs index 0c17653ce1..87c40ef101 100644 --- a/src/Opc.Ua.Client/Session/Redundancy/IServerRedundancyHandler.cs +++ b/src/Opc.Ua.Client/Session/Redundancy/IServerRedundancyHandler.cs @@ -138,6 +138,30 @@ public static ServerFailoverDecision ShouldFailover( throw new ArgumentNullException(nameof(currentEndpoint)); } + // Interface-typed callers bind here rather than to the concrete + // handler's instance method; route them to the full health checks. + if (redundancyHandler is DefaultServerRedundancyHandler defaultHandler) + { + return defaultHandler.ShouldFailover(redundancyInfo, currentEndpoint); + } + + // Part 4 §6.6.2.4.3: do not fail over away from a server that is still Healthy. + if (redundancyInfo.Mode is RedundancySupport.None or RedundancySupport.Transparent) + { + return new ServerFailoverDecision( + isFailoverWarranted: false, + DateTime.MinValue, + "Redundancy mode does not require client-side failover."); + } + if (redundancyInfo.ServiceLevelAccessible && + ServiceLevels.IsHealthy(redundancyInfo.ServiceLevel)) + { + return new ServerFailoverDecision( + isFailoverWarranted: false, + DateTime.MinValue, + "Current server remains in the Healthy service level subrange."); + } + return redundancyHandler.SelectFailoverTarget(redundancyInfo, currentEndpoint) != null ? new ServerFailoverDecision( isFailoverWarranted: true, diff --git a/src/Opc.Ua.Client/Session/Session.ChannelManager.cs b/src/Opc.Ua.Client/Session/Session.ChannelManager.cs index f494d977ae..4b20dce52e 100644 --- a/src/Opc.Ua.Client/Session/Session.ChannelManager.cs +++ b/src/Opc.Ua.Client/Session/Session.ChannelManager.cs @@ -240,6 +240,14 @@ private async ValueTask ReconnectParticipantAsync( return ParticipantReconnectResult.RequiresSessionRecreate; } + // An identity update or subscription transfer may hold the + // reconnect lock while its request waits for this channel to + // become Ready, which needs this callback to return first. + // Interrupt it (it fails with BadSecureChannelClosed and can be + // retried) instead of deadlocking on the lock. Operations queued + // on the lock behind it are interrupted as they take it, until the + // reconnect below has finished with the lock. + BeginReconnectLockInterruption(); try { await ReconnectCoreAsync( @@ -285,6 +293,10 @@ await ReconnectCoreAsync( SessionId); return ParticipantReconnectResult.TransientFailure; } + finally + { + EndReconnectLockInterruption(); + } } /// @@ -460,7 +472,31 @@ private static async ValueTask ReconnectManagedChannelAsync( private IClientChannelManager? m_channelManager; private IManagedTransportChannel? m_managedChannel; private PendingSubscriptionRecovery? m_pendingSubscriptionRecovery; + + /// + /// Single-flight gate for : the + /// channel manager and the managed session can both complete the same + /// pending recovery and must not recreate the subscriptions twice. + /// + // CA2213: never allocates a wait handle, so there is nothing to release; + // disposing it would fault a completion racing with session teardown. +#pragma warning disable CA2213 + private readonly SemaphoreSlim m_subscriptionRecoveryGate = new(1, 1); +#pragma warning restore CA2213 private ReconnectDeadline? m_deferredRecoveryDeadline; + + /// + /// Cancellation of the operation that currently holds the reconnect + /// lock across service calls; see . + /// + private CancellationTokenSource? m_reconnectLockHolder; + + /// + /// Number of channel-manager reconnects of this session that wait for + /// the reconnect lock; while non-zero, every operation that takes the + /// lock is interrupted; see . + /// + private int m_reconnectLockInterrupts; private int m_subscriptionRecoveryDeferrals; private ChannelRecoveryOwner? m_channelRecoveryOwner; private bool m_boundChannelReconnect; diff --git a/src/Opc.Ua.Client/Session/Session.cs b/src/Opc.Ua.Client/Session/Session.cs index 5d0027ebb3..cf57c74473 100644 --- a/src/Opc.Ua.Client/Session/Session.cs +++ b/src/Opc.Ua.Client/Session/Session.cs @@ -522,7 +522,18 @@ protected virtual async ValueTask DisposeAsyncCore(bool disposing) // Before the keep-alive timer and the channel go away: a publish // notification already dispatched still reads session state. - await BackgroundWork.DisposeAsync().ConfigureAwait(false); + if (IsDisposeBlockingBackgroundWork) + { + // Disposed from a handler running on this background work + // (e.g. Notification), directly or through a wrapper the + // handler waits for: the drain would wait for the caller + // itself until it times out. Stop and cancel without waiting. + BackgroundWork.Dispose(); + } + else + { + await BackgroundWork.DisposeAsync().ConfigureAwait(false); + } try { @@ -1134,11 +1145,21 @@ private void RestoreSessionState(SessionState sessionConfiguration) { ByteString serverCertificate = m_endpoint.Description?.ServerCertificate ?? default; m_sessionName = sessionConfiguration.SessionName ?? "SessionName"; + + // The endpoint may carry the full server certificate chain + // (leaf + issuers); only the leaf is the server certificate. + Certificate? restoredServerCertificate = null; + if (!serverCertificate.IsEmpty) + { + using CertificateCollection serverCertificateChain = + Utils.ParseCertificateChainBlob(serverCertificate, m_telemetry); + if (serverCertificateChain.Count > 0) + { + restoredServerCertificate = serverCertificateChain[0].AddRef(); + } + } m_serverCertificate?.Dispose(); - m_serverCertificate = - !serverCertificate.IsEmpty - ? Certificate.FromRawData(serverCertificate) - : null; + m_serverCertificate = restoredServerCertificate; m_identity = sessionConfiguration.Identity ?? new UserIdentity(); m_checkDomain = sessionConfiguration.CheckDomain; m_serverNonce = sessionConfiguration.ServerNonce; @@ -1798,6 +1819,8 @@ public async ValueTask UpdateIdentityAsync( using Activity? activity = m_telemetry.StartActivity(); await AcquireIdentityUpdateLockAsync(ct).ConfigureAwait(false); + CancellationTokenSource lockHolder = EnterReconnectLockHolder(ct); + CancellationToken operationCt = lockHolder.Token; try { IdentitySelectionContext context; @@ -1839,9 +1862,9 @@ public async ValueTask UpdateIdentityAsync( bool hasOverride = !string.IsNullOrEmpty(overrideUserTokenPolicyUri); // Pin only when there's actually a bound ephemeral // key; an override request bypasses pinning (the - // caller is explicitly asking for a new policy and - // the ephemeral key will be renewed on the next - // service call AFTER UpdateSessionAsync succeeds). + // caller is explicitly asking for a new policy; a key + // for it is requested before the new identity is + // activated when the token must be encrypted with it). string? boundEphemeralUri = !hasOverride && m_eccServerEphemeralKey != null ? m_userTokenSecurityPolicyUri @@ -1857,14 +1880,65 @@ public async ValueTask UpdateIdentityAsync( boundEphemeralUri); } - IUserIdentity identity = await provider.AcquireIdentityAsync(context, ct) + IUserIdentity identity = await provider.AcquireIdentityAsync(context, operationCt) .ConfigureAwait(false); string? previousPolicyUri = null; Nonce? previousEphemeralKey = null; bool overrideCommitted = false; + bool keyBoundToOverride = false; + bool fetchEphemeralKey = false; if (!string.IsNullOrEmpty(overrideUserTokenPolicyUri)) { + string? originalPolicyUri; + lock (m_lock) + { + originalPolicyUri = m_userTokenSecurityPolicyUri; + keyBoundToOverride = m_eccServerEphemeralKey != null && + string.Equals( + m_userTokenSecurityPolicyUri, + overrideUserTokenPolicyUri, + StringComparison.Ordinal); + } + + // Part 6 6.8.2: a token encrypted for an ECC policy needs + // the EphemeralKey the server returned for exactly that + // policy, and the server only returns it in an activation + // response that asked for it (ECDHPolicyUri). Obtain the + // key first by reactivating the current identity, then + // activate the new identity with it below. + fetchEphemeralKey = !keyBoundToOverride && + RequiresEphemeralKey(overrideUserTokenPolicyUri!, identity); + if (fetchEphemeralKey) + { + // The fetch replaces (and disposes) the key of the + // current identity: park a copy so a failed activation + // of the new identity can fall back to it below. +#pragma warning disable CA2000 // disposed on success, restored into the session on failure + previousEphemeralKey = CopyEphemeralKey(originalPolicyUri); +#pragma warning restore CA2000 + try + { + await RequestEphemeralKeyAsync( + overrideUserTokenPolicyUri!, + originalPolicyUri, + operationCt).ConfigureAwait(false); + } + catch + { + lock (m_lock) + { + if (m_eccServerEphemeralKey == null) + { + m_eccServerEphemeralKey = previousEphemeralKey; + previousEphemeralKey = null; + } + } + previousEphemeralKey?.Dispose(); + throw; + } + } + // Commit override state ONLY after the new identity // has been materialised — if AcquireIdentityAsync // threw (cert load failure, policy mismatch, etc.) @@ -1874,11 +1948,20 @@ public async ValueTask UpdateIdentityAsync( // ever renewed by a successful activation response, so // a failed override would otherwise leave the previous // identity without the key its next encryption needs. + // A key that was just fetched for (or already belongs + // to) the override policy stays in place: the new + // identity is encrypted with it. The previous key was + // parked as a copy before the fetch replaced it. lock (m_lock) { - previousPolicyUri = m_userTokenSecurityPolicyUri; - previousEphemeralKey = m_eccServerEphemeralKey; - m_eccServerEphemeralKey = null; + previousPolicyUri = fetchEphemeralKey + ? originalPolicyUri + : m_userTokenSecurityPolicyUri; + if (!fetchEphemeralKey && !keyBoundToOverride) + { + previousEphemeralKey = m_eccServerEphemeralKey; + m_eccServerEphemeralKey = null; + } m_userTokenSecurityPolicyUri = overrideUserTokenPolicyUri; } overrideCommitted = true; @@ -1894,7 +1977,7 @@ public async ValueTask UpdateIdentityAsync( try { - await UpdateSessionAsync(identity, default, ct).ConfigureAwait(false); + await UpdateSessionAsync(identity, default, operationCt).ConfigureAwait(false); previousEphemeralKey?.Dispose(); } catch when (overrideCommitted) @@ -1902,12 +1985,34 @@ public async ValueTask UpdateIdentityAsync( // The server did not accept the new identity, so the old // one stays active. Put the token policy and the ephemeral // key back so reconnects keep encrypting it with the policy - // and key it was issued for. + // and key it was issued for. A key that already belonged to + // the override policy is still the server's current one. + // So is a key fetched for the override: the server only + // accepts the key of its latest activation response, so it + // is kept (with its policy) when the old identity can be + // encrypted under that policy. Otherwise the parked key is + // the best that is left. lock (m_lock) { - m_userTokenSecurityPolicyUri = previousPolicyUri; - m_eccServerEphemeralKey?.Dispose(); - m_eccServerEphemeralKey = previousEphemeralKey; + if (fetchEphemeralKey && + m_eccServerEphemeralKey != null && + m_identity != null && + m_endpoint.Description.FindUserTokenPolicy( + m_identity.TokenType, + m_identity.IssuedTokenType, + overrideUserTokenPolicyUri!) != null) + { + previousEphemeralKey?.Dispose(); + } + else + { + m_userTokenSecurityPolicyUri = previousPolicyUri; + if (!keyBoundToOverride) + { + m_eccServerEphemeralKey?.Dispose(); + m_eccServerEphemeralKey = previousEphemeralKey; + } + } } throw; } @@ -1920,13 +2025,94 @@ public async ValueTask UpdateIdentityAsync( SessionId); throw; } + catch (OperationCanceledException) when (IsInterruptedByChannelRecovery(lockHolder, ct)) + { + throw InterruptedByChannelRecovery(); + } finally { + ExitReconnectLockHolder(lockHolder); Reconnecting = false; m_reconnectLock.Release(); } } + /// + /// Registers the caller as holder of for an + /// operation that sends requests while holding it. A channel-manager + /// reconnect of this session waits for that lock, and the managed + /// channel only becomes Ready again once the reconnect returned, so a + /// holder waiting for Ready would deadlock with it. The reconnect + /// therefore cancels the holder (see ). + /// + private CancellationTokenSource EnterReconnectLockHolder(CancellationToken ct) + { + var holder = CancellationTokenSource.CreateLinkedTokenSource(ct); + Interlocked.Exchange(ref m_reconnectLockHolder, holder); + + // An operation that was queued on the lock behind the interrupted + // holder takes it while the channel recovery still waits for it: + // interrupt it right away instead of deadlocking again. + if (Volatile.Read(ref m_reconnectLockInterrupts) != 0) + { + holder.Cancel(); + } + return holder; + } + + private void ExitReconnectLockHolder(CancellationTokenSource holder) + { + Interlocked.CompareExchange(ref m_reconnectLockHolder, null, holder); + holder.Dispose(); + } + + /// + /// Interrupts the operation currently holding + /// across network I/O, and every operation that takes the lock until + /// , so a channel-manager + /// reconnect can take the lock. + /// + private void BeginReconnectLockInterruption() + { + Interlocked.Increment(ref m_reconnectLockInterrupts); + CancellationTokenSource? holder = Volatile.Read(ref m_reconnectLockHolder); + if (holder == null) + { + return; + } + try + { + holder.Cancel(); + } + catch (ObjectDisposedException) + { + // The holder finished in the meantime. + } + } + + /// + /// Ends an interruption started by + /// once the channel-manager reconnect no longer needs the lock. + /// + private void EndReconnectLockInterruption() + { + Interlocked.Decrement(ref m_reconnectLockInterrupts); + } + + private static bool IsInterruptedByChannelRecovery( + CancellationTokenSource holder, + CancellationToken ct) + { + return holder.IsCancellationRequested && !ct.IsCancellationRequested; + } + + private static ServiceResultException InterruptedByChannelRecovery() + { + return ServiceResultException.Create( + StatusCodes.BadSecureChannelClosed, + "The operation was interrupted because the channel is being recovered."); + } + private async Task AcquireIdentityUpdateLockAsync(CancellationToken ct) { while (true) @@ -1943,6 +2129,89 @@ private async Task AcquireIdentityUpdateLockAsync(CancellationToken ct) } } + /// + /// Whether activating under the user-token + /// policy encrypts the token with a server + /// ephemeral key (ECC policies, UserName and IssuedToken tokens). + /// + private bool RequiresEphemeralKey(string policyUri, IUserIdentity identity) + { + if (identity.TokenType is not (UserTokenType.UserName or UserTokenType.IssuedToken)) + { + return false; + } + SecurityPolicyInfo? policy = m_securityPolicies.GetInfo(policyUri); + return policy != null && policy.EphemeralKeyAlgorithm != CertificateKeyAlgorithm.None; + } + + /// + /// Copies the current server ephemeral key, which belongs to the + /// user-token policy , or returns + /// null when there is none. + /// + private Nonce? CopyEphemeralKey(string? policyUri) + { + byte[]? data; + lock (m_lock) + { + data = m_eccServerEphemeralKey?.Data; + } + if (data == null || string.IsNullOrEmpty(policyUri)) + { + return null; + } + SecurityPolicyInfo? policy = m_securityPolicies.GetInfo(policyUri!); + return policy == null ? null : Nonce.CreateNonce(policy, [.. data]); + } + + /// + /// Reactivates the session with the currently active identity and asks + /// the server for an ephemeral key for + /// (Part 6 6.8.2), so a following activation can encrypt a new + /// identity for that policy. The caller holds the reconnect lock. + /// + /// + /// The server did not return an ephemeral key for the policy. + /// + private async Task RequestEphemeralKeyAsync( + string policyUri, + string? currentPolicyUri, + CancellationToken ct) + { + ByteString serverNonce; + ArrayOf preferredLocales; + IUserIdentity? currentIdentity; + Nonce? currentKey; + lock (m_lock) + { + serverNonce = m_serverNonce; + preferredLocales = m_preferredLocales; + currentIdentity = m_identity; + currentKey = m_eccServerEphemeralKey; + } + + await ReactivateExistingSessionAsync( + currentIdentity, + preferredLocales, + serverNonce, + ct, + ephemeralKeyPolicyUri: policyUri).ConfigureAwait(false); + + lock (m_lock) + { + if (m_eccServerEphemeralKey == null || + ReferenceEquals(m_eccServerEphemeralKey, currentKey)) + { + // Keep the current identity's policy with its key. + m_userTokenSecurityPolicyUri = currentPolicyUri; + throw ServiceResultException.Create( + StatusCodes.BadSecurityPolicyRejected, + "The server did not provide an ephemeral key for user token policy '{0}'.", + policyUri); + } + } + } + /// /// Returns when the current endpoint /// advertises at least one whose @@ -2012,13 +2281,19 @@ await ReactivateExistingSessionAsync(identity, preferredLocales, serverNonce, ct /// /// A cancellation token. /// An optional client bound to this recovery callback's send channel. + /// + /// When set, the ephemeral key is requested for this user-token policy + /// instead of the policy the identity is encrypted with, and that + /// policy stays in effect afterwards (it owns the returned key). + /// /// private async Task ReactivateExistingSessionAsync( IUserIdentity? identity, ArrayOf preferredLocales, ByteString serverNonce, CancellationToken ct, - SessionClient? recoveryClient = null) + SessionClient? recoveryClient = null, + string? ephemeralKeyPolicyUri = null) { // get the identity token. string securityPolicyUri = @@ -2128,14 +2403,15 @@ await identityToken.EncryptAsync( // it must not outlive a failed activation: the previously active // identity is still the one the server accepts in that case. string? previousUserTokenSecurityPolicyUri = m_userTokenSecurityPolicyUri; - m_userTokenSecurityPolicyUri = tokenSecurityPolicyUri; + string keyPolicyUri = ephemeralKeyPolicyUri ?? tokenSecurityPolicyUri; + m_userTokenSecurityPolicyUri = keyPolicyUri; ActivateSessionResponse response; ByteString activationRequestNonce = serverNonce; try { RequestHeader? requestHeader = CreateRequestHeaderForActivateSession( - tokenSecurityPolicyUri!); + keyPolicyUri); response = await (recoveryClient ?? this).ActivateSessionAsync( requestHeader, @@ -2257,6 +2533,8 @@ public async Task ReactivateSubscriptionsAsync( { bool reconnecting = false; await m_reconnectLock.WaitAsync(ct).ConfigureAwait(false); + CancellationTokenSource lockHolder = EnterReconnectLockHolder(ct); + CancellationToken operationCt = lockHolder.Token; try { reconnecting = Reconnecting; @@ -2265,7 +2543,7 @@ public async Task ReactivateSubscriptionsAsync( for (int ii = 0; ii < subscriptions.Count; ii++) { if (!await subscriptions[ii] - .TransferAsync(this, subscriptionIds[ii], [], ct) + .TransferAsync(this, subscriptionIds[ii], [], operationCt) .ConfigureAwait(false)) { m_logger.SubscriptionIdSubscriptionIdFailedReactivate( @@ -2281,7 +2559,7 @@ public async Task ReactivateSubscriptionsAsync( { ArrayOf resendResults = await this.ResendDataAsync( subscriptions.Select(s => s.Id).ToArrayOf(), - ct).ConfigureAwait(false); + operationCt).ConfigureAwait(false); for (int ii = 0; ii < resendResults.Count; ii++) { // no need to try for subscriptions which do not exist @@ -2304,8 +2582,13 @@ public async Task ReactivateSubscriptionsAsync( failedSubscriptions, SessionId); } + catch (OperationCanceledException) when (IsInterruptedByChannelRecovery(lockHolder, ct)) + { + throw InterruptedByChannelRecovery(); + } finally { + ExitReconnectLockHolder(lockHolder); Reconnecting = reconnecting; m_reconnectLock.Release(); } @@ -2370,6 +2653,17 @@ private async Task TransferSubscriptionsCoreAsync( { bool reconnecting = false; await m_reconnectLock.WaitAsync(ct).ConfigureAwait(false); + CancellationTokenSource lockHolder = EnterReconnectLockHolder(ct); + CancellationToken operationCt = lockHolder.Token; + + // The server reports the transfer to the old session with a + // Good_SubscriptionTransferred, which the subscription must + // not take for an unsolicited transfer (Reconnecting is only + // set on this session, not on the old one). + foreach (Subscription subscription in subscriptions) + { + subscription.OnTransferStarting(); + } try { reconnecting = Reconnecting; @@ -2379,7 +2673,7 @@ private async Task TransferSubscriptionsCoreAsync( null, subscriptionIds, sendInitialValues, - ct) + operationCt) .ConfigureAwait(false); ArrayOf results = response.Results; ArrayOf diagnosticInfos = response.DiagnosticInfos; @@ -2406,7 +2700,8 @@ await subscriptions[ii].TransferWithAcknowledgementsAsync( this, subscriptionIds[ii], results[ii].AvailableSequenceNumbers, - ct) + true, + operationCt) .ConfigureAwait(false); if (transferredSubscription) { @@ -2475,6 +2770,11 @@ await subscriptions[ii].TransferWithAcknowledgementsAsync( } finally { + ExitReconnectLockHolder(lockHolder); + foreach (Subscription subscription in subscriptions) + { + subscription.OnTransferFinished(); + } Reconnecting = reconnecting; m_reconnectLock.Release(); } @@ -3093,6 +3393,22 @@ internal Task ReactivateOnNetworkEndpointAsync( networkRecovery: true); } + /// + /// Forgets the server session this client is bound to without closing it + /// on the server. Afterwards the client is no longer connected, so a later + /// close or dispose only tears down the local state and the channel and + /// never sends CloseSession. Used when another client (a promoted replica) + /// takes the same server session over by reactivating it. + /// + internal void AbandonServerSession() + { + DeleteSubscriptionsOnClose = false; + lock (m_lock) + { + SessionCreated(default, default); + } + } + internal Task ReactivateMirroredSessionAsync( ConfiguredEndpoint endpoint, CancellationToken ct = default) @@ -3518,6 +3834,27 @@ await m_reconnectLock /// /// The subscription recovery deadline expires. internal async Task CompleteSessionRecoveryAsync(CancellationToken ct) + { + if (Volatile.Read(ref m_subscriptionRecoveryDeferrals) != 0 || + m_pendingSubscriptionRecovery == null) + { + return; + } + + // Single flight: a second caller waits for the running recovery and + // then only acts if that one left the recovery pending (failed). + await m_subscriptionRecoveryGate.WaitAsync(ct).ConfigureAwait(false); + try + { + await CompleteSessionRecoveryCoreAsync(ct).ConfigureAwait(false); + } + finally + { + m_subscriptionRecoveryGate.Release(); + } + } + + private async Task CompleteSessionRecoveryCoreAsync(CancellationToken ct) { if (Volatile.Read(ref m_subscriptionRecoveryDeferrals) != 0) { @@ -4204,7 +4541,8 @@ await identityToken.EncryptAsync( subscriptionId, default, false, - notificationMessage); + notificationMessage, + republished: true); return (true, ServiceResult.Good); } @@ -4853,19 +5191,41 @@ private async Task OnSendKeepAliveAsync( continue; } + long timeoutHint = Math.Min(2L * KeepAliveInterval, int.MaxValue); var requestHeader = new RequestHeader { RequestHandle = NewRequestHandle(), - TimeoutHint = (uint)(KeepAliveInterval * 2), + TimeoutHint = (uint)timeoutHint, ReturnDiagnostics = 0 }; - ReadResponse result = await ReadAsync( - requestHeader, - 0, - TimestampsToReturn.Neither, - nodesToRead, - ct).ConfigureAwait(false); + // Part 4 7.32: the client applies timeoutHint to the call + // itself. Without it the read waits for the transport's + // OperationTimeout (minutes) on a silent server, and no + // keep-alive error - hence no reconnect - is raised until then. + using CancellationTokenSource readTimeout = + m_timeProvider.CreateCancellationTokenSource( + TimeSpan.FromMilliseconds(timeoutHint)); + using var readCancellation = + CancellationTokenSource.CreateLinkedTokenSource(ct, readTimeout.Token); + ReadResponse result; + try + { + result = await ReadAsync( + requestHeader, + 0, + TimestampsToReturn.Neither, + nodesToRead, + readCancellation.Token).ConfigureAwait(false); + } + catch (Exception) when ( + readTimeout.IsCancellationRequested && !ct.IsCancellationRequested) + { + throw ServiceResultException.Create( + StatusCodes.BadNoCommunication, + "Keep alive read timed out after {0} ms.", + timeoutHint); + } // read the server status. ArrayOf values = result.Results; @@ -6261,6 +6621,95 @@ protected virtual void ProcessResponseAdditionalHeader( internal BackgroundTaskScope BackgroundWork { get; } = new(nameof(Session), AmbientMessageContext.Telemetry); + /// + /// Runs on and marks + /// the flow as owned by this session's background work, so a handler + /// that closes or disposes the session from inside it does not wait for + /// the drain of the very operation it runs in. + /// + internal bool RunBackgroundWork(string operation, Action work) + { + return BackgroundWork.Run(operation, _ => + { + var scope = new BackgroundWorkScope(this); + s_backgroundWorkOwner.Value = scope; + try + { + work(); + } + finally + { + // Tasks and continuations the work spawned captured the + // scope with the execution context: deactivate it so they + // do not pass as this work item once it has returned. + scope.Active = false; + s_backgroundWorkOwner.Value = null; + } + return default; + }); + } + + /// + /// Called by a wrapper (the managed session) that is asked to close or + /// dispose from a flow that may be one of this session's background + /// work items, before it hands the close to another flow. A dispose + /// running on that other flow then does not wait for the drain of the + /// work item, which is blocked on the wrapper until the close is done. + /// + internal void NoteCloseFromBackgroundWork() + { + BackgroundWorkScope? scope = CurrentBackgroundWorkScope; + if (scope != null) + { + Volatile.Write(ref m_closingBackgroundWork, scope); + } + } + + /// + /// The background work item of this session the current flow runs in, + /// or null. + /// + private BackgroundWorkScope? CurrentBackgroundWorkScope => + s_backgroundWorkOwner.Value is { Active: true } scope && + ReferenceEquals(scope.Owner, this) + ? scope + : null; + + /// + /// Whether the dispose must not wait for the background work drain, + /// because the caller is (or waits for) a work item that is still + /// running and the drain would wait for it until it times out. + /// + private bool IsDisposeBlockingBackgroundWork => + CurrentBackgroundWorkScope != null || + Volatile.Read(ref m_closingBackgroundWork) is { Active: true }; + + /// + /// Marks one work item run by . + /// + private sealed class BackgroundWorkScope + { + public BackgroundWorkScope(Session owner) + { + Owner = owner; + } + + public Session Owner { get; } + + public volatile bool Active = true; + } + + /// + /// The background work item that runs the current flow. + /// + private static readonly AsyncLocal s_backgroundWorkOwner = new(); + + /// + /// A background work item that asked a wrapper to close this session + /// and waits for it, see . + /// + private BackgroundWorkScope? m_closingBackgroundWork; + /// /// If set totrue then the domain in the certificate must match the endpoint used. /// diff --git a/src/Opc.Ua.Client/Session/SessionClientBatched.cs b/src/Opc.Ua.Client/Session/SessionClientBatched.cs index 68ce698086..5bc9402894 100644 --- a/src/Opc.Ua.Client/Session/SessionClientBatched.cs +++ b/src/Opc.Ua.Client/Session/SessionClientBatched.cs @@ -108,29 +108,39 @@ async ValueTask BrowseBatchedAsync( out List stringTable, nodesToBrowse.Count, operationLimit); - foreach (ArrayOf nodesToBrowseBatch in nodesToBrowse.Batch((int)operationLimit)) + try { - requestHeader.RequestHandle = 0; - response = await base.BrowseAsync( - requestHeader, - view, - requestedMaxReferencesPerNode, - nodesToBrowseBatch, - ct).ConfigureAwait(false); + foreach (ArrayOf nodesToBrowseBatch in nodesToBrowse.Batch((int)operationLimit)) + { + requestHeader.RequestHandle = 0; + response = await base.BrowseAsync( + requestHeader, + view, + requestedMaxReferencesPerNode, + nodesToBrowseBatch, + ct).ConfigureAwait(false); - ArrayOf batchResults = response.Results; - ArrayOf batchDiagnosticInfos = response.DiagnosticInfos; + ArrayOf batchResults = response.Results; + ArrayOf batchDiagnosticInfos = response.DiagnosticInfos; - ValidateResponse(batchResults, nodesToBrowseBatch); - ValidateDiagnosticInfos(batchDiagnosticInfos, nodesToBrowseBatch); + ValidateResponse(batchResults, nodesToBrowseBatch); + ValidateDiagnosticInfos(batchDiagnosticInfos, nodesToBrowseBatch); - AddResponses( - ref results, - ref diagnosticInfos, - ref stringTable, - batchResults, - batchDiagnosticInfos, - response.ResponseHeader.StringTable); + AddResponses( + ref results, + ref diagnosticInfos, + ref stringTable, + batchResults, + batchDiagnosticInfos, + response.ResponseHeader.StringTable); + } + } + catch (Exception) when (results.Count > 0) + { + // The caller never sees the continuation points of the completed + // batches, so release them before the failure propagates. + await ReleaseBrowseContinuationPointsAsync(results).ConfigureAwait(false); + throw; } ValidateResponse(response); @@ -189,28 +199,38 @@ async ValueTask BrowseNextBatchedAsync( out List stringTable, continuationPoints.Count, operationLimit); - foreach (ArrayOf continuationPointsBatch in continuationPoints.Batch((int)operationLimit)) + try { - requestHeader.RequestHandle = 0; - response = await base.BrowseNextAsync( - requestHeader, - releaseContinuationPoints, - continuationPointsBatch, - ct).ConfigureAwait(false); + foreach (ArrayOf continuationPointsBatch in continuationPoints.Batch((int)operationLimit)) + { + requestHeader.RequestHandle = 0; + response = await base.BrowseNextAsync( + requestHeader, + releaseContinuationPoints, + continuationPointsBatch, + ct).ConfigureAwait(false); - ArrayOf batchResults = response.Results; - ArrayOf batchDiagnosticInfos = response.DiagnosticInfos; + ArrayOf batchResults = response.Results; + ArrayOf batchDiagnosticInfos = response.DiagnosticInfos; - ValidateResponse(batchResults, continuationPointsBatch); - ValidateDiagnosticInfos(batchDiagnosticInfos, continuationPointsBatch); + ValidateResponse(batchResults, continuationPointsBatch); + ValidateDiagnosticInfos(batchDiagnosticInfos, continuationPointsBatch); - AddResponses( - ref results, - ref diagnosticInfos, - ref stringTable, - batchResults, - batchDiagnosticInfos, - response.ResponseHeader.StringTable); + AddResponses( + ref results, + ref diagnosticInfos, + ref stringTable, + batchResults, + batchDiagnosticInfos, + response.ResponseHeader.StringTable); + } + } + catch (Exception) when (results.Count > 0 && !releaseContinuationPoints) + { + // The caller never sees the continuation points the completed + // batches returned, so release them before the failure propagates. + await ReleaseBrowseContinuationPointsAsync(results).ConfigureAwait(false); + throw; } ValidateResponse(response); @@ -496,31 +516,45 @@ async ValueTask HistoryReadBatchedAsync( out List stringTable, nodesToRead.Count, operationLimit); - foreach (ArrayOf batchNodesToRead in nodesToRead - .Batch((int)operationLimit)) + try { - requestHeader.RequestHandle = 0; - response = await base.HistoryReadAsync( - requestHeader, - historyReadDetails, - timestampsToReturn, - releaseContinuationPoints, - batchNodesToRead, - ct).ConfigureAwait(false); + foreach (ArrayOf batchNodesToRead in nodesToRead + .Batch((int)operationLimit)) + { + requestHeader.RequestHandle = 0; + response = await base.HistoryReadAsync( + requestHeader, + historyReadDetails, + timestampsToReturn, + releaseContinuationPoints, + batchNodesToRead, + ct).ConfigureAwait(false); - ArrayOf batchResults = response.Results; - ArrayOf batchDiagnosticInfos = response.DiagnosticInfos; + ArrayOf batchResults = response.Results; + ArrayOf batchDiagnosticInfos = response.DiagnosticInfos; - ValidateResponse(batchResults, batchNodesToRead); - ValidateDiagnosticInfos(batchDiagnosticInfos, batchNodesToRead); + ValidateResponse(batchResults, batchNodesToRead); + ValidateDiagnosticInfos(batchDiagnosticInfos, batchNodesToRead); - AddResponses( - ref results, - ref diagnosticInfos, - ref stringTable, - batchResults, - batchDiagnosticInfos, - response.ResponseHeader.StringTable); + AddResponses( + ref results, + ref diagnosticInfos, + ref stringTable, + batchResults, + batchDiagnosticInfos, + response.ResponseHeader.StringTable); + } + } + catch (Exception) when (results.Count > 0 && !releaseContinuationPoints) + { + // The caller never sees the continuation points the completed + // batches returned, so release them before the failure propagates. + await ReleaseHistoryContinuationPointsAsync( + historyReadDetails, + timestampsToReturn, + nodesToRead, + results).ConfigureAwait(false); + throw; } ValidateResponse(response); @@ -772,29 +806,39 @@ async ValueTask CreateMonitoredItemsBatchedAsync( out List stringTable, itemsToCreate.Count, operationLimit); - foreach (ArrayOf batchItemsToCreate in itemsToCreate - .Batch((int)operationLimit)) + try { - requestHeader.RequestHandle = 0; - response = await base.CreateMonitoredItemsAsync( - requestHeader, - subscriptionId, - timestampsToReturn, - batchItemsToCreate, - ct).ConfigureAwait(false); + foreach (ArrayOf batchItemsToCreate in itemsToCreate + .Batch((int)operationLimit)) + { + requestHeader.RequestHandle = 0; + response = await base.CreateMonitoredItemsAsync( + requestHeader, + subscriptionId, + timestampsToReturn, + batchItemsToCreate, + ct).ConfigureAwait(false); - ArrayOf batchResults = response.Results; - ArrayOf batchDiagnosticInfos = response.DiagnosticInfos; - ValidateResponse(batchResults, batchItemsToCreate); - ValidateDiagnosticInfos(batchDiagnosticInfos, batchItemsToCreate); + ArrayOf batchResults = response.Results; + ArrayOf batchDiagnosticInfos = response.DiagnosticInfos; + ValidateResponse(batchResults, batchItemsToCreate); + ValidateDiagnosticInfos(batchDiagnosticInfos, batchItemsToCreate); - AddResponses( - ref results, - ref diagnosticInfos, - ref stringTable, - batchResults, - batchDiagnosticInfos, - response.ResponseHeader.StringTable); + AddResponses( + ref results, + ref diagnosticInfos, + ref stringTable, + batchResults, + batchDiagnosticInfos, + response.ResponseHeader.StringTable); + } + } + catch (Exception) when (results.Count > 0) + { + // The caller never learns the ids of the items the completed + // batches created, so delete them before the failure propagates. + await DeleteCreatedMonitoredItemsAsync(subscriptionId, results).ConfigureAwait(false); + throw; } ValidateResponse(response); @@ -1488,6 +1532,119 @@ private static void InitResponseCollections( stringTable = []; } + /// + /// Best effort release of the browse continuation points returned by the + /// batches that completed before a later batch failed (Part 4 7.9). + /// + private async ValueTask ReleaseBrowseContinuationPointsAsync(List results) + { + var continuationPoints = new List(); + foreach (BrowseResult? result in results) + { + if (result != null && !result.ContinuationPoint.IsEmpty) + { + continuationPoints.Add(result.ContinuationPoint); + } + } + if (continuationPoints.Count == 0) + { + return; + } + try + { + await BrowseNextAsync( + null, + true, + continuationPoints.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception) + { + // Best effort, the original failure is what the caller sees. The + // server frees the continuation points when the session closes. + } + } + + /// + /// Best effort release of the history continuation points returned by the + /// batches that completed before a later batch failed. + /// + private async ValueTask ReleaseHistoryContinuationPointsAsync( + ExtensionObject historyReadDetails, + TimestampsToReturn timestampsToReturn, + ArrayOf nodesToRead, + List results) + { + var nodesToRelease = new List(); + for (int ii = 0; ii < results.Count && ii < nodesToRead.Count; ii++) + { + HistoryReadResult? result = results[ii]; + if (result != null && !result.ContinuationPoint.IsEmpty) + { + HistoryReadValueId nodeToRead = nodesToRead[ii]; + nodesToRelease.Add(new HistoryReadValueId + { + NodeId = nodeToRead.NodeId, + IndexRange = nodeToRead.IndexRange, + DataEncoding = nodeToRead.DataEncoding, + ContinuationPoint = result.ContinuationPoint + }); + } + } + if (nodesToRelease.Count == 0) + { + return; + } + try + { + await HistoryReadAsync( + null, + historyReadDetails, + timestampsToReturn, + true, + nodesToRelease.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception) + { + // Best effort, the original failure is what the caller sees. + } + } + + /// + /// Best effort deletion of the monitored items created by the batches + /// that completed before a later batch failed. + /// + private async ValueTask DeleteCreatedMonitoredItemsAsync( + uint subscriptionId, + List results) + { + var monitoredItemIds = new List(); + foreach (MonitoredItemCreateResult? result in results) + { + if (result != null && StatusCode.IsGood(result.StatusCode)) + { + monitoredItemIds.Add(result.MonitoredItemId); + } + } + if (monitoredItemIds.Count == 0) + { + return; + } + try + { + await DeleteMonitoredItemsAsync( + null, + subscriptionId, + monitoredItemIds.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception) + { + // Best effort, the original failure is what the caller sees. + } + } + /// /// Add the result of a batched service call to the results. /// @@ -1534,7 +1691,11 @@ private static void AddResponses( diagnosticInfos.Add(null!); } } - else if (batchedStringTable.Count > 0) + + // Padding and rebasing are independent: the batch strings are appended + // after the strings already collected (e.g. by the other list of a + // SetTriggering call sharing this string table). + if (batchedStringTable.Count > 0 && stringTable.Count > 0) { // correct indexes in the string table int stringTableOffset = stringTable.Count; diff --git a/src/Opc.Ua.Client/Session/SessionClientExtensions.cs b/src/Opc.Ua.Client/Session/SessionClientExtensions.cs index 466ef0cc53..e52628339e 100644 --- a/src/Opc.Ua.Client/Session/SessionClientExtensions.cs +++ b/src/Opc.Ua.Client/Session/SessionClientExtensions.cs @@ -822,6 +822,16 @@ public static async ValueTask ReadBytesAsync( // will return empty array constant. break; } + if (chunk.Length > maxByteStringLength) + { + // The server ignored the IndexRange: appending the chunk would + // corrupt the value and the loop would never terminate. + throw ServiceResultException.Create( + StatusCodes.BadUnknownResponse, + "Server returned {0} bytes for an index range of {1} bytes.", + chunk.Length, + maxByteStringLength); + } if (chunk.Length < maxByteStringLength && offset == 0) { // Fast path for small values, just return the chunk @@ -837,6 +847,14 @@ public static async ValueTask ReadBytesAsync( { break; } + if ((long)offset + (2L * maxByteStringLength) - 1 > int.MaxValue) + { + // The next range would not fit into an int and the value + // could not be returned as a single byte string anyway. + throw ServiceResultException.Create( + StatusCodes.BadEncodingLimitsExceeded, + "ByteString value exceeds the maximum supported length."); + } offset += maxByteStringLength; } return stream != null ? stream.ToArray().ToByteString() : ByteString.Empty; diff --git a/src/Opc.Ua.Client/Session/SessionExtensions.cs b/src/Opc.Ua.Client/Session/SessionExtensions.cs index 70b9249317..4334a78eba 100644 --- a/src/Opc.Ua.Client/Session/SessionExtensions.cs +++ b/src/Opc.Ua.Client/Session/SessionExtensions.cs @@ -230,7 +230,18 @@ public static async Task ReadByteStringInChunksAsync( NodeId nodeId, CancellationToken ct = default) { - int maxByteStringLength = (int)session.ServerCapabilities.MaxByteStringLength; + // An absent server property reads as 0 (Part 5 6.3.2: not provided = no + // limit) and a value beyond int range is effectively unlimited as well: + // use the client's own encoder limit in both cases. + uint serverLimit = session.ServerCapabilities.MaxByteStringLength; + int clientLimit = session.MessageContext.MaxByteStringLength; + int maxByteStringLength = serverLimit is 0 or > int.MaxValue + ? clientLimit + : (int)serverLimit; + if (clientLimit > 0 && maxByteStringLength > clientLimit) + { + maxByteStringLength = clientLimit; + } if (maxByteStringLength <= 1) { throw ServiceResultException.Create( diff --git a/src/Opc.Ua.Client/Session/Subscription/ClassicSubscriptionEngine.cs b/src/Opc.Ua.Client/Session/Subscription/ClassicSubscriptionEngine.cs index aa6ff595c5..2ae492acd9 100644 --- a/src/Opc.Ua.Client/Session/Subscription/ClassicSubscriptionEngine.cs +++ b/src/Opc.Ua.Client/Session/Subscription/ClassicSubscriptionEngine.cs @@ -652,12 +652,22 @@ await m_timeProvider.Delay(TimeSpan.FromMilliseconds(100), ct) /// /// Processes the response from a publish request. /// + /// The response header. + /// The subscription the message belongs to. + /// The sequence numbers the + /// server still holds for the subscription. + /// Whether more notifications are pending. + /// The notification message. + /// The message came from a Republish + /// response, which carries no available sequence numbers: the empty + /// list then does not mean the server holds nothing. internal void ProcessPublishResponse( ResponseHeader responseHeader, uint subscriptionId, ArrayOf availableSequenceNumbers, bool moreNotifications, - NotificationMessage notificationMessage) + NotificationMessage notificationMessage, + bool republished = false) { Subscription? subscription = null; var availableSequenceNumberList = availableSequenceNumbers.ToList(); @@ -702,6 +712,12 @@ internal void ProcessPublishResponse( { acknowledgementsToSend.Add(acknowledgement); } + else if (republished) + { + // a republish response cannot tell which messages the + // server still holds, so keep every pending ack. + acknowledgementsToSend.Add(acknowledgement); + } else if (availableSequenceNumberList.Remove(acknowledgement.SequenceNumber)) { acknowledgementsToSend.Add(acknowledgement); @@ -712,8 +728,11 @@ internal void ProcessPublishResponse( // a publish response may be processed out of // order, allow for a tolerance until the // sequence number is removed. - else if (Math.Abs((int)(acknowledgement.SequenceNumber - latestSequenceNumberToSend)) < - kPublishRequestSequenceNumberOutOfOrderThreshold) + // The distance is compared without Math.Abs, which throws + // an OverflowException for int.MinValue (a distance of 2^31). + else if (IsWithinOutOfOrderThreshold( + acknowledgement.SequenceNumber, + latestSequenceNumberToSend)) { acknowledgementsToSend.Add(acknowledgement); } @@ -815,7 +834,14 @@ internal void ProcessPublishResponse( notificationMessage.StringTable = responseHeader.StringTable; // update subscription cache. - subscription.SaveMessageInCache(availableSequenceNumbers, notificationMessage); + if (republished) + { + subscription.SaveRepublishedMessageInCache(notificationMessage); + } + else + { + subscription.SaveMessageInCache(availableSequenceNumbers, notificationMessage); + } // raise the notification. var args = new NotificationEventArgs( @@ -1156,6 +1182,17 @@ private bool BelowPublishRequestLimit(int requestCount) return (result, error); } + /// + /// Returns true if the wrap-aware distance between two sequence + /// numbers is below the out-of-order tolerance in either direction. + /// + internal static bool IsWithinOutOfOrderThreshold(uint sequenceNumber, uint latestSequenceNumber) + { + int delta = unchecked((int)(sequenceNumber - latestSequenceNumber)); + return delta > -kPublishRequestSequenceNumberOutOfOrderThreshold && + delta < kPublishRequestSequenceNumberOutOfOrderThreshold; + } + private const int kMinPublishRequestCountMax = 100; private const int kMaxPublishRequestCountMax = ushort.MaxValue; private const int kDefaultPublishRequestCount = 1; diff --git a/src/Opc.Ua.Client/Session/Subscription/SessionEngineContext.cs b/src/Opc.Ua.Client/Session/Subscription/SessionEngineContext.cs index 3a8a98c29f..921cedf0ae 100644 --- a/src/Opc.Ua.Client/Session/Subscription/SessionEngineContext.cs +++ b/src/Opc.Ua.Client/Session/Subscription/SessionEngineContext.cs @@ -214,14 +214,10 @@ public void OnPublishNotification( if (publishEventHandler != null) { - m_session.BackgroundWork.Run( + m_session.RunBackgroundWork( nameof(RaisePublishNotification), - _ => - { - RaisePublishNotification( - publishEventHandler, notification); - return default; - }); + () => RaisePublishNotification( + publishEventHandler, notification)); } } diff --git a/src/Opc.Ua.Client/StateMachines/FiniteStateMachineTypeClientExtensions.cs b/src/Opc.Ua.Client/StateMachines/FiniteStateMachineTypeClientExtensions.cs index f0c0d275ae..7c98102541 100644 --- a/src/Opc.Ua.Client/StateMachines/FiniteStateMachineTypeClientExtensions.cs +++ b/src/Opc.Ua.Client/StateMachines/FiniteStateMachineTypeClientExtensions.cs @@ -231,6 +231,8 @@ private static async IAsyncEnumerable ObserveFiniteTransiti /// Cancellation token. /// is null. /// + /// CurrentState/Id cannot be + /// resolved, or the observation ended before the target state was reached. /// public static async ValueTask WaitForStateAsync( this FiniteStateMachineTypeClient client, @@ -261,6 +263,16 @@ public static async ValueTask WaitForStateAsync( return current; } + // Without CurrentState/Id there is nothing to observe; report it rather + // than ending the wait as a spurious cancellation. + NodeId currentStateIdNodeId = await client + .ResolveChildNodeIdAsync(BrowseNames.CurrentState, BrowseNames.Id, ct) + .ConfigureAwait(false); + if (currentStateIdNodeId.IsNull) + { + throw new ServiceResultException(StatusCodes.BadNotFound); + } + TimeProvider tp = timeProvider ?? TimeProvider.System; using CancellationTokenSource? timeoutCts = timeout.HasValue ? tp.CreateCancellationTokenSource(timeout.Value) @@ -280,8 +292,14 @@ public static async ValueTask WaitForStateAsync( } } - throw new OperationCanceledException( - "Target state not reached before cancellation or timeout.", ct); + if (effective.IsCancellationRequested) + { + throw new OperationCanceledException( + "Target state not reached before cancellation or timeout.", ct); + } + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "The state observation ended before the target state was reached."); } /// @@ -652,10 +670,65 @@ public static async ValueTask ReferenceDescription r = response.Results[0].References[0]; var childId = ExpandedNodeId.ToNodeId( r.NodeId, parent.Session.MessageContext.NamespaceUris); + + // Part 16 §4.4.16: the state and its sub-state machine are instance + // declarations of the StateMachineType, so the reference target is the + // type's declaration. Resolve the same-named component of this instance. + NodeId instanceChildId = await ResolveInstanceComponentAsync( + parent, r.BrowseName, ct).ConfigureAwait(false); + if (!instanceChildId.IsNull) + { + childId = instanceChildId; + } return new FiniteStateMachineTypeClient( parent.Session, childId, telemetry); } + private static async ValueTask ResolveInstanceComponentAsync( + FiniteStateMachineTypeClient parent, + QualifiedName browseName, + CancellationToken ct) + { + if (browseName.IsNull || string.IsNullOrEmpty(browseName.Name)) + { + return NodeId.Null; + } + + ArrayOf requests = + [ + new BrowsePath + { + StartingNode = parent.ObjectId, + RelativePath = new RelativePath + { + Elements = + [ + new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HasComponent, + IsInverse = false, + IncludeSubtypes = true, + TargetName = browseName + } + ] + } + } + ]; + TranslateBrowsePathsToNodeIdsResponse response = + await parent.Session.TranslateBrowsePathsToNodeIdsAsync( + null, requests, ct).ConfigureAwait(false); + if (response == null || + response.Results.Count == 0 || + StatusCode.IsBad(response.Results[0].StatusCode) || + response.Results[0].Targets.Count == 0) + { + return NodeId.Null; + } + return ExpandedNodeId.ToNodeId( + response.Results[0].Targets[0].TargetId, + parent.Session.MessageContext.NamespaceUris); + } + /// /// Yields a combined snapshot whenever the parent transitions /// OR when the parent's currently-active sub-state-machine diff --git a/src/Opc.Ua.Client/StateMachines/StateMachineTypeClientExtensions.cs b/src/Opc.Ua.Client/StateMachines/StateMachineTypeClientExtensions.cs index ceb7a5c5aa..60de2317a3 100644 --- a/src/Opc.Ua.Client/StateMachines/StateMachineTypeClientExtensions.cs +++ b/src/Opc.Ua.Client/StateMachines/StateMachineTypeClientExtensions.cs @@ -208,8 +208,14 @@ public static async ValueTask WaitForStateAsync( } } - throw new OperationCanceledException( - "Target state not reached before cancellation or timeout.", ct); + if (effective.IsCancellationRequested) + { + throw new OperationCanceledException( + "Target state not reached before cancellation or timeout.", ct); + } + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "The state observation ended before the target state was reached."); } /// diff --git a/src/Opc.Ua.Client/Subscription/Classic/MonitoredItem.cs b/src/Opc.Ua.Client/Subscription/Classic/MonitoredItem.cs index 0a06678033..1d0d60a6bf 100644 --- a/src/Opc.Ua.Client/Subscription/Classic/MonitoredItem.cs +++ b/src/Opc.Ua.Client/Subscription/Classic/MonitoredItem.cs @@ -107,6 +107,12 @@ public MonitoredItem( if (copyClientHandle) { ClientHandle = template.ClientHandle; + + // The triggering links reference client handles, which stay + // valid, so a recreated subscription can restore them. The + // server id of the triggering item does not carry over. + TriggeredItems = template.TriggeredItems; + TriggeringItemId = 0; } else { @@ -492,14 +498,17 @@ public NotificationMessage? LastMessage { lock (m_cache) { + // The last notification is stored whatever its kind, so a + // notification of the other kind (a buggy server or a + // client handle clash) must not throw here. if (m_dataCache != null) { - return ((MonitoredItemNotification?)m_lastNotification)?.Message; + return (m_lastNotification as MonitoredItemNotification)?.Message; } if (m_eventCache != null) { - return ((EventFieldList?)m_lastNotification)?.Message; + return (m_lastNotification as EventFieldList)?.Message; } return null; diff --git a/src/Opc.Ua.Client/Subscription/Classic/Subscription.cs b/src/Opc.Ua.Client/Subscription/Classic/Subscription.cs index ad511113c0..1650037ba8 100644 --- a/src/Opc.Ua.Client/Subscription/Classic/Subscription.cs +++ b/src/Opc.Ua.Client/Subscription/Classic/Subscription.cs @@ -180,13 +180,20 @@ public virtual void Snapshot(out SubscriptionState state) /// private void ResetPublishTimerAndWorkerState() { - ResetPublishTimerAndWorkerStateAsync().GetAwaiter().GetResult(); + // Do not wait for a running keep-alive callback: its + // PublishStatusChanged handler may be the one that (through the + // session, possibly on another flow such as the managed session's + // state machine worker) disposes this subscription. + ResetPublishTimerAndWorkerStateAsync(waitForTimerCallbacks: false) + .GetAwaiter().GetResult(); } /// /// Resets the state of the publish timer and associated message worker. /// - private async Task ResetPublishTimerAndWorkerStateAsync() + /// Whether to wait for a keep-alive + /// callback that is running while the publish timer is disposed. + private async Task ResetPublishTimerAndWorkerStateAsync(bool waitForTimerCallbacks = true) { Task? workerTask; CancellationTokenSource? workerCts; @@ -208,6 +215,7 @@ private async Task ResetPublishTimerAndWorkerStateAsync() if (m_messageWorkerTask == null) { + publishTimer?.Dispose(); m_messageWorkerCts?.Dispose(); m_messageWorkerCts = null; return; @@ -245,7 +253,14 @@ private async Task ResetPublishTimerAndWorkerStateAsync() { if (publishTimer != null) { - await publishTimer.DisposeAsync().ConfigureAwait(false); + if (waitForTimerCallbacks) + { + await publishTimer.DisposeAsync().ConfigureAwait(false); + } + else + { + publishTimer.Dispose(); + } } try { @@ -272,9 +287,31 @@ private async Task ResetPublishTimerAndWorkerStateAsync() /// e.g. the user explicitly initiating a transfer-away — runs /// through the spec-strict path. /// + /// + /// A notification that arrived while this client was transferring + /// the subscription itself (or that predates such a transfer) only + /// reports that transfer to the old session. It is ignored: the old + /// session's reconnecting flag does not reveal the transfer, and + /// stopping or recreating here would kill the subscription on the + /// session it was just moved to. + /// /// - private void HandleGoodSubscriptionTransferred() + /// The message was received while a + /// transfer of this subscription was in progress. + /// The transfer generation at the + /// time the message was received. + private void HandleGoodSubscriptionTransferred( + bool savedDuringTransfer, + int transferGeneration) { + if (savedDuringTransfer || + Volatile.Read(ref m_transfersInProgress) != 0 || + transferGeneration != Volatile.Read(ref m_transferGeneration)) + { + m_logger.SubscriptionIdIgnoredGoodSubscriptionTransferredOwnTransfer(Id); + return; + } + ISession? session = Session; bool unsolicited = session != null && !session.Reconnecting && @@ -296,6 +333,27 @@ private void HandleGoodSubscriptionTransferred() _ = ResetPublishTimerAndWorkerStateAsync(); // Do not block on ourselves but exit } + /// + /// Called by the session before it asks the server to transfer this + /// subscription, so the Good_SubscriptionTransferred the server + /// sends to the old session is not taken for an unsolicited one. + /// Must be paired with . + /// + internal void OnTransferStarting() + { + Interlocked.Increment(ref m_transferGeneration); + Interlocked.Increment(ref m_transfersInProgress); + } + + /// + /// Called by the session once a transfer started with + /// has completed or failed. + /// + internal void OnTransferFinished() + { + Interlocked.Decrement(ref m_transfersInProgress); + } + /// /// Run an in-place recreate on the same session after an /// unsolicited Good_SubscriptionTransferred. Idempotent: @@ -951,8 +1009,9 @@ public bool PublishingStopped { TimeSpan timeSinceLastNotification = m_timeProvider .GetElapsedTime(m_lastNotificationTimestamp); + // add in double: the interval may be capped at int.MaxValue. return timeSinceLastNotification.TotalMilliseconds > - m_keepAliveInterval + kKeepAliveTimerMargin; + (double)m_keepAliveInterval + kKeepAliveTimerMargin; } } @@ -964,6 +1023,12 @@ public async Task CreateAsync(CancellationToken ct = default) using Activity? activity = m_telemetry.StartActivity(); VerifySessionAndSubscriptionState(false); + // The subscription is not created, so a server id an item still + // carries (restored from storage, or left by a failed transfer) + // belongs to another server subscription. Reset it, otherwise the + // item is taken as created and never created on the new one. + ResetMonitoredItemServerIds(createdOnly: true); + // create the subscription. uint revisedMaxKeepAliveCount = KeepAliveCount; uint revisedLifetimeCount = LifetimeCount; @@ -1278,6 +1343,23 @@ public async Task> CreateItemsAsync(CancellationToken ct response.DiagnosticInfos, response.ResponseHeader); } + + // An item removed while its create request was in flight was + // not queued for deletion (it was not created yet), but it now + // exists on the server: queue it so it is not leaked. + lock (m_cache) + { + foreach (MonitoredItem monitoredItem in itemsToCreate) + { + if (monitoredItem.Status.Created && + (!m_monitoredItems.TryGetValue(monitoredItem.ClientHandle, out MonitoredItem? current) || + !ReferenceEquals(current, monitoredItem)) && + !m_deletedItems.Contains(monitoredItem)) + { + m_deletedItems.Add(monitoredItem); + } + } + } } catch { @@ -1735,18 +1817,32 @@ public async Task TransferAsync( ArrayOf availableSequenceNumbers, CancellationToken ct = default) { + // The caller may not have transferred anything to the session + // (e.g. ReactivateSubscriptionsAsync adopts the session's own + // subscriptions), so a failed adoption must never delete the + // server subscription here. (bool transferred, _) = await TransferWithAcknowledgementsAsync( - session, id, availableSequenceNumbers, ct).ConfigureAwait(false); + session, id, availableSequenceNumbers, false, ct).ConfigureAwait(false); return transferred; } /// /// Transfers the subscription and returns only sequence numbers not claimed for republish. /// + /// The session to which the subscription is transferred. + /// Id of the transferred subscription. + /// The available sequence numbers on the server. + /// Set only when the caller just + /// moved the server subscription to with a + /// TransferSubscriptions call. The server subscription is then deleted + /// when it definitively cannot be adopted, so it does not live on as + /// an orphan. Transient failures never delete it. + /// The cancellation token. internal async Task<(bool Transferred, ArrayOf Acknowledgements)> TransferWithAcknowledgementsAsync( ISession session, uint id, ArrayOf availableSequenceNumbers, + bool deleteOnDefinitiveFailure, CancellationToken ct) { using Activity? activity = m_telemetry.StartActivity(); @@ -1786,19 +1882,39 @@ await session.RemoveSubscriptionsAsync(subscriptionsToRemove, ct) else { // handle the case when the client restarts and loads the saved subscriptions from storage - (bool success, ArrayOf serverHandles, ArrayOf clientHandles) = await GetMonitoredItemsAsync(ct) - .ConfigureAwait(false); - if (!success) + (StatusCode status, ArrayOf serverHandles, ArrayOf clientHandles) = + await CallGetMonitoredItemsAsync(ct).ConfigureAwait(false); + if (StatusCode.IsNotGood(status)) { m_logger.SubscriptionIdSubscriptionIdServerFailedRespondGetMonitoredItems( Id, Session?.SessionId); + + // Only a definitive answer proves the subscription can + // never be adopted. A transient error (timeout, busy + // server, lost connection) leaves it in place for a retry. + if (deleteOnDefinitiveFailure && IsDefinitiveGetMonitoredItemsFailure(status)) + { + await DeleteTransferredSubscriptionAsync(session, id, ct).ConfigureAwait(false); + } return (false, default); } - int monitoredItemsCount = m_monitoredItems.Count; - if (serverHandles.Count != monitoredItemsCount || - clientHandles.Count != monitoredItemsCount) + // The server must hold every item that was created, but items + // that were never created (e.g. failed with BadNodeIdUnknown + // before the state was saved) do not exist on the server. A + // clone of a live subscription has no server ids at all, so + // the created items only give the lower bound. + int monitoredItemsCount; + int createdItemsCount; + lock (m_cache) + { + monitoredItemsCount = m_monitoredItems.Count; + createdItemsCount = m_monitoredItems.Values.Count(item => item.Status.Created); + } + if (serverHandles.Count != clientHandles.Count || + serverHandles.Count > monitoredItemsCount || + serverHandles.Count < createdItemsCount) { // invalid state m_logger.SubscriptionIdSubscriptionIdNumberMonitoredItemsClient( @@ -1806,6 +1922,15 @@ await session.RemoveSubscriptionsAsync(subscriptionsToRemove, ct) serverHandles.Count, monitoredItemsCount, Session?.SessionId); + + // When the caller just moved the server subscription to + // this session, nothing on the client owns it (Id stays + // 0), so it would live on and consume publish requests: + // delete it. + if (deleteOnDefinitiveFailure) + { + await DeleteTransferredSubscriptionAsync(session, id, ct).ConfigureAwait(false); + } return (false, default); } @@ -1833,18 +1958,64 @@ await session.RemoveSubscriptionsAsync(subscriptionsToRemove, ct) return (true, acknowledgements); } + /// + /// Best effort delete of a server subscription that was transferred + /// to but could not be adopted, so it does + /// not stay alive as an orphan for the lifetime of the session. + /// + private async Task DeleteTransferredSubscriptionAsync( + ISession session, + uint id, + CancellationToken ct) + { + try + { + await session.DeleteSubscriptionsAsync(null, [id], ct).ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.SubscriptionIdFailedDeleteTransferredSubscription( + ex, + id, + session.SessionId); + } + } + /// /// Adds the notification message to internal cache. /// public void SaveMessageInCache( ArrayOf availableSequenceNumbers, NotificationMessage message) + { + SaveMessageInCache(availableSequenceNumbers, message, true); + } + + /// + /// Adds a notification message received in a Republish response to + /// the internal cache. A Republish response carries no available + /// sequence numbers, so the ones from the last Publish response are + /// kept: otherwise every other missing message would be given up + /// while the server still holds it. + /// + internal void SaveRepublishedMessageInCache(NotificationMessage message) + { + SaveMessageInCache(default, message, false); + } + + private void SaveMessageInCache( + ArrayOf availableSequenceNumbers, + NotificationMessage message, + bool updateAvailableSequenceNumbers) { PublishStateChangedEventHandler? callback = null; lock (m_cache) { - m_availableSequenceNumbers = availableSequenceNumbers; + if (updateAvailableSequenceNumbers) + { + m_availableSequenceNumbers = availableSequenceNumbers; + } if (message == null) { @@ -1867,16 +2038,39 @@ public void SaveMessageInCache( // create queue for the first time. m_incomingMessages ??= new LinkedList(); - // find or create an entry for the incoming sequence number. - IncomingMessage entry = FindOrCreateEntry(now, monotonicTimestamp, message.SequenceNumber); - - // check for keep alive. - if (message.NotificationData.Count > 0) + // A late copy of a message that was already processed or given + // up (the original Publish response racing its Republish, or a + // message arriving after its placeholder expired) has no entry + // any more. Creating one would deliver it a second time and + // fill placeholders for messages that were already processed. + if (message.SequenceNumber != 0 && + !m_resyncLastSequenceNumberProcessed && + !IsNewerSequenceNumber(message.SequenceNumber, m_lastSequenceNumberProcessed) && + FindEntry(message.SequenceNumber) == null) { - entry.Message = message; - entry.Processed = false; + m_logger.SubscriptionIdIgnoredAlreadyProcessedSequenceNumber( + Id, + message.SequenceNumber, + m_lastSequenceNumberProcessed, + Session?.SessionId); + } + else + { + // find or create an entry for the incoming sequence number. + IncomingMessage newEntry = FindOrCreateEntry(now, monotonicTimestamp, message.SequenceNumber); + + // check for keep alive, and never hand a message that was + // already processed to the worker again. + if (message.NotificationData.Count > 0 && !newEntry.Processed) + { + newEntry.Message = message; + newEntry.SavedDuringTransfer = Volatile.Read(ref m_transfersInProgress) != 0; + newEntry.TransferGeneration = Volatile.Read(ref m_transferGeneration); + } } + IncomingMessage entry; + // fill in any gaps in the queue LinkedListNode? node = m_incomingMessages.First; // While the last processed sequence number has not been @@ -2191,6 +2385,21 @@ await Session.CallAsync(ObjectIds.Server, MethodIds.Server_ResendData, ct, Id) )> GetMonitoredItemsAsync(CancellationToken ct = default) { using Activity? activity = m_telemetry.StartActivity(); + (StatusCode status, ArrayOf serverHandles, ArrayOf clientHandles) = + await CallGetMonitoredItemsAsync(ct).ConfigureAwait(false); + return (StatusCode.IsGood(status), serverHandles, clientHandles); + } + + /// + /// Calls GetMonitoredItems and keeps the status of a failed call, so + /// a transient error can be told apart from a definitive one. + /// + private async Task<( + StatusCode Status, + ArrayOf ServerHandles, + ArrayOf ClientHandles + )> CallGetMonitoredItemsAsync(CancellationToken ct) + { VerifySession(); try { @@ -2203,8 +2412,11 @@ await Session.CallAsync(ObjectIds.Server, MethodIds.Server_ResendData, ct, Id) { var serverHandles = (ArrayOf)outputArguments[0]; var clientHandles = (ArrayOf)outputArguments[1]; - return (true, serverHandles, clientHandles); + return (StatusCodes.Good, serverHandles, clientHandles); } + + // The server answered, but not with the two handle arrays. + return (StatusCodes.BadTypeMismatch, default, default); } catch (ServiceResultException sre) { @@ -2212,8 +2424,24 @@ await Session.CallAsync(ObjectIds.Server, MethodIds.Server_ResendData, ct, Id) sre, Id, Session?.SessionId); + return (sre.StatusCode, default, default); } - return (false, default, default); + } + + /// + /// Whether a failed GetMonitoredItems call proves that the transferred + /// subscription can never be adopted, as opposed to a transient error + /// such as BadTimeout, BadTooManyOperations, BadServerTooBusy or a + /// communication error, after which a retry can still succeed. + /// + private static bool IsDefinitiveGetMonitoredItemsFailure(StatusCode status) + { + return status == StatusCodes.BadTypeMismatch || + status == StatusCodes.BadMethodInvalid || + status == StatusCodes.BadNotSupported || + status == StatusCodes.BadNotImplemented || + status == StatusCodes.BadNotExecutable || + status == StatusCodes.BadUserAccessDenied; } /// @@ -2412,7 +2640,19 @@ private void OnKeepAlive(object? state) return; } - HandleOnKeepAliveStopped(); + // The PublishStatusChanged handlers run on the timer callback. + // Disposing the timer waits for its running callbacks, so a + // Dispose from such a handler must take the non-blocking path. + bool dispatchContext = m_dispatchContext.Value; + m_dispatchContext.Value = true; + try + { + HandleOnKeepAliveStopped(); + } + finally + { + m_dispatchContext.Value = dispatchContext; + } } /// @@ -2436,6 +2676,13 @@ private void HandleOnKeepAliveStopped() PublishingStateChanged(callback, PublishStateChangedMask.Stopped); + // The handler may have disposed the subscription or its + // session; the timer no longer waits for this callback then. + if (m_disposed) + { + return; + } + // try to send a publish to recover stopped publishing. session.BeginPublish(BeginPublishTimeout()); } @@ -2516,8 +2763,10 @@ internal void TraceState(string context) /// private int BeginPublishTimeout() { + // multiply in long: three keep-alive intervals of more than about + // 8.3 days overflow an int. return Math.Max( - Math.Min(m_keepAliveInterval * 3, int.MaxValue), + (int)Math.Min(3L * m_keepAliveInterval, int.MaxValue), MinKeepAliveTimerInterval); } @@ -2576,6 +2825,17 @@ private void CreateOrModifySubscription( } else { + // A new server subscription numbers its messages from 1 again: + // forget the sequence state of a previous one (this object may + // be recreated in place or cloned from a template), otherwise + // its first messages look like late copies of processed ones. + lock (m_cache) + { + m_lastSequenceNumberProcessed = 0; + m_resyncLastSequenceNumberProcessed = false; + m_incomingMessages?.Clear(); + } + CurrentPublishingEnabled = PublishingEnabled; TransferId = Id = subscriptionId; m_changeMask |= SubscriptionChangeMask.Created; @@ -2631,11 +2891,11 @@ private void CreateOrModifySubscription( private int CalculateKeepAliveInterval() { int keepAliveInterval = (int) - Math.Min(CurrentPublishingInterval * (CurrentKeepAliveCount + 1), int.MaxValue); + Math.Min(CurrentPublishingInterval * (CurrentKeepAliveCount + 1.0), int.MaxValue); if (keepAliveInterval < MinKeepAliveTimerInterval) { keepAliveInterval = (int)Math.Min( - PublishingInterval * (KeepAliveCount + 1), + PublishingInterval * (KeepAliveCount + 1.0), int.MaxValue); keepAliveInterval = Math.Max(MinKeepAliveTimerInterval, keepAliveInterval); } @@ -2655,21 +2915,32 @@ private void DeleteSubscription() CurrentPriority = 0; // update items. + ResetMonitoredItemServerIds(); + + m_changeMask |= SubscriptionChangeMask.Deleted; + } + + /// + /// Marks the monitored items as not created on the server and drops + /// pending deletions, whose server ids no longer exist. + /// + /// Leave items that are not created (and + /// their last error) untouched. + private void ResetMonitoredItemServerIds(bool createdOnly = false) + { lock (m_cache) { foreach (MonitoredItem monitoredItem in m_monitoredItems.Values) { - monitoredItem.SetDeleteResult(StatusCodes.Good, -1, default, null); + if (!createdOnly || monitoredItem.Status.Created) + { + monitoredItem.SetDeleteResult(StatusCodes.Good, -1, default, null); + } } - } - lock (m_cache) - { m_deletedItems.Clear(); m_deletingItems.Clear(); } - - m_changeMask |= SubscriptionChangeMask.Deleted; } /// @@ -2762,7 +3033,8 @@ private async Task OnMessageReceivedAsync(CancellationToken ct) PublishStateChangedEventHandler? callback = null; // list of new messages to process. - List? messagesToProcess = null; + List<(NotificationMessage Message, bool SavedDuringTransfer, int TransferGeneration)>? + messagesToProcess = null; // list of keep alive messages to process. List? keepAliveToProcess = null; @@ -2788,10 +3060,13 @@ private async Task OnMessageReceivedAsync(CancellationToken ct) !ii.Value.Processed && (!State.SequentialPublishing || ValidSequentialPublishMessage(ii.Value))) { - (messagesToProcess ??= []).Add(ii.Value.Message); + (messagesToProcess ??= []).Add( + (ii.Value.Message, ii.Value.SavedDuringTransfer, ii.Value.TransferGeneration)); - // remove the oldest items. - while (m_messageCache.Count > MaxMessageCount) + // remove the oldest items. The emptiness check keeps a + // negative MaxMessageCount from throwing on RemoveFirst. + while (m_messageCache.Count > 0 && + m_messageCache.Count > MaxMessageCount) { m_messageCache.RemoveFirst(); } @@ -2893,7 +3168,8 @@ private async Task OnMessageReceivedAsync(CancellationToken ct) = FastDataChangeCallback; FastEventNotificationEventHandler? eventCallback = FastEventCallback; - foreach (NotificationMessage message in messagesToProcess) + foreach ((NotificationMessage message, bool savedDuringTransfer, int transferGeneration) + in messagesToProcess) { noNotificationsReceived = 0; try @@ -2975,7 +3251,9 @@ private async Task OnMessageReceivedAsync(CancellationToken ct) publishStateChangedMask |= PublishStateChangedMask.Transferred; - HandleGoodSubscriptionTransferred(); + HandleGoodSubscriptionTransferred( + savedDuringTransfer, + transferGeneration); } else if (statusChanged.Status == StatusCodes.BadTimeout) { @@ -3414,6 +3692,24 @@ private static uint ForwardSequenceGap(uint expected, uint actual) return actual >= expected ? actual - expected : uint.MaxValue - expected + actual; } + /// + /// Returns the entry for the sequence number, or null if there is none. + /// + private IncomingMessage? FindEntry(uint sequenceNumber) + { + Debug.Assert(m_cache.IsHeldByCurrentThread); + for (LinkedListNode? node = m_incomingMessages?.Last; + node != null; + node = node.Previous) + { + if (node.Value.SequenceNumber == sequenceNumber) + { + return node.Value; + } + } + return null; + } + /// /// Find or create an entry for the incoming sequence number. /// @@ -3521,6 +3817,8 @@ private void PublishingStateChanged( /// private readonly AsyncLocal m_dispatchContext = new(); private int m_recreateAfterTransferInProgress; + private int m_transfersInProgress; + private int m_transferGeneration; private readonly Lock m_cache = new(); private readonly LinkedList m_messageCache = new(); private ArrayOf m_availableSequenceNumbers; @@ -3549,6 +3847,8 @@ private class IncomingMessage public bool Republished; public bool RepublishImmediately; public StatusCode RepublishStatus; + public bool SavedDuringTransfer; + public int TransferGeneration; } } @@ -4178,5 +4478,31 @@ public static partial void ErrorWhileRaisingPublishStateChangedEventState( string state, uint subscriptionId, NodeId? sessionId); + + [LoggerMessage(EventId = ClientEventIds.Subscription + 68, Level = LogLevel.Warning, + Message = "SubscriptionId {SubscriptionId}: Failed to delete the transferred subscription that" + + " could not be adopted, SessionId={SessionId}.")] + public static partial void SubscriptionIdFailedDeleteTransferredSubscription( + this ILogger logger, + Exception? exception, + uint subscriptionId, + NodeId? sessionId); + + [LoggerMessage(EventId = ClientEventIds.Subscription + 69, Level = LogLevel.Debug, + Message = "SubscriptionId {SubscriptionId}: Ignored late message with sequence number" + + " {SequenceNumber}, last processed is {LastSequenceNumber}. SessionId={SessionId}.")] + public static partial void SubscriptionIdIgnoredAlreadyProcessedSequenceNumber( + this ILogger logger, + uint subscriptionId, + uint sequenceNumber, + uint lastSequenceNumber, + NodeId? sessionId); + + [LoggerMessage(EventId = ClientEventIds.Subscription + 70, Level = LogLevel.Information, + Message = "SubscriptionId {SubscriptionId}: Good_SubscriptionTransferred reports a transfer this" + + " client made itself; the subscription is kept running.")] + public static partial void SubscriptionIdIgnoredGoodSubscriptionTransferredOwnTransfer( + this ILogger logger, + uint subscriptionId); } } diff --git a/src/Opc.Ua.Client/Subscription/MessageProcessor.cs b/src/Opc.Ua.Client/Subscription/MessageProcessor.cs index c0c31a0254..4f838d0b7d 100644 --- a/src/Opc.Ua.Client/Subscription/MessageProcessor.cs +++ b/src/Opc.Ua.Client/Subscription/MessageProcessor.cs @@ -373,6 +373,15 @@ private async Task ProcessMessageCoreAsync( // the keep-alive, and DO NOT advance the data-dedup gate // (LastDataSequenceNumberProcessed) — otherwise the next real // data message would be silently dropped as a duplicate. + // + // Because the keep-alive names the next sequence number, a + // keep-alive more than one ahead of the last data message + // proves the messages in between were sent but never + // received (Part 4 §5.14.1.1). Recover them now rather than + // waiting for the next data message, which on a quiet + // subscription may never come before the server drops them. + await RecoverMissingBeforeKeepAliveAsync(curSeqNum, ct) + .ConfigureAwait(false); LastSequenceNumberProcessed = curSeqNum; await OnNotificationReceivedAsync( incoming.Message, @@ -461,6 +470,8 @@ await TryRepublishAsync(seq, curSeqNum, ct) } LastDataSequenceNumberProcessed = curSeqNum; LastSequenceNumberProcessed = curSeqNum; + // The original arrived after all, e.g. after its recovery failed. + m_notDispatched.Remove(curSeqNum); await OnNotificationReceivedAsync( incoming.Message, PublishState.None, @@ -468,6 +479,88 @@ await OnNotificationReceivedAsync( ct).ConfigureAwait(false); } + /// + /// Republish the data messages a keep-alive proves are missing. The + /// keep-alive carries the sequence number of the next message to be + /// sent, so every sequence number between the last processed data + /// message and the keep-alive was sent and is missing. + /// + /// The sequence number of the + /// keep-alive. + /// Cancellation token. + private async ValueTask RecoverMissingBeforeKeepAliveAsync( + uint keepAliveSeqNum, + CancellationToken ct) + { + const uint kBackwardThreshold = 1u << 31; + uint prevDataSeq = LastDataSequenceNumberProcessed; + if (prevDataSeq == 0) + { + // Nothing to compare against yet (first message after create). + return; + } + uint delta = unchecked(keepAliveSeqNum - prevDataSeq); + if (delta is 0 or 1 or >= kBackwardThreshold) + { + // No gap, or a stale keep-alive. + return; + } + uint gap = delta - 1; + if (keepAliveSeqNum < prevDataSeq) + { + // Zero is skipped at the wrap point (Part 4 §5.14.5.1). + gap--; + } + if (gap == 0) + { + return; + } + IReadOnlyList available = AvailableInRetransmissionQueue; + var candidates = new List(); + for (int i = 0; i < available.Count; i++) + { + uint seq = available[i]; + uint offset = unchecked(seq - prevDataSeq); + if (offset != 0 && offset < delta) + { + candidates.Add(seq); + } + } + if (candidates.Count == 0) + { + // Nothing the server holds can be recovered now. Leave the + // data gate where it is: a missing message may still be in + // flight on another publish worker, and advancing the gate + // would discard it as a duplicate when it arrives. The next + // data message accounts for the gap. + return; + } + Interlocked.Add(ref m_missingCount, gap); + + // Recover in sequence order and advance the data gate only over + // what was actually recovered. On the first failure stop, so the + // gap-walk of the next data message retries the rest instead of + // treating them as already processed. + foreach (uint seq in SortAscendingWrapAware(candidates)) + { + if (!await TryRepublishAsync(seq, keepAliveSeqNum, ct) + .ConfigureAwait(false)) + { + return; + } + LastDataSequenceNumberProcessed = seq; + } + + // Everything the server holds was recovered. Advance the data + // gate to the message just before the keep-alive so the gap is + // accounted for once: the data message that later reuses the + // keep-alive's sequence number is then not treated as a gap + // again, and recovered messages that arrive late are discarded + // as duplicates. + uint lastMissing = unchecked(keepAliveSeqNum - 1); + LastDataSequenceNumberProcessed = lastMissing == 0 ? uint.MaxValue : lastMissing; + } + /// /// Retires the current server-side generation while message delivery is /// quiesced, resets generation-specific cursors, and initializes the @@ -481,15 +574,36 @@ protected async ValueTask ResetMessageGenerationAsync( Func initializeAsync, CancellationToken ct) { - await m_messageDispatchGate.WaitAsync(ct).ConfigureAwait(false); + if (!m_messageDispatchGate.Wait(0, CancellationToken.None)) + { + // A notification callback is running. Callers hold the + // owner's state lock here, so give the owner a chance to + // release anything that callback may be awaiting which in + // turn needs that lock - otherwise neither side progresses. + OnDispatchGateContended(); + try + { + await m_messageDispatchGate.WaitAsync(ct).ConfigureAwait(false); + } + catch + { + // The reset gave up before it got the gate. Withdraw the + // release request so later callbacks are not treated as + // blocking a reset that no longer waits. + OnDispatchGateWaitAbandoned(); + throw; + } + } try { + OnDispatchGateAcquired(); await retireAsync(ct).ConfigureAwait(false); Interlocked.Increment(ref m_generation); LastSequenceNumberProcessed = 0; LastDataSequenceNumberProcessed = 0; LastNotificationTimestamp = 0; AvailableInRetransmissionQueue = []; + m_notDispatched.Clear(); await initializeAsync(ct).ConfigureAwait(false); } finally @@ -498,6 +612,33 @@ protected async ValueTask ResetMessageGenerationAsync( } } + /// + /// Called by when it has to + /// wait for a notification callback to return before it can retire + /// the current generation. + /// + protected virtual void OnDispatchGateContended() + { + } + + /// + /// Called by once it holds + /// the dispatch gate, i.e. while no notification callback runs. + /// + protected virtual void OnDispatchGateAcquired() + { + } + + /// + /// Called by when its wait + /// for the dispatch gate, announced through + /// , ends without acquiring the + /// gate (for example because it was cancelled). + /// + protected virtual void OnDispatchGateWaitAbandoned() + { + } + /// /// Recover the notification messages the server still holds in its /// retransmission queue after a subscription was transferred to this @@ -536,6 +677,29 @@ protected async ValueTask RecoverTransferredMessagesAsync( // next message is not treated as the first after create. foreach (uint sequenceNumber in ordered) { + if (LastDataSequenceNumberProcessed != 0 && + !IsNewerSequenceNumber( + sequenceNumber, + LastDataSequenceNumberProcessed) && + !m_notDispatched.Contains(sequenceNumber)) + { + // The subscription survived the transfer with its + // dedup gate intact (reconnect onto the same + // object), and this message was already dispatched + // - it is only still listed because its + // acknowledgement had not reached the server yet + // (Part 4 §5.14.7.2). Do not republish or + // re-dispatch it; just acknowledge it so the server + // can drop it from its retransmission queue. + // Messages whose republish failed earlier were + // never dispatched and are recovered below. + await AckQueue.QueueAsync(new SubscriptionAcknowledgement + { + SequenceNumber = sequenceNumber, + SubscriptionId = Id + }, ct).ConfigureAwait(false); + continue; + } await RepublishKnownAvailableAsync( sequenceNumber, sequenceNumber, @@ -608,7 +772,10 @@ private static bool IsNewerSequenceNumber(uint sequenceNumber, uint currentSeque /// /// /// - private async ValueTask TryRepublishAsync( + /// false if the message is listed as available but + /// could not be recovered; true if it was dispatched or the + /// server no longer holds it. + private async ValueTask TryRepublishAsync( uint missing, uint curSeqNum, CancellationToken ct) @@ -619,9 +786,9 @@ private async ValueTask TryRepublishAsync( Logger.SubscriptionMessageSequenceNumberSeqNumberNot( Id, missing); - return; + return true; } - await RepublishAvailableAsync(missing, curSeqNum, ct) + return await RepublishAvailableAsync(missing, curSeqNum, ct) .ConfigureAwait(false); } @@ -635,7 +802,14 @@ await RepublishAvailableAsync(missing, curSeqNum, ct) .ConfigureAwait(false); } - private async ValueTask RepublishAvailableAsync( + /// + /// Republish a message the server lists as available and dispatch + /// it. A message that cannot be recovered is remembered as never + /// dispatched, so a later transfer recovery republishes it instead + /// of acknowledging it as already processed. + /// + /// true if the message was dispatched. + private async ValueTask RepublishAvailableAsync( uint missing, uint curSeqNum, CancellationToken ct) @@ -654,21 +828,21 @@ private async ValueTask RepublishAvailableAsync( if (ServiceResult.IsGood(republish.ResponseHeader.ServiceResult)) { + m_notDispatched.Remove(missing); await OnNotificationReceivedAsync( republish.NotificationMessage, PublishState.Republish, republish.ResponseHeader.StringTable.ToArray() ?? [], ct).ConfigureAwait(false); + return true; } - else - { - Logger.SubscriptionRepublishingMessageSequenceNumberSeqNumber( - Id, - missing); - } + Logger.SubscriptionRepublishingMessageSequenceNumberSeqNumber( + Id, + missing); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + RememberNotDispatched(missing); throw; } catch (Exception ex) @@ -678,6 +852,27 @@ await OnNotificationReceivedAsync( Id, missing); } + RememberNotDispatched(missing); + return false; + } + + /// + /// Remember a sequence number whose republish failed. Called with the + /// dispatch gate held. The set is bounded: numbers the server no + /// longer lists cannot be recovered any more and are dropped first. + /// + private void RememberNotDispatched(uint sequenceNumber) + { + if (m_notDispatched.Count >= kMaxNotDispatched) + { + IReadOnlyList available = AvailableInRetransmissionQueue; + m_notDispatched.RemoveWhere(s => !available.Contains(s)); + if (m_notDispatched.Count >= kMaxNotDispatched) + { + return; + } + } + m_notDispatched.Add(sequenceNumber); } /// @@ -912,6 +1107,14 @@ protected bool IsDispatchingNotification private readonly Task m_messageWorkerTask; private readonly Channel m_messages; private const int kIncomingMessageCapacity = 1024; + private const int kMaxNotDispatched = 1024; + + /// + /// Sequence numbers of the current generation whose republish failed, + /// so they were never dispatched although the data gate may have + /// moved past them. Only accessed with the dispatch gate held. + /// + private readonly HashSet m_notDispatched = []; } /// diff --git a/src/Opc.Ua.Client/Subscription/MonitoredItem.cs b/src/Opc.Ua.Client/Subscription/MonitoredItem.cs index 8bda832e1d..d079deae6d 100644 --- a/src/Opc.Ua.Client/Subscription/MonitoredItem.cs +++ b/src/Opc.Ua.Client/Subscription/MonitoredItem.cs @@ -948,7 +948,13 @@ public Change(MonitoredItem item, MonitoredItemOptions options, MonitoredItemOpt if (currentOptions == null || currentOptions.StartNodeId != options.StartNodeId || currentOptions.AttributeId != options.AttributeId || - currentOptions.IndexRange != options.IndexRange || + // A null and an empty IndexRange both mean "not used" + // (Part 4 §7.27), so only a real difference forces a + // recreate. + !string.Equals( + string.IsNullOrEmpty(currentOptions.IndexRange) ? null : currentOptions.IndexRange, + string.IsNullOrEmpty(options.IndexRange) ? null : options.IndexRange, + StringComparison.Ordinal) || currentOptions.Encoding != options.Encoding) { Modify = null; @@ -1021,8 +1027,8 @@ internal void SetCreateResult(MonitoredItemCreateRequest request, // interval and queue size the server never accepted. Item.CurrentMonitoringMode = request.MonitoringMode; Item.ServerId = result.MonitoredItemId; - Item.CurrentSamplingInterval = - TimeSpan.FromMilliseconds(result.RevisedSamplingInterval); + Item.CurrentSamplingInterval = SaturatingTimeSpan.FromMilliseconds( + result.RevisedSamplingInterval, Options.SamplingInterval); Item.CurrentQueueSize = result.RevisedQueueSize; Item.LogRevisedSamplingRateAndQueueSize(Options, true); @@ -1061,8 +1067,8 @@ internal void SetModifyResult(MonitoredItemModifyRequest request, request.RequestedParameters.SamplingInterval); Item.CurrentQueueSize = request.RequestedParameters.QueueSize; - Item.CurrentSamplingInterval = TimeSpan.FromMilliseconds( - result.RevisedSamplingInterval); + Item.CurrentSamplingInterval = SaturatingTimeSpan.FromMilliseconds( + result.RevisedSamplingInterval, Options.SamplingInterval); Item.CurrentQueueSize = result.RevisedQueueSize; if (MonitoringModeChange == null) diff --git a/src/Opc.Ua.Client/Subscription/MonitoredItemManager.cs b/src/Opc.Ua.Client/Subscription/MonitoredItemManager.cs index 838d38e859..bbe109881f 100644 --- a/src/Opc.Ua.Client/Subscription/MonitoredItemManager.cs +++ b/src/Opc.Ua.Client/Subscription/MonitoredItemManager.cs @@ -470,10 +470,32 @@ internal async Task ApplyChangesAsync(bool once, bool resetAll, CancellationToken ct) { bool modified = false; + var attemptedChanges = new HashSet(); + var attemptedDeletes = new HashSet(); while (!ct.IsCancellationRequested && TryGetMonitoredItemChanges( out List? itemsToDelete, out List? itemsToModify, resetAll)) { + if (modified && + itemsToDelete.TrueForAll(attemptedDeletes.Contains) && + itemsToModify.TrueForAll(attemptedChanges.Contains)) + { + // Everything still pending already failed once in this + // call. Retrying it back-to-back would burn the per-item + // retry budget within milliseconds (and spin forever on + // a delete that keeps failing), so hand the leftovers back + // and let the owner's backoff schedule the next attempt. + if (itemsToDelete.Count != 0) + { + lock (m_monitoredItemsLock) + { + m_deletedItems.AddRange(itemsToDelete); + } + } + break; + } + attemptedChanges.UnionWith(itemsToModify); + attemptedDeletes.UnionWith(itemsToDelete); await ApplyMonitoredItemChangesAsync(itemsToDelete, itemsToModify, ct).ConfigureAwait(false); // While there are changes pending to be applied apply them @@ -802,6 +824,34 @@ internal async ValueTask TrySynchronizeHandlesAsync( return false; } + // + // Build the handle map before touching the item table. Client + // handles are not unique on the server (Part 4 §7.21): a create + // whose response was lost and that was then issued again leaves + // two server items with the same client handle. Keep one server + // item per client handle - preferring the one the local item is + // already bound to - and delete the extra ones. + // + var clientServerHandleMap = new Dictionary(); + var duplicateServerHandles = new List(); + foreach ((uint serverHandle, uint clientHandle) in serverHandleStateMap) + { + if (clientServerHandleMap.TryAdd(clientHandle, serverHandle)) + { + continue; + } + if (m_monitoredItems.TryGetValue(clientHandle, out MonitoredItem? bound) && + bound.ServerId == serverHandle) + { + duplicateServerHandles.Add(clientServerHandleMap[clientHandle]); + clientServerHandleMap[clientHandle] = serverHandle; + } + else + { + duplicateServerHandles.Add(serverHandle); + } + } + IDictionary monitoredItems = m_monitoredItems.ToDictionary(); m_monitoredItems.Clear(); @@ -810,8 +860,6 @@ internal async ValueTask TrySynchronizeHandlesAsync( // handles the case where the CreateMonitoredItems call succeeded on the // server side, but the response was not provided back. // - var clientServerHandleMap = serverHandleStateMap - .ToDictionary(m => m.clientHandle, m => m.serverHandle); foreach (KeyValuePair monitoredItem in monitoredItems.ToList()) { // @@ -832,8 +880,13 @@ internal async ValueTask TrySynchronizeHandlesAsync( // This handles the case where we are recreating the subscription from a // previously stored state. // - var serverClientHandleMap = clientServerHandleMap - .ToDictionary(m => m.Value, m => m.Key); + var serverClientHandleMap = new Dictionary(); + foreach (KeyValuePair handles in clientServerHandleMap) + { + // A server reporting the same server handle twice is + // broken; keep the first mapping rather than throwing. + serverClientHandleMap.TryAdd(handles.Value, handles.Key); + } foreach (KeyValuePair monitoredItem in monitoredItems.ToList()) { uint serverHandle = monitoredItem.Value.ServerId; @@ -850,7 +903,10 @@ internal async ValueTask TrySynchronizeHandlesAsync( } m_deletedItems.Clear(); - itemsToDelete = new ArrayOf(serverClientHandleMap.Keys.ToArray()); + itemsToDelete = new ArrayOf(serverClientHandleMap.Keys + .Concat(duplicateServerHandles) + .Distinct() + .ToArray()); // Remaining items do not exist anymore on the server and need to be recreated foreach (MonitoredItem? missingOnServer in monitoredItems.Values) @@ -1840,6 +1896,17 @@ private void ReplayTriggeringLinksAfterRecreate(MonitoredItem item) } } + /// + /// Complete an operation that can no longer be applied with + /// . The caller marks + /// it cancelled so a later apply pass skips it. + /// + /// The abandoned operation. + internal static void AbandonTriggeringOperation(TriggeringOperation op) + { + FailOperation(op, op.TriggeringItem, StatusCodes.BadOperationAbandoned); + } + private static void FailOperation( TriggeringOperation op, IMonitoredItem trig, StatusCode status) { diff --git a/src/Opc.Ua.Client/Subscription/MonitoredItemStateSnapshot.cs b/src/Opc.Ua.Client/Subscription/MonitoredItemStateSnapshot.cs index 04ea2a4718..56f27eabdb 100644 --- a/src/Opc.Ua.Client/Subscription/MonitoredItemStateSnapshot.cs +++ b/src/Opc.Ua.Client/Subscription/MonitoredItemStateSnapshot.cs @@ -203,7 +203,10 @@ public MonitoredItemOptions ToOptions() StartNodeId = StartNodeId.IsNull ? NodeId.Null : StartNodeId, TimestampsToReturn = (TimestampsToReturn)TimestampsToReturn, AttributeId = AttributeId, - IndexRange = IndexRange, + // The wire form stores "not used" as an empty string; map it + // back to the MonitoredItemOptions default so re-applying the + // application's own options compares equal after a restore. + IndexRange = string.IsNullOrEmpty(IndexRange) ? null : IndexRange, Encoding = Encoding.IsNull ? null : Encoding, MonitoringMode = (MonitoringMode)MonitoringMode, SamplingInterval = TimeSpan.FromMilliseconds(SamplingIntervalMs), diff --git a/src/Opc.Ua.Client/Subscription/SaturatingTimeSpan.cs b/src/Opc.Ua.Client/Subscription/SaturatingTimeSpan.cs new file mode 100644 index 0000000000..09a2fbc92d --- /dev/null +++ b/src/Opc.Ua.Client/Subscription/SaturatingTimeSpan.cs @@ -0,0 +1,111 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.Client.Subscriptions +{ + /// + /// Non-throwing conversions for values revised by + /// the server. A server may negotiate durations and counts up to the + /// limits of their wire types (Part 4 §5.14.2.2), and products such as + /// publishing interval x keep-alive count can exceed + /// . The throwing framework APIs would then + /// abort processing after the service call already succeeded, so these + /// helpers saturate instead. + /// + internal static class SaturatingTimeSpan + { + /// + /// Convert a duration in milliseconds, saturating at + /// . Negative values map to + /// and NaN to . + /// + /// The duration in milliseconds. + /// The value to use for NaN. + public static TimeSpan FromMilliseconds(double milliseconds, TimeSpan fallback) + { + if (double.IsNaN(milliseconds)) + { + return fallback; + } + if (milliseconds <= 0) + { + return TimeSpan.Zero; + } + if (milliseconds >= kMaxMilliseconds) + { + return TimeSpan.MaxValue; + } + return TimeSpan.FromMilliseconds(milliseconds); + } + + /// + /// Multiply a duration, saturating at + /// and . + /// + /// The duration. + /// The factor. + public static TimeSpan Multiply(TimeSpan value, double factor) + { + double ticks = value.Ticks * factor; + if (double.IsNaN(ticks)) + { + return TimeSpan.Zero; + } + if (ticks >= long.MaxValue) + { + return TimeSpan.MaxValue; + } + if (ticks <= long.MinValue) + { + return TimeSpan.MinValue; + } + return TimeSpan.FromTicks((long)ticks); + } + + /// + /// Convert a whole number of hours, saturating at + /// . + /// + /// The number of hours. + public static TimeSpan FromHours(uint hours) + { + return hours >= kMaxHours ? TimeSpan.MaxValue : TimeSpan.FromHours(hours); + } + + /// + /// Whole milliseconds that still convert without overflow on every + /// target framework (net48 rounds to the millisecond first). + /// + private const double kMaxMilliseconds = long.MaxValue / TimeSpan.TicksPerMillisecond; + + private const uint kMaxHours = (uint)(long.MaxValue / TimeSpan.TicksPerHour); + } +} diff --git a/src/Opc.Ua.Client/Subscription/Streaming/StreamingSubscription.cs b/src/Opc.Ua.Client/Subscription/Streaming/StreamingSubscription.cs index 45f080c71c..f5b8d27e9c 100644 --- a/src/Opc.Ua.Client/Subscription/Streaming/StreamingSubscription.cs +++ b/src/Opc.Ua.Client/Subscription/Streaming/StreamingSubscription.cs @@ -51,12 +51,18 @@ public sealed class StreamingSubscription : IStreamingSubscription, IStreamingSu { private readonly ISubscriptionManager m_subscriptionManager; private readonly SubscriptionOptions m_subscriptionOptions; + // CA2213: intentionally never disposed - a subscriber racing + // DisposeAsync may still wait on it and must be able to release it. + // SemaphoreSlim only holds unmanaged state once AvailableWaitHandle + // is accessed, which never happens here. +#pragma warning disable CA2213 private readonly SemaphoreSlim m_initLock = new(1, 1); +#pragma warning restore CA2213 private readonly ConcurrentDictionary m_subscribers = new(); private readonly Notifier m_notifier; private ISubscription? m_subscription; - private bool m_disposed; + private volatile bool m_disposed; private long m_handleCounter; /// @@ -114,7 +120,7 @@ private async IAsyncEnumerable SubscribeDataChangesImpl( MonitoredItems.MonitoredItemOptions? options, [EnumeratorCancellation] CancellationToken ct) { - await EnsureSubscriptionAsync(ct).ConfigureAwait(false); + ISubscription subscription = await EnsureSubscriptionAsync(ct).ConfigureAwait(false); Channel channel = CreateChannel( options?.QueueSize ?? 0, @@ -128,6 +134,10 @@ private async IAsyncEnumerable SubscribeDataChangesImpl( try { + // A dispose that ran after the subscription was obtained has + // already completed the subscribers it saw; fail instead of + // waiting on a channel nobody completes. + ThrowIfDisposed(); foreach (NodeId nodeId in nodeIds) { MonitoredItems.MonitoredItemOptions itemOptions = @@ -139,7 +149,7 @@ private async IAsyncEnumerable SubscribeDataChangesImpl( string name = $"stream_data_{handle}_{nodeId}"; - if (m_subscription!.MonitoredItems.TryAdd( + if (subscription.MonitoredItems.TryAdd( name, new OptionsMonitor(itemOptions), out IMonitoredItem? item) && @@ -168,7 +178,7 @@ private async IAsyncEnumerable SubscribeDataChangesImpl( { try { - m_subscription?.MonitoredItems.TryRemove(item.ClientHandle); + subscription.MonitoredItems.TryRemove(item.ClientHandle); } catch { @@ -227,7 +237,7 @@ private async IAsyncEnumerable SubscribeEventsImpl( [EnumeratorCancellation] CancellationToken ct, Func? onMonitoredItemReady = null) { - await EnsureSubscriptionAsync(ct).ConfigureAwait(false); + ISubscription subscription = await EnsureSubscriptionAsync(ct).ConfigureAwait(false); MonitoredItems.MonitoredItemOptions itemOptions = (options ?? new MonitoredItems.MonitoredItemOptions()) with @@ -252,7 +262,8 @@ private async IAsyncEnumerable SubscribeEventsImpl( try { - if (m_subscription!.MonitoredItems.TryAdd( + ThrowIfDisposed(); + if (subscription.MonitoredItems.TryAdd( name, new OptionsMonitor(itemOptions), out IMonitoredItem? item) && @@ -284,7 +295,7 @@ private async IAsyncEnumerable SubscribeEventsImpl( { try { - m_subscription?.MonitoredItems.TryRemove(clientHandle.Value); + subscription.MonitoredItems.TryRemove(clientHandle.Value); } catch { @@ -311,32 +322,51 @@ public async ValueTask DisposeAsync() } m_subscribers.Clear(); - if (m_subscription != null) + // Take the init lock so a first subscriber that is creating the + // subscription right now either finishes before we look at it + // (and we dispose what it created) or observes m_disposed and + // creates nothing. The lock is deliberately not disposed: such a + // racing subscriber may still be waiting on it and must be able + // to acquire and release it to observe the disposal. + ISubscription? subscription; + await m_initLock.WaitAsync().ConfigureAwait(false); + try + { + subscription = m_subscription; + m_subscription = null; + } + finally + { + m_initLock.Release(); + } + + if (subscription != null) { try { - await m_subscription.DisposeAsync().ConfigureAwait(false); + await subscription.DisposeAsync().ConfigureAwait(false); } catch { // best effort cleanup } - m_subscription = null; } - - m_initLock.Dispose(); } - private async ValueTask EnsureSubscriptionAsync(CancellationToken ct) + private async ValueTask EnsureSubscriptionAsync(CancellationToken ct) { - if (m_subscription != null) + ISubscription? current = Volatile.Read(ref m_subscription); + if (current != null) { - return; + return current; } + ThrowIfDisposed(); await m_initLock.WaitAsync(ct).ConfigureAwait(false); try { + ThrowIfDisposed(); + // Double-check after acquiring the init lock — another // caller may have created the subscription while we // were waiting. CA1508's single-threaded flow analysis @@ -346,13 +376,15 @@ private async ValueTask EnsureSubscriptionAsync(CancellationToken ct) #pragma warning disable CA1508 if (m_subscription != null) { - return; + return m_subscription; } #pragma warning restore CA1508 - m_subscription = m_subscriptionManager.Add( + ISubscription created = m_subscriptionManager.Add( m_notifier, new OptionsMonitor(m_subscriptionOptions)); + Volatile.Write(ref m_subscription, created); + return created; } finally { @@ -360,6 +392,14 @@ private async ValueTask EnsureSubscriptionAsync(CancellationToken ct) } } + private void ThrowIfDisposed() + { + if (m_disposed) + { + throw new ObjectDisposedException(nameof(StreamingSubscription)); + } + } + internal void DispatchDataChange(in DataValueChange change) { if (change.MonitoredItem == null) diff --git a/src/Opc.Ua.Client/Subscription/Subscription.cs b/src/Opc.Ua.Client/Subscription/Subscription.cs index 622d848ec0..534f306294 100644 --- a/src/Opc.Ua.Client/Subscription/Subscription.cs +++ b/src/Opc.Ua.Client/Subscription/Subscription.cs @@ -371,9 +371,60 @@ linksToRemove is null op.MarkCancelled(); tcs.TrySetCanceled(ct); }); + if (IsDispatchingNotification) + { + // A notification callback holds the dispatch gate while it + // awaits the apply pass, and the apply pass needs the state + // lock. A delete, recreate or dispose holds that lock while + // waiting for the gate, so neither could progress. Such a + // reset signals here first; the operation can then no + // longer be applied to this generation and is abandoned. + Task release = Volatile.Read(ref m_dispatchReleaseRequested).Task; + Task completed = await Task.WhenAny(tcs.Task, release) + .ConfigureAwait(false); + if (!ReferenceEquals(completed, tcs.Task)) + { + op.MarkCancelled(); + MonitoredItemManager.AbandonTriggeringOperation(op); + } + } return await tcs.Task.ConfigureAwait(false); } + /// + protected override void OnDispatchGateContended() + { + Volatile.Read(ref m_dispatchReleaseRequested).TrySetResult(true); + } + + /// + protected override void OnDispatchGateAcquired() + { + // No callback runs while the gate is held, so a fresh signal can + // be installed without a callback observing the old one late. + RearmDispatchReleaseRequest(); + } + + /// + protected override void OnDispatchGateWaitAbandoned() + { + // The reset no longer waits for the running callback. Callbacks + // that already observed the signal were released for a reset + // that was requested; later ones must see a fresh signal, or + // every SetTriggeringAsync from a callback would be abandoned. + RearmDispatchReleaseRequest(); + } + + private void RearmDispatchReleaseRequest() + { + if (Volatile.Read(ref m_dispatchReleaseRequested).Task.IsCompleted) + { + Volatile.Write(ref m_dispatchReleaseRequested, + new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously)); + } + } + /// public async ValueTask SetAsDurableAsync( TimeSpan lifetime, @@ -422,7 +473,7 @@ public async ValueTask SetAsDurableAsync( throw ServiceResultException.Create(StatusCodes.BadUnexpectedError, "Server.SetSubscriptionDurable returned no revised lifetime."); } - return TimeSpan.FromHours(revised); + return SaturatingTimeSpan.FromHours(revised); } /// @@ -1312,7 +1363,8 @@ internal async ValueTask CreateAsync(SubscriptionOptions options, CancellationTo options.Priority, options.MaxNotificationsPerPublish); OnSubscriptionUpdateComplete(true, response.SubscriptionId, - TimeSpan.FromMilliseconds(response.RevisedPublishingInterval), + SaturatingTimeSpan.FromMilliseconds( + response.RevisedPublishingInterval, options.PublishingInterval), response.RevisedMaxKeepAliveCount, response.RevisedLifetimeCount, options.Priority, options.MaxNotificationsPerPublish, options.PublishingEnabled); @@ -1358,7 +1410,8 @@ internal async ValueTask ModifyAsync(SubscriptionOptions options, CancellationTo options.Priority, options.MaxNotificationsPerPublish); OnSubscriptionUpdateComplete(false, 0, - TimeSpan.FromMilliseconds(response.RevisedPublishingInterval), + SaturatingTimeSpan.FromMilliseconds( + response.RevisedPublishingInterval, options.PublishingInterval), response.RevisedMaxKeepAliveCount, response.RevisedLifetimeCount, options.Priority, options.MaxNotificationsPerPublish, options.PublishingEnabled); @@ -1540,11 +1593,13 @@ private void StartKeepAliveTimer() { SubscriptionOptions options = Options; LastNotificationTimestamp = TimeProvider.GetTimestamp(); - m_keepAliveInterval = CurrentPublishingInterval.Multiply(CurrentKeepAliveCount + 1); + m_keepAliveInterval = SaturatingTimeSpan.Multiply( + CurrentPublishingInterval, (double)CurrentKeepAliveCount + 1); if (m_keepAliveInterval < s_minKeepAliveTimerInterval) { AdjustCounts(options, out uint adjustedKeepAliveCount, out _); - m_keepAliveInterval = options.PublishingInterval.Multiply(adjustedKeepAliveCount + 1); + m_keepAliveInterval = SaturatingTimeSpan.Multiply( + options.PublishingInterval, (double)adjustedKeepAliveCount + 1); } if (m_keepAliveInterval > s_maxKeepAliveTimerInterval) { @@ -1718,6 +1773,9 @@ private sealed record RequestedSubscriptionSettings( private readonly TaskCompletionSource m_disposeCompletion = new( TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource m_dispatchReleaseRequested = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CancellationTokenSource m_cts = new(); private readonly Task m_stateManagement; private readonly SemaphoreSlim m_stateLock = new(1, 1); diff --git a/src/Opc.Ua.Client/Subscription/SubscriptionManager.cs b/src/Opc.Ua.Client/Subscription/SubscriptionManager.cs index b28a2a0d63..ee3b50549a 100644 --- a/src/Opc.Ua.Client/Subscription/SubscriptionManager.cs +++ b/src/Opc.Ua.Client/Subscription/SubscriptionManager.cs @@ -1175,13 +1175,44 @@ internal async ValueTask RestoreGroupAsync( SubscriptionOptions options = snapshots[0].ToOptions(); var optionsMonitor = new OptionsMonitor(options); - for (int i = 1; i < snapshots.Count; i++) + try + { + for (int i = 1; i < snapshots.Count; i++) + { + ct.ThrowIfCancellationRequested(); + await AttachSecondaryPartitionAsync( + wrapper, handler, optionsMonitor, + snapshots[i], transferSubscriptions, ct) + .ConfigureAwait(false); + } + } + catch { - ct.ThrowIfCancellationRequested(); - await AttachSecondaryPartitionAsync( - wrapper, handler, optionsMonitor, - snapshots[i], transferSubscriptions, ct) - .ConfigureAwait(false); + // The primary and any secondaries attached so far are + // registered, but the caller never receives the wrapper. + // Roll back like RestoreAsync does for a failed transfer so + // no zombie keeps a server subscription alive and keeps + // calling the handler with nobody able to dispose it. + IReadOnlyList partitions = wrapper.Partitions; + lock (m_subscriptionLock) + { + m_logicals.Remove(wrapper); + foreach (IManagedSubscription partition in partitions) + { + m_subscriptions.Remove(partition); + } + } + try + { + await wrapper.DisposeAsync().ConfigureAwait(false); + } + catch (Exception disposeException) + when (disposeException is not OutOfMemoryException) + { + m_logger.ReCreationDidNotComplete( + partitions.Count > 0 ? partitions[0].Id : 0); + } + throw; } return wrapper; } @@ -1224,11 +1255,25 @@ private async ValueTask AttachSecondaryPartitionAsync( wrapper.ForwardingHandler ?? handler; IManagedSubscription partition = MintPreloadedPartition( effective, optionsMonitor, loadState); - AttachReactiveFallback(partition, wrapper); + try + { + AttachReactiveFallback(partition, wrapper); - // Append to the wrapper's partition list so the composite - // collection's policy + index account for it. - wrapper.AppendPreloadedPartition(partition); + // Append to the wrapper's partition list so the composite + // collection's policy + index account for it. + wrapper.AppendPreloadedPartition(partition); + } + catch + { + // Not yet owned by the wrapper, so the group rollback would + // not find it. + lock (m_subscriptionLock) + { + m_subscriptions.Remove(partition); + } + await partition.DisposeAsync().ConfigureAwait(false); + throw; + } // Best-effort transfer: when the saved ServerId is // non-zero AND the caller asked for transfer, issue @@ -2024,6 +2069,19 @@ private async Task PublishWorkerAsync(CancellationToken ct) m_consecutivePublishErrors = 0; m_lastLoggedErrorStatus = default; + // + // Snapshot the pending-creation state BEFORE the id + // lookup. A creation assigns its id before it clears + // its in-progress flag, so a creation that is no + // longer pending at the snapshot has its id visible + // to the lookup, and one that completes after the + // snapshot is still covered by the snapshot. The + // orphan delete below is decided on this snapshot + // only; a later, separate check could observe the + // creation completed after a lookup that missed it. + // + bool creationPending = m_outer.HasSubscriptionsPendingCreation(); + // Get the subscription with the provided identifier IManagedSubscription? subscription = m_outer.GetById(subscriptionId); publishLatency = m_outer.m_timeProvider.GetElapsedTime( @@ -2047,6 +2105,18 @@ private async Task PublishWorkerAsync(CancellationToken ct) } else if (!ct.IsCancellationRequested) { + // + // The Publish itself succeeded, so the server has + // processed the acknowledgements it carried. End + // the in-flight request now: a failure below must + // not re-queue those acknowledgements (the server + // would answer Bad_SequenceNumberUnknown), and a + // drain must not wait for the throttle or the + // orphan delete below. + // + m_outer.EndPublishRequest(); + publishActive = false; + // // The identifier does not resolve to any subscription // this manager owns. Delete the orphan on the server, @@ -2073,7 +2143,7 @@ private async Task PublishWorkerAsync(CancellationToken ct) await DelayUnresolvedSubscriptionAsync(ct) .ConfigureAwait(false); } - else if (m_outer.HasSubscriptionsPendingCreation()) + else if (creationPending) { // Log only the first response of a run for the // same identifier so a long lived creation window @@ -2111,7 +2181,7 @@ await DelayUnresolvedSubscriptionAsync(ct) await m_outer.m_session.DeleteSubscriptionsAsync( null, [subscriptionId], - ct).ConfigureAwait(false); + attemptToken).ConfigureAwait(false); // // Retire the identifier so responses that were // already in flight, or that the server emits @@ -2353,26 +2423,43 @@ private async Task> WaitForAcksAsync( .ReadAsync(pauseCts.Token).AsTask(); Task pausedTask = m_outer.m_publishingPaused .WaitAsync(pauseCts.Token); - Task completed = await Task.WhenAny(readTask, pausedTask) - .ConfigureAwait(false); - if (ReferenceEquals(completed, pausedTask)) + bool readConsumed = false; + SubscriptionAcknowledgement firstAck; + try { - await pausedTask.ConfigureAwait(false); - await pauseCts.CancelAsync().ConfigureAwait(false); - try + Task completed = await Task.WhenAny(readTask, pausedTask) + .ConfigureAwait(false); + if (ReferenceEquals(completed, pausedTask)) { - SubscriptionAcknowledgement acknowledgement = - await readTask.ConfigureAwait(false); - m_outer.m_acks.Writer.TryWrite(acknowledgement); + // Throws when the wait timed out or was cancelled; + // the finally below still rescues a racing ack. + await pausedTask.ConfigureAwait(false); + return []; } - catch (OperationCanceledException) + firstAck = await readTask.ConfigureAwait(false); + readConsumed = true; + } + finally + { + if (!readConsumed) { + // A writer can hand an acknowledgement to the + // pending read in the same instant the wait ends. + // Put it back on every exit path, otherwise it is + // never sent and the server keeps the message in + // its retransmission queue. + await pauseCts.CancelAsync().ConfigureAwait(false); + try + { + SubscriptionAcknowledgement acknowledgement = + await readTask.ConfigureAwait(false); + m_outer.m_acks.Writer.TryWrite(acknowledgement); + } + catch (Exception) when (readTask.IsCanceled || readTask.IsFaulted) + { + } } - return []; } - - SubscriptionAcknowledgement firstAck = - await readTask.ConfigureAwait(false); await pauseCts.CancelAsync().ConfigureAwait(false); try { @@ -2453,13 +2540,18 @@ private int CalculateTimeouts(long latency, ref uint currentTimeout) foreach (IManagedSubscription s in created) { TimeSpan publishingInterval = s.CurrentPublishingInterval; - TimeSpan keepAlive = publishingInterval.Multiply(s.CurrentKeepAliveCount); + // Saturate: the server may revise both factors up to the + // limits of their wire types (Part 4 §5.14.2.2), and a + // throwing multiply here would fault every publish worker. + TimeSpan keepAlive = SaturatingTimeSpan.Multiply( + publishingInterval, s.CurrentKeepAliveCount); if (timeout < keepAlive) { timeout = keepAlive; } - int pi = (int)publishingInterval.TotalMilliseconds; + int pi = (int)Math.Min( + publishingInterval.TotalMilliseconds, int.MaxValue); if (pi <= 0) { continue; @@ -2475,7 +2567,7 @@ private int CalculateTimeouts(long latency, ref uint currentTimeout) // value for PublishingInterval * KeepAliveCount // TODO: Validate this against spec // - timeout = timeout.Multiply(2); + timeout = SaturatingTimeSpan.Multiply(timeout, 2); if (timeout < s_minOperationTimeout) { timeout = s_minOperationTimeout; diff --git a/src/Opc.Ua.Client/Utils/AsyncReaderWriterLock.cs b/src/Opc.Ua.Client/Utils/AsyncReaderWriterLock.cs index 1f0e4d1fa9..69c5ae9325 100644 --- a/src/Opc.Ua.Client/Utils/AsyncReaderWriterLock.cs +++ b/src/Opc.Ua.Client/Utils/AsyncReaderWriterLock.cs @@ -69,7 +69,7 @@ namespace Opc.Ua.Client /// /// /// Cancellation: both and - /// respect the supplied + /// respect the supplied /// . If the writer is cancelled /// while waiting for the drain signal, the writer-entry /// semaphore is released so subsequent writers and readers can @@ -145,7 +145,35 @@ public async ValueTask ReaderLockAsync( /// release. /// /// Cancellation token. + public ValueTask WriterLockAsync( + CancellationToken ct = default) + { + return WriterLockAsync(Timeout.InfiniteTimeSpan, null, static () => false, ct); + } + + /// + /// Asynchronously acquires the writer lock, checking every + /// while readers that are already + /// in flight drain. When + /// returns true at such a check, the writer proceeds while those + /// readers are still active (new readers stay excluded); otherwise + /// it stays exclusive and keeps waiting. Used by recovery, which + /// fails the stale readers itself by replacing the dead channel + /// they wait on, so waiting for them first would only delay it by + /// their full request timeout. On a healthy channel the readers + /// complete normally and must not overlap with the writer. + /// + /// Interval at which + /// is checked, or + /// to always drain fully. + /// Time provider for the drain timeout. + /// Whether the writer may + /// stop waiting for the readers still in flight. + /// Cancellation token. public async ValueTask WriterLockAsync( + TimeSpan drainTimeout, + TimeProvider? timeProvider, + Func proceedWhileReadersActive, CancellationToken ct = default) { await m_writer.WaitAsync(ct).ConfigureAwait(false); @@ -167,7 +195,20 @@ public async ValueTask WriterLockAsync( { try { - await wait.WaitAsync(ct).ConfigureAwait(false); + while (!await WaitForDrainAsync(wait, drainTimeout, timeProvider, ct) + .ConfigureAwait(false)) + { + if (proceedWhileReadersActive()) + { + // Proceed holding the writer gate. The readers + // still in flight release on their own later. + lock (m_state) + { + m_drained = null; + } + break; + } + } } catch { @@ -186,6 +227,36 @@ public async ValueTask WriterLockAsync( return new Releaser(this, isWriter: true); } + /// + /// Waits for the readers to drain. Returns false when + /// elapsed first. + /// + private static async Task WaitForDrainAsync( + Task wait, + TimeSpan drainTimeout, + TimeProvider? timeProvider, + CancellationToken ct) + { + if (drainTimeout == Timeout.InfiniteTimeSpan) + { + await wait.WaitAsync(ct).ConfigureAwait(false); + return true; + } + using CancellationTokenSource drain = + (timeProvider ?? TimeProvider.System).CreateCancellationTokenSource(drainTimeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, drain.Token); + try + { + await wait.WaitAsync(linked.Token).ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when ( + drain.IsCancellationRequested && !ct.IsCancellationRequested) + { + return wait.IsCompleted; + } + } + /// /// Releases a reader lock. Internal — invoked by /// . @@ -216,7 +287,7 @@ private void ReleaseWriter() /// /// Disposable handle returned from /// / - /// . Releases the corresponding + /// . Releases the corresponding /// lock on dispose. /// public readonly struct Releaser : IDisposable diff --git a/src/Opc.Ua.Client/WebApi/WebApiClient.cs b/src/Opc.Ua.Client/WebApi/WebApiClient.cs index 5c38d7047d..788dc3af80 100644 --- a/src/Opc.Ua.Client/WebApi/WebApiClient.cs +++ b/src/Opc.Ua.Client/WebApi/WebApiClient.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading; @@ -87,8 +88,15 @@ public sealed class WebApiClient : IWebApiClient, IDisposable /// /// The HTTP client to use. /// Configuration options. + /// + /// BadSecurityChecksFailed when credentials are configured and the + /// client's base address is not https://. public WebApiClient(HttpClient httpClient, WebApiClientOptions? options = null) - : this(httpClient, ownsHttpClient: false, configureHttpClient: true, options) + : this( + ThrowIfCredentialsOverPlainHttp(httpClient, options), + ownsHttpClient: false, + configureHttpClient: true, + options) { } @@ -109,6 +117,7 @@ internal WebApiClient( WebApiClientOptions? options) : this(httpClient, ownsHttpClient: false, configureHttpClient: false, options) { + ThrowIfCredentialsOverPlainHttp(baseAddress, m_options); m_baseAddress = baseAddress; } @@ -188,6 +197,7 @@ public static WebApiClient Create(Uri baseAddress, WebApiClientOptions? options // handed to HttpClient — HttpClient only understands the // registered transport schemes. Uri normalizedAddress = NormalizeOpcUaUrl(baseAddress); + ThrowIfCredentialsOverPlainHttp(normalizedAddress, options); HttpClient httpClient = options?.HttpMessageHandler != null ? new HttpClient(options.HttpMessageHandler, disposeHandler: options.DisposeHandler) : new HttpClient(); @@ -195,6 +205,49 @@ public static WebApiClient Create(Uri baseAddress, WebApiClientOptions? options return new WebApiClient(httpClient, ownsHttpClient: true, configureHttpClient: true, options); } + /// + /// Checks a caller-supplied client before its default headers are + /// written, so credentials are never installed on a non-TLS client. + /// + private static HttpClient ThrowIfCredentialsOverPlainHttp( + HttpClient httpClient, + WebApiClientOptions? options) + { + if (httpClient?.BaseAddress != null) + { + ThrowIfCredentialsOverPlainHttp(httpClient.BaseAddress, options); + } + // A null client is rejected by the constructor it is passed to. + return httpClient!; + } + + /// + /// Refuses to send Bearer / Basic credentials in cleartext: the + /// Authorization header would otherwise go out over a plain + /// http:// address (the WSS channel rejects the same case). + /// + /// + /// BadSecurityChecksFailed when credentials are configured and + /// is not https://. + private static void ThrowIfCredentialsOverPlainHttp( + Uri address, + WebApiClientOptions? options) + { + if (options == null || + (options.BearerToken == null && !options.BasicCredentials.HasValue)) + { + return; + } + if (!address.IsAbsoluteUri || + !string.Equals(address.Scheme, Utils.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw ServiceResultException.Create( + StatusCodes.BadSecurityChecksFailed, + "Web API credentials must not be sent over a non-TLS address. " + + "Use an https:// endpoint or omit BearerToken/BasicCredentials."); + } + } + private static Uri NormalizeOpcUaUrl(Uri url) { if (string.Equals(url.Scheme, Utils.UriSchemeOpcHttpsWebApi, StringComparison.OrdinalIgnoreCase) || @@ -287,6 +340,20 @@ private async ValueTask InvokeRouteUnsafeAsync( } ThrowIfDisposed(); + // The caller may set BaseAddress after construction (or not at + // all), so check the address this request is actually sent to. + if (m_authorization != null) + { + Uri? effectiveBase = m_baseAddress ?? m_httpClient.BaseAddress; + if (effectiveBase == null) + { + throw ServiceResultException.Create( + StatusCodes.BadSecurityChecksFailed, + "Web API credentials require an https:// base address."); + } + ThrowIfCredentialsOverPlainHttp(effectiveBase, m_options); + } + byte[] body = WebApiBodyCodec.EncodeBody( request, m_messageContext, @@ -328,6 +395,14 @@ private async ValueTask InvokeRouteUnsafeAsync( .SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, linkedCts.Token) .ConfigureAwait(false); + // Translate throttling (HTTP 429/503, e.g. a rate limiter gate) into + // BadServerTooBusy with the Retry-After hint, like HttpsTransportChannel. + if ((int)response.StatusCode == 429 || + response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + throw HttpsTransportChannel.CreateServerTooBusyException(response); + } + response.EnsureSuccessStatusCode(); byte[] payload = await HttpResponseBodyReader.ReadAsync( diff --git a/src/Opc.Ua.Client/WebApi/WebApiTransportChannel.cs b/src/Opc.Ua.Client/WebApi/WebApiTransportChannel.cs index 94f48c1c7d..fa0827309b 100644 --- a/src/Opc.Ua.Client/WebApi/WebApiTransportChannel.cs +++ b/src/Opc.Ua.Client/WebApi/WebApiTransportChannel.cs @@ -28,6 +28,8 @@ * ======================================================================*/ using System; +using System.IO; +using System.Net; using System.Net.Http; using System.Net.Security; using System.Security.Cryptography.X509Certificates; @@ -248,13 +250,31 @@ private async Task HydrateEndpointFromServerAsync( ProfileUris = default }; + // The HttpClient timeout is infinite because OperationTimeout + // governs each request, so bound this call by OperationTimeout + // too - otherwise a server that accepts TLS but never answers + // hangs OpenAsync forever. + int operationTimeout = OperationTimeout; + using CancellationTokenSource? timeoutCts = operationTimeout > 0 + ? m_timeProvider.CreateCancellationTokenSource( + TimeSpan.FromMilliseconds(operationTimeout)) + : null; + using CancellationTokenSource? linkedCts = timeoutCts != null + ? CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token) + : null; + GetEndpointsResponse response; try { response = await m_client - .GetEndpointsAsync(request, ct) + .GetEndpointsAsync(request, linkedCts?.Token ?? ct) .ConfigureAwait(false); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // The caller cancelled the open - do not report success. + throw; + } catch { // Best-effort: if the server doesn't respond to sessionless @@ -375,6 +395,65 @@ public async ValueTask SendRequestAsync( ThrowIfDisposed(); WebApiClient client = m_client ?? throw BadNotConnected(); + // Apply the OperationTimeout like every other channel does and map + // HTTP transport failures onto ServiceResultExceptions (mirrors + // HttpsTransportChannel): the session keep-alive, the managed + // channel lease and the publish worker only recognise those. + int operationTimeout = OperationTimeout; + using CancellationTokenSource? timeoutCts = operationTimeout > 0 + ? m_timeProvider.CreateCancellationTokenSource( + TimeSpan.FromMilliseconds(operationTimeout)) + : null; + using CancellationTokenSource? linkedCts = timeoutCts != null + ? CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token) + : null; + try + { + return await DispatchAsync(client, request, linkedCts?.Token ?? ct) + .ConfigureAwait(false); + } + catch (HttpRequestException hre) + { + m_logger.WebApiRequestFailed(hre, nameof(WebApiTransportChannel)); + throw ServiceResultException.Create( + MapRequestFailure(hre), + hre, + "Error sending Web API request: {0}", + hre.InnerException?.Message ?? hre.Message); + } + catch (OperationCanceledException oce) when (!ct.IsCancellationRequested) + { + // Either the OperationTimeout or the HttpClient/RequestTimeout + // fired - the caller did not cancel. + m_logger.WebApiRequestTimedOut(oce, nameof(WebApiTransportChannel), operationTimeout); + throw ServiceResultException.Create( + StatusCodes.BadRequestTimeout, + "Web API request timed out (OperationTimeout {0} ms).", + operationTimeout); + } + catch (IOException ioe) + { + m_logger.WebApiRequestFailed(ioe, nameof(WebApiTransportChannel)); + throw ServiceResultException.Create( + StatusCodes.BadNotConnected, + ioe, + "Error reading Web API response: {0}", + ioe.Message); + } + } + + private static StatusCode MapRequestFailure(HttpRequestException exception) + { + // Throttling (HTTP 429/503) never gets here: WebApiClient already + // turned it into BadServerTooBusy before EnsureSuccessStatusCode. + return HttpsTransportChannel.MapRequestFailure(exception); + } + + private static async ValueTask DispatchAsync( + WebApiClient client, + IServiceRequest request, + CancellationToken ct) + { // Dispatch on the runtime CLR type via a hard-coded // switch over the 28 spec routes. Each branch calls a // strongly-typed WebApiClient.Async method, so @@ -487,7 +566,11 @@ private WebApiClientOptions BuildClientOptions(TransportChannelSettings settings DisposeHandler = disposeHandler, BearerToken = m_userOptions.BearerToken, BasicCredentials = m_userOptions.BasicCredentials, - RequestTimeout = m_userOptions.RequestTimeout + // Without an explicit RequestTimeout the channel's own + // OperationTimeout governs each request; the HttpClient's + // 100 s default would otherwise cut long-poll Publish short. + RequestTimeout = m_userOptions.RequestTimeout ?? + (OperationTimeout > 0 ? Timeout.InfiniteTimeSpan : null) }; } @@ -686,5 +769,20 @@ internal static partial class WebApiTransportChannelLog public static partial void ChannelTypeBypassingIOpcUaHttpClientFactoryOPCUACertificateValidator( this ILogger logger, string channelType); + + [LoggerMessage(EventId = ClientEventIds.WebApiTransportChannel + 1, Level = LogLevel.Warning, + Message = "{ChannelType}: Web API request failed.")] + public static partial void WebApiRequestFailed( + this ILogger logger, + Exception exception, + string channelType); + + [LoggerMessage(EventId = ClientEventIds.WebApiTransportChannel + 2, Level = LogLevel.Warning, + Message = "{ChannelType}: Web API request timed out (OperationTimeout {OperationTimeout} ms).")] + public static partial void WebApiRequestTimedOut( + this ILogger logger, + Exception exception, + string channelType, + int operationTimeout); } } diff --git a/src/Opc.Ua.Core/Stack/Https/HttpsTransportChannel.cs b/src/Opc.Ua.Core/Stack/Https/HttpsTransportChannel.cs index 2211fd5fe7..e0631fcc6f 100644 --- a/src/Opc.Ua.Core/Stack/Https/HttpsTransportChannel.cs +++ b/src/Opc.Ua.Core/Stack/Https/HttpsTransportChannel.cs @@ -825,7 +825,7 @@ protected virtual IServiceResponse DecodeResponse( /// it, distinguishing a transport failure worth retrying from a delivered /// HTTP error or a rejected TLS handshake. /// - private static StatusCode MapRequestFailure(HttpRequestException exception) + internal static StatusCode MapRequestFailure(HttpRequestException exception) { if (exception.InnerException is WebException webException) { diff --git a/src/Opc.Ua.Gds.Client/GlobalDiscoveryServerClient.cs b/src/Opc.Ua.Gds.Client/GlobalDiscoveryServerClient.cs index cc7a3613d6..d605c806fd 100644 --- a/src/Opc.Ua.Gds.Client/GlobalDiscoveryServerClient.cs +++ b/src/Opc.Ua.Gds.Client/GlobalDiscoveryServerClient.cs @@ -467,11 +467,14 @@ await CoreClientUtils.SelectEndpointAsync( endpointUrl, true, MessageContext.Telemetry, - ct).ConfigureAwait(false); + ct).ConfigureAwait(false) ?? + throw new ServiceResultException( + StatusCodes.BadSecurityPolicyRejected, + "The server offers no secure endpoint the client supports."); var endpointConfiguration = EndpointConfiguration.Create(Configuration); var endpoint = new ConfiguredEndpoint( null, - endpointDescription!, + endpointDescription, endpointConfiguration); await ConnectInternalAsync(endpoint, false, ct).ConfigureAwait(false); diff --git a/src/Opc.Ua.Gds.Client/ServerPushConfigurationClient.cs b/src/Opc.Ua.Gds.Client/ServerPushConfigurationClient.cs index 8037bf2564..06731c66e2 100644 --- a/src/Opc.Ua.Gds.Client/ServerPushConfigurationClient.cs +++ b/src/Opc.Ua.Gds.Client/ServerPushConfigurationClient.cs @@ -387,11 +387,14 @@ await CoreClientUtils.SelectEndpointAsync( endpointUrl, true, MessageContext.Telemetry, - ct).ConfigureAwait(false); + ct).ConfigureAwait(false) ?? + throw new ServiceResultException( + StatusCodes.BadSecurityPolicyRejected, + "The server offers no secure endpoint the client supports."); var endpointConfiguration = EndpointConfiguration.Create(Configuration); var endpoint = new ConfiguredEndpoint( null, - endpointDescription!, + endpointDescription, endpointConfiguration); await ConnectInternalAsync(endpoint, false, ct).ConfigureAwait(false); diff --git a/tests/Opc.Ua.Client.ComplexTypes.Tests/Types/EncoderTests.cs b/tests/Opc.Ua.Client.ComplexTypes.Tests/Types/EncoderTests.cs index 3416c81ffe..9848f5a2d0 100644 --- a/tests/Opc.Ua.Client.ComplexTypes.Tests/Types/EncoderTests.cs +++ b/tests/Opc.Ua.Client.ComplexTypes.Tests/Types/EncoderTests.cs @@ -239,6 +239,53 @@ public void ReEncodeUnionComplexType( emittedType); } + /// + /// Verbose JSON carries no SwitchField; the selected union field must be + /// recovered from the member name even though union fields are never + /// IsOptional. + /// + [Test] + [Category("ComplexTypes")] + public void DecodeVerboseJsonUnionSelectsFieldByName() + { + (_, Type complexType) = TypeDictionary[StructureType.Union]; + var expected = (UnionComplexType)Activator.CreateInstance(complexType); + expected["Int32"] = Variant.From(42); + Assert.That(expected.SwitchField, Is.Not.Zero); + + var union = (UnionComplexType)Activator.CreateInstance(complexType); + using var decoder = new JsonDecoder("{\"Int32\":42}", EncoderContext); + union.Decode(decoder); + + Assert.That(union.SwitchField, Is.EqualTo(expected.SwitchField)); + Assert.That(union.Value, Is.EqualTo(Variant.From(42))); + } + + /// + /// A SwitchField beyond the defined union fields is a decoding error + /// (Part 6 §5.2.8) instead of a poisoned object. + /// + [Test] + [Category("ComplexTypes")] + public void DecodeUnionWithOutOfRangeSwitchFieldThrows() + { + (_, Type complexType) = TypeDictionary[StructureType.Union]; + var union = (UnionComplexType)Activator.CreateInstance(complexType); + + byte[] buffer; + using (var encoder = new BinaryEncoder(EncoderContext)) + { + encoder.WriteUInt32(null, (uint)union.GetPropertyCount() + 1); + buffer = encoder.CloseAndReturnBuffer(); + } + + using var decoder = new BinaryDecoder(buffer, EncoderContext); + ServiceResultException sre = Assert.Throws( + () => union.Decode(decoder)); + Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadDecodingError)); + Assert.That(union.SwitchField, Is.Zero); + } + /// /// Verify serialize/encode of a structured type initiated from outside of an IEncoder instance. /// diff --git a/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameResolverTests.cs b/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameResolverTests.cs index 0643d34451..389c8f7ac6 100644 --- a/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameResolverTests.cs +++ b/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameResolverTests.cs @@ -32,10 +32,13 @@ // — and AliasNameResolver.DisposeAsync returns synchronously anyway. #pragma warning disable CA2007 +using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Client.AliasNames; +using Opc.Ua.Client.AliasNames.Refresh; namespace Opc.Ua.Client.Tests.AliasNames { @@ -142,6 +145,76 @@ public async Task InvalidateForcesRefreshOnNextResolveAsync() Is.EqualTo(new ExpandedNodeId("T-2", 2))); } + [Test] + public async Task DuplicateAliasNamesMergeTargetsAsync() + { + // FindAlias searches sub-categories too, so the same Name may be + // returned twice; the second entry must not overwrite the first. + var harness = AliasNameSessionHarness.Create(); + harness.CallHandler = _ => AliasesResult( + ("Temp", new ExpandedNodeId("T1", 2)), + ("Temp", new ExpandedNodeId("T2", 3))); + var client = AliasNameClient.OpenStandardAliases(harness.Session); + await using var resolver = new AliasNameResolver(client); + + IReadOnlyList result = await resolver + .ResolveAsync("Temp").ConfigureAwait(false); + + Assert.That(result, Is.EqualTo(new[] + { + new ExpandedNodeId("T1", 2), + new ExpandedNodeId("T2", 3) + })); + } + + [Test] + public async Task StrategyStartFailureDoesNotBreakResolveAsync() + { + var harness = AliasNameSessionHarness.Create(); + harness.CallHandler = _ => AliasesResult( + ("A", new ExpandedNodeId("T1", 2))); + var client = AliasNameClient.OpenStandardAliases(harness.Session); + var strategy = new ThrowingStartStrategy(); + await using var resolver = new AliasNameResolver( + client, + new AliasNameResolverOptions { RefreshStrategy = strategy }); + + IReadOnlyList result = await resolver + .ResolveAsync("A").ConfigureAwait(false); + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(strategy.StartCalls, Is.EqualTo(1)); + + // Loaded: a further resolve neither refetches nor restarts. + await resolver.ResolveAsync("A").ConfigureAwait(false); + Assert.That(strategy.StartCalls, Is.EqualTo(1)); + + // The start is retried on the next load. + resolver.Invalidate(); + await resolver.ResolveAsync("A").ConfigureAwait(false); + Assert.That(strategy.StartCalls, Is.EqualTo(2)); + } + + private sealed class ThrowingStartStrategy + : IAliasNameRefreshStrategy + { + public int StartCalls { get; private set; } + + public ValueTask StartAsync( + AliasNameClient client, + Action onInvalidate, + CancellationToken ct) + { + StartCalls++; + throw ServiceResultException.Create( + StatusCodes.BadTooManySubscriptions, "rejected"); + } + + public ValueTask DisposeAsync() + { + return default; + } + } + [Test] public async Task UnknownNameReturnsEmptyListAsync() { @@ -231,12 +304,33 @@ public async Task NonVerboseResolverReturnsEmptyServerUrisAsync() } [Test] - public async Task VerboseResolverFallsBackToNonVerboseOnNotSupportedAsync() + public Task VerboseResolverFallsBackToNonVerboseOnNotSupportedAsync() { // Server returns BadNotImplemented for the verbose method — // the resolver must transparently fall back to FindAlias and // populate the cache from the non-verbose response. + return AssertVerboseFallbackAsync(StatusCodes.BadNotImplemented); + } + + [Test] + public Task VerboseResolverFallsBackToNonVerboseOnMethodInvalidAsync() + { + // A category that does not expose the optional FindAliasVerbose + // returns BadMethodInvalid (Part 4 §5.12.2.4); that must also + // trigger the non-verbose fallback instead of failing every resolve. + return AssertVerboseFallbackAsync(StatusCodes.BadMethodInvalid); + } + + private static async Task AssertVerboseFallbackAsync(StatusCode verboseStatus) + { var harness = AliasNameSessionHarness.Create(); + // The category has no FindAliasVerbose instance method either, so + // the proxy's instance-MethodId retry finds nothing. + harness.BrowsePathHandler = _ => new BrowsePathResult + { + StatusCode = StatusCodes.BadNoMatch, + Targets = Array.Empty().ToArrayOf() + }; int verboseCalls = 0; int nonVerboseCalls = 0; harness.CallHandler = req => @@ -246,7 +340,7 @@ public async Task VerboseResolverFallsBackToNonVerboseOnNotSupportedAsync() verboseCalls++; return new CallMethodResult { - StatusCode = StatusCodes.BadNotImplemented + StatusCode = verboseStatus }; } nonVerboseCalls++; diff --git a/tests/Opc.Ua.Client.Tests/AuditRegressions/BrowserAuditRegressionTests.cs b/tests/Opc.Ua.Client.Tests/AuditRegressions/BrowserAuditRegressionTests.cs index 879c2ce114..35a949769a 100644 --- a/tests/Opc.Ua.Client.Tests/AuditRegressions/BrowserAuditRegressionTests.cs +++ b/tests/Opc.Ua.Client.Tests/AuditRegressions/BrowserAuditRegressionTests.cs @@ -292,6 +292,303 @@ public void FetchReferencesSurfacesTheBrowseError() "type queries wrongly for the whole cache lifetime"); } + /// + /// L9-2: a server advertising MaxNodesPerBrowse = 0xFFFFFFFF (legal, + /// often meaning "unlimited") made the managed browse cast the limit + /// to a negative batch size and throw ArgumentOutOfRangeException. + /// + [Test] + public async Task ManagedBrowseHandlesMaxNodesPerBrowseAboveInt32Async() + { + using SessionMock session = SessionMock.Create(); + + session.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(new BrowseResponse + { + Results = [GoodResult("targetA"), GoodResult("targetB")], + DiagnosticInfos = [] + })); + + var browser = new Browser(session) + { + MaxNodesPerBrowse = uint.MaxValue, + MaxBrowseContinuationPoints = 0 + }; + ResultSet> results = await browser + .BrowseAsync(new[] { new NodeId("A", 2), new NodeId("B", 2) }.ToArrayOf()) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(ServiceResult.IsGood(results.Errors[0]), Is.True); + Assert.That(ServiceResult.IsGood(results.Errors[1]), Is.True); + Assert.That(results.Results[1].Count, Is.EqualTo(1)); + }); + } + + /// + /// L9-1: the managed browse followed a continuation point that never + /// yielded references forever. It now gives up after a bounded number + /// of empty rounds, reports the node and releases the point. + /// + [Test] + public async Task ManagedBrowseGivesUpOnEndlessEmptyPagesAsync() + { + using SessionMock session = SessionMock.Create(); + List browseNexts = SetupEmptyPages(session, int.MaxValue); + + var browser = new Browser(session); + ResultSet> results = await browser + .BrowseAsync(new[] { new NodeId("A", 2) }.ToArrayOf()) + .AsTask() + .WaitAsync(System.TimeSpan.FromSeconds(30)) + .ConfigureAwait(false); + + Assert.That( + results.Errors[0].StatusCode, + Is.EqualTo((StatusCode)StatusCodes.BadNoData)); + Assert.That( + browseNexts.Exists(r => r.ReleaseContinuationPoints), + Is.True, + "the abandoned continuation point must be released"); + } + + /// + /// L9-1: empty pages with a continuation point are legal while the + /// server is still working, so the managed browse keeps following + /// them and returns the references that arrive later. + /// + [Test] + public async Task ManagedBrowseFollowsEmptyPagesOfASlowServerAsync() + { + using SessionMock session = SessionMock.Create(); + SetupEmptyPages(session, 3); + + var browser = new Browser(session); + ResultSet> results = await browser + .BrowseAsync(new[] { new NodeId("A", 2) }.ToArrayOf()) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(results.Errors[0]), Is.True); + Assert.That(results.Results[0].Count, Is.EqualTo(1)); + } + + /// + /// L9-5: BrowseAsync(NodeId) stopped at the first empty page and + /// returned a truncated reference list while the server still held + /// the continuation point. + /// + [Test] + public async Task SingleNodeBrowseFollowsEmptyPagesOfASlowServerAsync() + { + using SessionMock session = SessionMock.Create(); + SetupEmptyPages(session, 3); + + var browser = new Browser(session) { ContinueUntilDone = true }; + ArrayOf references = await browser + .BrowseAsync(new NodeId("A", 2)) + .ConfigureAwait(false); + + Assert.That(references.Count, Is.EqualTo(1)); + } + + /// + /// L9-5: a server that never makes progress fails the single node + /// browse and the continuation point is released. + /// + [Test] + public void SingleNodeBrowseGivesUpOnEndlessEmptyPagesAndReleases() + { + using SessionMock session = SessionMock.Create(); + List browseNexts = SetupEmptyPages(session, int.MaxValue); + + var browser = new Browser(session) { ContinueUntilDone = true }; + ServiceResultException ex = Assert.ThrowsAsync( + async () => await browser.BrowseAsync(new NodeId("A", 2)).ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadNoData)); + Assert.That(browseNexts.Exists(r => r.ReleaseContinuationPoints), Is.True); + } + + /// + /// L9-4: the stream yielded BadNoData for an empty page with a + /// continuation point and dropped the rest of the node's references. + /// + [Test] + public async Task BrowseStreamFollowsEmptyPagesOfASlowServerAsync() + { + using SessionMock session = SessionMock.Create(); + SetupEmptyPages(session, 3); + + var browser = new Browser(session); + var results = new List(); + await foreach (BrowseResult result in browser.BrowseStreamAsync( + null, null, [new BrowseDescription { NodeId = new NodeId("A", 2) }], default) + .ConfigureAwait(false)) + { + results.Add(result); + } + + Assert.That(results.TrueForAll(r => StatusCode.IsGood(r.StatusCode)), Is.True); + Assert.That(results[^1].References.Count, Is.EqualTo(1)); + } + + /// + /// L9-4: a server that never makes progress ends the stream for that + /// node with BadNoData and the point is released. + /// + [Test] + public async Task BrowseStreamGivesUpOnEndlessEmptyPagesAsync() + { + using SessionMock session = SessionMock.Create(); + List browseNexts = SetupEmptyPages(session, int.MaxValue); + + var browser = new Browser(session); + var results = new List(); + await foreach (BrowseResult result in browser.BrowseStreamAsync( + null, null, [new BrowseDescription { NodeId = new NodeId("A", 2) }], default) + .ConfigureAwait(false)) + { + results.Add(result); + Assert.That(results, Has.Count.LessThan(100), "the stream must terminate"); + } + + Assert.That( + results[^1].StatusCode, + Is.EqualTo((StatusCode)StatusCodes.BadNoData)); + Assert.That(browseNexts.Exists(r => r.ReleaseContinuationPoints), Is.True); + } + + /// + /// L9-3: a consumer stopping during the first page left every + /// continuation point of that page allocated on the server. + /// + [Test] + public async Task BrowseStreamReleasesAllPointsOfThePageOnEarlyExitAsync() + { + using SessionMock session = SessionMock.Create(); + var cpA = new ByteString(new byte[] { 0x0A }); + var cpB = new ByteString(new byte[] { 0x0B }); + + session.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(new BrowseResponse + { + Results = + [ + WithContinuationPoint(GoodResult("a"), cpA), + WithContinuationPoint(GoodResult("b"), cpB) + ], + DiagnosticInfos = [] + })); + var browseNexts = new List(); + session.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns((IServiceRequest request, CancellationToken _) => + { + var next = (BrowseNextRequest)request; + lock (browseNexts) + { + browseNexts.Add(next); + } + var results = new List(); + for (int i = 0; i < next.ContinuationPoints.Count; i++) + { + results.Add(GoodResult("x")); + } + return new ValueTask(new BrowseNextResponse + { + Results = results.ToArrayOf(), + DiagnosticInfos = [] + }); + }); + + var browser = new Browser(session); + await foreach (BrowseResult _ in browser.BrowseStreamAsync( + null, + null, + [ + new BrowseDescription { NodeId = new NodeId("A", 2) }, + new BrowseDescription { NodeId = new NodeId("B", 2) } + ], + default).ConfigureAwait(false)) + { + break; + } + + Assert.That(browseNexts, Has.Count.EqualTo(1)); + Assert.That(browseNexts[0].ReleaseContinuationPoints, Is.True); + Assert.That(browseNexts[0].ContinuationPoints.Count, Is.EqualTo(2)); + } + + /// + /// Browse answers with no references and a continuation point, and so + /// does every BrowseNext until pages + /// were returned; the next one carries a reference and no point. + /// + private static List SetupEmptyPages( + SessionMock session, + int emptyNextPages) + { + var cp = new ByteString(new byte[] { 0x01, 0x02 }); + session.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(new BrowseResponse + { + Results = [WithContinuationPoint(BadResult(StatusCodes.Good), cp)], + DiagnosticInfos = [] + })); + + var requests = new List(); + int served = 0; + session.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns((IServiceRequest request, CancellationToken _) => + { + var next = (BrowseNextRequest)request; + lock (requests) + { + requests.Add(next); + } + BrowseResult result; + if (next.ReleaseContinuationPoints) + { + result = BadResult(StatusCodes.Good); + } + else if (Interlocked.Increment(ref served) <= emptyNextPages) + { + result = WithContinuationPoint(BadResult(StatusCodes.Good), cp); + } + else + { + result = GoodResult("late"); + } + return new ValueTask(new BrowseNextResponse + { + Results = [result], + DiagnosticInfos = [] + }); + }); + return requests; + } + + private static BrowseResult WithContinuationPoint(BrowseResult result, ByteString cp) + { + result.ContinuationPoint = cp; + return result; + } + private static BrowseResult GoodResult(string targetName) { return new BrowseResult diff --git a/tests/Opc.Ua.Client.Tests/AuditRegressions/CoreClientUtilsAuditRegressionTests.cs b/tests/Opc.Ua.Client.Tests/AuditRegressions/CoreClientUtilsAuditRegressionTests.cs new file mode 100644 index 0000000000..5d70006ec3 --- /dev/null +++ b/tests/Opc.Ua.Client.Tests/AuditRegressions/CoreClientUtilsAuditRegressionTests.cs @@ -0,0 +1,187 @@ +/* ======================================================================== + * 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 NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Client.Tests.AuditRegressions +{ + /// + /// Regressions for the endpoint selection defects reported by the + /// Opc.Ua.Client audit. + /// + [TestFixture] + [Category("Client")] + [Category("AuditRegression")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + public sealed class CoreClientUtilsAuditRegressionTests + { + private static readonly Uri s_url = new("opc.tcp://localhost:4840"); + + /// + /// L9-9: when no endpoint passed the security filter, the "first + /// available endpoint" fallback returned a None endpoint although the + /// caller asked for security - a silent downgrade a rogue discovery + /// answer could force. + /// + [Test] + public void SelectEndpointWithSecurityNeverFallsBackToNone() + { + ArrayOf endpoints = + [ + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None) + ]; + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + s_url, + endpoints, + useSecurity: true, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.Null); + } + + /// + /// L9-9: the fallback must not pick a None endpoint when the only + /// secure endpoint uses a policy the client does not know. + /// + [Test] + public void SelectEndpointWithSecurityFallsBackOnlyToSecureEndpoints() + { + ArrayOf endpoints = + [ + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None), + CreateEndpoint(MessageSecurityMode.SignAndEncrypt, "http://unknown/policy") + ]; + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + s_url, + endpoints, + useSecurity: true, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.Not.Null); + Assert.That(selected!.SecurityMode, Is.EqualTo(MessageSecurityMode.SignAndEncrypt)); + } + + /// + /// Without security requested the fallback keeps its legacy + /// behaviour and still returns a secure endpoint when that is all the + /// server offers. + /// + [Test] + public void SelectEndpointWithoutSecurityStillFallsBack() + { + ArrayOf endpoints = + [ + CreateEndpoint(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256) + ]; + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + s_url, + endpoints, + useSecurity: false, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.Not.Null); + } + + /// + /// Review G17: an HTTPS endpoint with SecurityMode None is TLS protected + /// (Part 6, 7.4.1) and the discovery ran over the same TLS transport, so + /// with an https / opc.https discovery URL it is an acceptable fallback + /// under useSecurity (servers with HttpsMutualTls disabled only offer it). + /// + [TestCase("https://localhost:4843")] + [TestCase("opc.https://localhost:4843")] + public void SelectEndpointWithSecurityAcceptsHttpsNoneOverHttps(string discoveryUrl) + { + var url = new Uri(discoveryUrl); + ArrayOf endpoints = + [ + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None, s_url), + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None, url) + ]; + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + url, + endpoints, + useSecurity: true, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.Not.Null); + Assert.That(selected!.EndpointUrl, Does.StartWith(url.Scheme + "://")); + Assert.That(selected.SecurityMode, Is.EqualTo(MessageSecurityMode.None)); + } + + /// + /// Review G17: over HTTPS an endpoint with message security is still + /// preferred to the TLS-only None endpoint. + /// + [Test] + public void SelectEndpointWithSecurityPrefersSecureHttpsEndpoint() + { + var url = new Uri("opc.https://localhost:4843"); + ArrayOf endpoints = + [ + CreateEndpoint(MessageSecurityMode.None, SecurityPolicies.None, url), + CreateEndpoint(MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256, url) + ]; + + EndpointDescription? selected = CoreClientUtils.SelectEndpoint( + null!, + url, + endpoints, + useSecurity: true, + NUnitTelemetryContext.Create()); + + Assert.That(selected, Is.Not.Null); + Assert.That(selected!.SecurityMode, Is.EqualTo(MessageSecurityMode.SignAndEncrypt)); + } + + private static EndpointDescription CreateEndpoint( + MessageSecurityMode mode, + string policyUri, + Uri? url = null) + { + return new EndpointDescription + { + EndpointUrl = (url ?? s_url).ToString(), + SecurityMode = mode, + SecurityPolicyUri = policyUri + }; + } + } +} diff --git a/tests/Opc.Ua.Client.Tests/AuditRegressions/SessionCoreAuditRegressionTests.cs b/tests/Opc.Ua.Client.Tests/AuditRegressions/SessionCoreAuditRegressionTests.cs new file mode 100644 index 0000000000..71df996487 --- /dev/null +++ b/tests/Opc.Ua.Client.Tests/AuditRegressions/SessionCoreAuditRegressionTests.cs @@ -0,0 +1,160 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived, +// making CA2000 noisy without a real leak risk. Disabled file-level for the suite. +#pragma warning disable CA2000 +using System; +using System.Diagnostics; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client.TestFramework; +using Opc.Ua.Security.Certificates; + +namespace Opc.Ua.Client.Tests.AuditRegressions +{ + /// + /// Regressions for the session-core defects (L1-x) reported by the + /// Opc.Ua.Client audit. Each test names the defect it pins down. + /// + [TestFixture] + [Category("Client")] + [Category("AuditRegression")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + public sealed class SessionCoreAuditRegressionTests + { + private const BindingFlags PrivateInstance = BindingFlags.NonPublic | BindingFlags.Instance; + + /// + /// L1-5: Restore loaded the endpoint's server certificate with + /// Certificate.FromRawData, which throws on macOS for a chain blob + /// (leaf + issuers). The leaf must be parsed out of the chain. + /// + [Test] + public void RestoreParsesServerCertificateChainBlob() + { + using Certificate issuer = CertificateBuilder.Create("CN=RestoreIssuer") + .SetCAConstraint() + .CreateForRSA(); + using Certificate leaf = CertificateBuilder.Create("CN=RestoreLeaf") + .SetIssuer(issuer) + .CreateForRSA(); + var endpoint = new EndpointDescription + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + EndpointUrl = "opc.tcp://localhost:4840", + ServerCertificate = ByteString.From([.. leaf.RawData, .. issuer.RawData]), + UserIdentityTokens = [new UserTokenPolicy()] + }; + using SessionMock session = SessionMock.Create(endpoint); + + session.Restore(new SessionConfiguration + { + SessionName = "restored", + SessionId = new NodeId("session", 3), + AuthenticationToken = new NodeId("token", 3) + }); + + var restored = (Certificate)typeof(Session) + .GetField("m_serverCertificate", PrivateInstance)! + .GetValue(session)!; + Assert.That(restored, Is.Not.Null); + Assert.That(restored.Thumbprint, Is.EqualTo(leaf.Thumbprint)); + } + + /// + /// L1-3: disposing the session from a handler running on its + /// background work (Session.Notification) waited for the drain of + /// that same handler, i.e. the full 30 s drain timeout. + /// + [Test] + public async Task DisposeFromBackgroundHandlerDoesNotWaitForItself() + { + SessionMock session = SessionMock.Create(); + var disposed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Assert.That(session.RunBackgroundWork("test", () => + { + var watch = Stopwatch.StartNew(); + session.Dispose(); + disposed.SetResult(watch.Elapsed); + }), Is.True); + + TimeSpan elapsed = await disposed.Task.WaitAsync(TimeSpan.FromSeconds(15)).ConfigureAwait(false); + Assert.That(elapsed, Is.LessThan(TimeSpan.FromSeconds(10))); + } + + /// + /// G5 (review of L1-3): a continuation a Notification handler spawned + /// inherited the "running on this session's background work" marker. + /// Disposing the session from it after the handler returned skipped + /// the drain, so other handlers still running raced the teardown. + /// + [Test] + public async Task DisposeFromContinuationOfBackgroundHandlerStillDrains() + { + SessionMock session = SessionMock.Create(); + var trigger = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var otherRunning = new ManualResetEventSlim(false); + using var releaseOther = new ManualResetEventSlim(false); + var spawned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Assert.That(session.RunBackgroundWork("spawner", () => + { + // Captures the execution context of the work item. + spawned.SetResult(trigger.Task.ContinueWith( + _ => session.Dispose(), + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default)); + }), Is.True); + // Background work items may run concurrently; wait until the spawner ran. + Task disposal = await spawned.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(session.RunBackgroundWork("other", () => + { + otherRunning.Set(); + releaseOther.Wait(TimeSpan.FromSeconds(20)); + }), Is.True); + Assert.That(otherRunning.Wait(TimeSpan.FromSeconds(10)), Is.True); + + // The spawning work item has returned; dispose from its continuation. + trigger.SetResult(true); + Task finished = await Task.WhenAny(disposal, Task.Delay(500)).ConfigureAwait(false); + Assert.That(finished, Is.Not.SameAs(disposal), + "Dispose returned while another background handler was still running."); + + releaseOther.Set(); + await disposal.WaitAsync(TimeSpan.FromSeconds(15)).ConfigureAwait(false); + } + } +} diff --git a/tests/Opc.Ua.Client.Tests/ClientBuilder/OpcUaClientBuilderExtensionsCoverageTests.cs b/tests/Opc.Ua.Client.Tests/ClientBuilder/OpcUaClientBuilderExtensionsCoverageTests.cs index 18ce90c89c..9385aef457 100644 --- a/tests/Opc.Ua.Client.Tests/ClientBuilder/OpcUaClientBuilderExtensionsCoverageTests.cs +++ b/tests/Opc.Ua.Client.Tests/ClientBuilder/OpcUaClientBuilderExtensionsCoverageTests.cs @@ -229,6 +229,71 @@ public async Task ApplyManagedSessionOptionsFlagsAsync() Assert.That(captured.ModelChangeTracking, Is.True); } + [Test] + public async Task ApplyManagedSessionOptionsServerRedundancyOptionsAsync() + { + var redundancy = new ServerRedundancyOptions(); + ManagedSessionOptions captured = await CaptureAppliedSessionOptionsAsync(services => services.AddOpcUa().AddClient(opts => + { + opts.Configuration = CreateConfig(); + opts.Session = new ManagedSessionOptions + { + EnableServerRedundancy = true, + ServerRedundancy = redundancy + }; + })).ConfigureAwait(false); + + Assert.That(captured.ServerRedundancy, Is.SameAs(redundancy)); + } + + [Test] + public void AddDiscoveryAndConnectRewritesAdvertisedHostToDiscoveryHost() + { + var services = new ServiceCollection(); + services.AddOpcUa() + .AddClient(opts => opts.Configuration = CreateConfig()) + .AddDiscoveryAndConnect(opts => + { + opts.DiscoveryUrl = "opc.tcp://public-name:4841"; + opts.SecurityMode = MessageSecurityMode.None; + opts.SecurityPolicyUri = SecurityPolicies.None; + }); + services.AddSingleton(new EndpointDiscoveryStub( + [ + new EndpointDescription + { + EndpointUrl = "https://internal-host:4843", + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None + }, + new EndpointDescription + { + EndpointUrl = "opc.tcp://internal-host:4840/path", + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None + } + ])); + ConfiguredEndpoint? connected = null; + var factory = new Mock(); + factory.Setup(f => f.ConnectAsync(It.IsAny(), It.IsAny())) + .Callback((endpoint, _) => connected = endpoint) + .ThrowsAsync(new OperationCanceledException()); + services.AddSingleton(factory.Object); + + using ServiceProvider sp = services.BuildServiceProvider(); + Func> connect = + sp.GetRequiredService>>(); + + Assert.CatchAsync(() => connect(CancellationToken.None)); + + Assert.That(connected, Is.Not.Null); + Uri url = connected!.EndpointUrl!; + Assert.That(url.Scheme, Is.EqualTo("opc.tcp")); + Assert.That(url.Host, Is.EqualTo("public-name")); + Assert.That(url.Port, Is.EqualTo(4841)); + Assert.That(url.AbsolutePath, Is.EqualTo("/path")); + } + [Test] public async Task ApplyManagedSessionOptionsTimeProviderAsync() { @@ -334,6 +399,32 @@ private static ConfiguredEndpoint CreateEndpoint() /// Minimal discovery stub that returns an empty endpoint list so that /// SelectDiscoveredEndpointAsync always reaches the "no match" throw. /// + private sealed class EndpointDiscoveryStub : IOpcUaDiscoveryService + { + public EndpointDiscoveryStub(EndpointDescription[] endpoints) + { + m_endpoints = endpoints; + } + + public ValueTask> FindServersAsync( + string discoveryUrl, + ArrayOf serverUris = default, + CancellationToken ct = default) + { + return new ValueTask>([]); + } + + public ValueTask> GetEndpointsAsync( + string discoveryUrl, + ArrayOf profileUris = default, + CancellationToken ct = default) + { + return new ValueTask>(m_endpoints.ToArrayOf()); + } + + private readonly EndpointDescription[] m_endpoints; + } + private sealed class EmptyDiscoveryStub : IOpcUaDiscoveryService { public ValueTask> FindServersAsync( diff --git a/tests/Opc.Ua.Client.Tests/ClientBuilder/OpcUaClientBuilderTests.cs b/tests/Opc.Ua.Client.Tests/ClientBuilder/OpcUaClientBuilderTests.cs index a9bd0d7d56..f70c037912 100644 --- a/tests/Opc.Ua.Client.Tests/ClientBuilder/OpcUaClientBuilderTests.cs +++ b/tests/Opc.Ua.Client.Tests/ClientBuilder/OpcUaClientBuilderTests.cs @@ -617,6 +617,41 @@ private static void DeletePkiRoot(string pkiRoot) } } + [Test] + public async Task ManagedSessionAccessorReconnectsAfterCallerDisposesSharedSessionAsync() + { + Client.ManagedSession first = CreateUnconnectedManagedSession(); + Client.ManagedSession second = CreateUnconnectedManagedSession(); + int connects = 0; + await using var accessor = new OpcUaClientBuilderExtensions.ManagedSessionAccessor( + _ => Task.FromResult(++connects == 1 ? first : second)); + + Client.ManagedSession a = await accessor.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + Client.ManagedSession b = await accessor.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + await a.DisposeAsync().ConfigureAwait(false); + Client.ManagedSession c = await accessor.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.That(b, Is.SameAs(a)); + Assert.That(c, Is.SameAs(second), "a disposed shared session must not be handed out again"); + Assert.That(connects, Is.EqualTo(2)); + } + + [Test] + public async Task ManagedSessionAccessorDisposesCachedSessionOnDisposeAsync() + { + Client.ManagedSession session = CreateUnconnectedManagedSession(); + var accessor = new OpcUaClientBuilderExtensions.ManagedSessionAccessor( + _ => Task.FromResult(session)); + await accessor.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + await accessor.DisposeAsync().ConfigureAwait(false); + + Assert.That(session.Disposed, Is.True); + Assert.That( + () => accessor.ConnectAsync(CancellationToken.None), + Throws.TypeOf()); + } + private static Client.ManagedSession CreateUnconnectedManagedSession() { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); diff --git a/tests/Opc.Ua.Client.Tests/ClientBuilder/ReverseConnectManagerTests.cs b/tests/Opc.Ua.Client.Tests/ClientBuilder/ReverseConnectManagerTests.cs index daff9ce3de..a382522990 100644 --- a/tests/Opc.Ua.Client.Tests/ClientBuilder/ReverseConnectManagerTests.cs +++ b/tests/Opc.Ua.Client.Tests/ClientBuilder/ReverseConnectManagerTests.cs @@ -30,8 +30,10 @@ #nullable enable using System; +using System.Collections; using System.Globalization; using System.IO; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Moq; @@ -594,6 +596,79 @@ public void RegisterAndUnregisterWaitingConnection_RoundTrip() Assert.That(() => manager.UnregisterWaitingConnection(hashCode), Throws.Nothing); } + /// + /// Undefined is documented to default to Once; it must not behave as + /// Always (L8-1). + /// + [TestCase(ReverseConnectManager.ReverseConnectStrategy.Undefined, + ReverseConnectManager.ReverseConnectStrategy.Once)] + [TestCase(ReverseConnectManager.ReverseConnectStrategy.Any, + ReverseConnectManager.ReverseConnectStrategy.AnyOnce)] + [TestCase(ReverseConnectManager.ReverseConnectStrategy.Always, + ReverseConnectManager.ReverseConnectStrategy.Always)] + public void RegisterWaitingConnection_UndefinedStrategy_DefaultsToOnce( + ReverseConnectManager.ReverseConnectStrategy requested, + ReverseConnectManager.ReverseConnectStrategy expected) + { + ITelemetryContext telemetry = CreateTelemetry(); + using var manager = new ReverseConnectManager(telemetry); + + manager.RegisterWaitingConnection( + new Uri("opc.tcp://localhost:54329/"), + null, + (_, _) => { }, + requested); + + FieldInfo registrationsField = typeof(ReverseConnectManager).GetField( + "m_registrations", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var registrations = (IList)registrationsField.GetValue(manager)!; + Assert.That(registrations, Has.Count.EqualTo(1)); + object registration = registrations[0]!; + object strategy = registration.GetType() + .GetField("ReverseConnectStrategy")! + .GetValue(registration)!; + Assert.That(strategy, Is.EqualTo(expected)); + } + + /// + /// Re-adding an endpoint disposes the host it replaces (L8-2). + /// + [Test] + public async Task AddEndpoint_SameUrlTwice_DisposesReplacedHostAsync() + { + ITelemetryContext telemetry = CreateTelemetry(); + using var manager = new ReverseConnectManager(telemetry); + var uri = new Uri("opc.tcp://localhost:54330/"); + + manager.AddEndpoint(uri); + ReverseConnectHost first = GetManualHost(manager, uri); + Assert.That(first.HasListener, Is.True); + + manager.AddEndpoint(uri); + ReverseConnectHost second = GetManualHost(manager, uri); + + Assert.That(second, Is.Not.SameAs(first)); + Assert.That(second.HasListener, Is.True); + for (int ii = 0; ii < 100 && first.HasListener; ii++) + { + await Task.Delay(50).ConfigureAwait(false); + } + Assert.That(first.HasListener, Is.False, "The replaced host must be disposed."); + } + + private static ReverseConnectHost GetManualHost(ReverseConnectManager manager, Uri uri) + { + FieldInfo endpointsField = typeof(ReverseConnectManager).GetField( + "m_manualEndpoints", + BindingFlags.NonPublic | BindingFlags.Instance)!; + var endpoints = (IDictionary)endpointsField.GetValue(manager)!; + object info = endpoints[uri]!; + return (ReverseConnectHost)info.GetType() + .GetField("ReverseConnectHost")! + .GetValue(info)!; + } + [Test] public void UnregisterWaitingConnection_UnknownHashCode_DoesNotThrow() { diff --git a/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientCrudTests.cs b/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientCrudTests.cs index 3b5a86defe..8d1e3ad334 100644 --- a/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientCrudTests.cs +++ b/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientCrudTests.cs @@ -30,7 +30,9 @@ using System; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; +using Moq; using NUnit.Framework; using Opc.Ua.Client.FileSystem; @@ -253,6 +255,141 @@ public Task DeleteAsyncOnNonEmptyDirectoryWithoutRecursiveThrowsAsync() return Task.CompletedTask; } + /// + /// Review G16: the non-recursive empty check must not loop forever on + /// a server that answers every BrowseNext with an empty page and a + /// continuation point; the point is released and nothing is deleted. + /// + [Test] + public void DeleteAsyncWithoutRecursiveStopsOnEndlessEmptyBrowseNextPages() + { + var harness = FileSystemSessionHarness.Create(); + NodeId subdir = harness.RegisterDirectory(harness.Root, new QualifiedName("subdir")); + var continuationPoint = ByteString.From(new byte[] { 7, 7 }); + harness.SessionMock + .Setup(s => s.BrowseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.Is>(d => + d.Count == 1 && + d[0].NodeId == subdir && + d[0].ReferenceTypeId == ReferenceTypeIds.HierarchicalReferences), + It.IsAny())) + .ReturnsAsync(new BrowseResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new BrowseResult { ContinuationPoint = continuationPoint }] + }); + int browseNextCalls = 0; + harness.SessionMock + .Setup(s => s.BrowseNextAsync( + It.IsAny(), + false, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(() => + { + browseNextCalls++; + return new BrowseNextResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new BrowseResult { ContinuationPoint = continuationPoint }] + }; + }); + harness.SessionMock + .Setup(s => s.BrowseNextAsync( + It.IsAny(), + true, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new BrowseNextResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new BrowseResult()] + }); + var client = new FileSystemClient(harness.Session, harness.Root); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await client + .DeleteAsync("/subdir", recursive: false) + .ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNoData)); + Assert.That(browseNextCalls, Is.LessThan(20)); + harness.SessionMock.Verify(s => s.BrowseNextAsync( + It.IsAny(), + true, + It.IsAny>(), + It.IsAny()), Times.Once); + Assert.That(harness.CallRequests.Any(r => + r.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_DeleteFileSystemObject), Is.False); + } + + [Test] + public Task DeleteAsyncWithoutRecursiveRefusesDirectoryHoldingFilteredSubtypeAsync() + { + // The subtype filter hides a TrustListType child from + // EnumerateAsync, but the server's Delete would still remove it + // (Part 20 §4.3.5), so the directory is not empty. + var harness = FileSystemSessionHarness.Create(); + var typeTree = new Mock(MockBehavior.Loose); + typeTree + .Setup(t => t.IsTypeOf(It.IsAny(), It.IsAny())) + .Returns((sub, super) => sub.Equals(super) || + (sub.Equals(ObjectTypeIds.TrustListType) && super.Equals(ObjectTypeIds.FileType))); + harness.SessionMock.SetupGet(s => s.TypeTree).Returns(typeTree.Object); + NodeId subdir = harness.RegisterDirectory(harness.Root, new QualifiedName("subdir")); + harness.RegisterObject(subdir, new QualifiedName("TrustList"), ObjectTypeIds.TrustListType); + var client = new FileSystemClient( + harness.Session, + harness.Root, + new FileSystemClientOptions { IncludeFileTypeSubtypes = false }); + + Assert.ThrowsAsync( + async () => await client + .DeleteAsync("/subdir", recursive: false) + .ConfigureAwait(false)); + Assert.That(harness.CallRequests.Any(r => + r.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_DeleteFileSystemObject), Is.False); + return Task.CompletedTask; + } + + [Test] + public async Task ExistsAsyncReturnsFalseForExternallyDeletedCachedNodeAsync() + { + var harness = FileSystemSessionHarness.Create(); + NodeId dir = harness.RegisterDirectory(harness.Root, new QualifiedName("dir")); + NodeId file = harness.RegisterFile(dir, new QualifiedName("a.txt")); + var client = new FileSystemClient(harness.Session, harness.Root); + Assert.That(await client.ExistsAsync("/dir/a.txt").ConfigureAwait(false), Is.True); + + // Another client deletes the file; the path cache still maps it. + harness.RemoveNode(file); + + Assert.That(await client.ExistsAsync("/dir/a.txt").ConfigureAwait(false), Is.False); + } + + [Test] + public async Task GetInfoAsyncResolvesExternallyRecreatedDirectoryAsync() + { + var harness = FileSystemSessionHarness.Create(); + NodeId dir = harness.RegisterDirectory(harness.Root, new QualifiedName("dir")); + var client = new FileSystemClient(harness.Session, harness.Root); + Assert.That(await client.ExistsAsync("/dir").ConfigureAwait(false), Is.True); + + // Deleted and recreated elsewhere: same name, new NodeId. + harness.RemoveNode(dir); + NodeId recreated = harness.RegisterDirectory(harness.Root, new QualifiedName("dir")); + NodeId file = harness.RegisterFile(recreated, new QualifiedName("b.txt")); + + UaFileSystemInfo info = await client.GetInfoAsync("/dir/b.txt").ConfigureAwait(false); + Assert.That(info, Is.Not.Null); + Assert.That(info.NodeId, Is.EqualTo(file)); + } + [Test] public async Task DeleteAsyncOnNonEmptyDirectoryWithRecursiveCallsServerOnceAsync() { diff --git a/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientPathResolutionTests.cs b/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientPathResolutionTests.cs index 6648d50c4a..db0697e87a 100644 --- a/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientPathResolutionTests.cs +++ b/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientPathResolutionTests.cs @@ -255,5 +255,36 @@ public async Task ResolvedPathIsCachedAcrossLookupsAsync() // cache and not call Translate again for the leaf segment. Assert.That(translateCalls, Is.EqualTo(firstCount)); } + + [Test] + public async Task EnumeratedFullPathRoundTripsForColonAndNamespaceZeroNamesAsync() + { + // A namespace-zero child with ':' in its name inside a directory + // of a namespaced provider: its FullPath must resolve back to it. + var harness = FileSystemSessionHarness.Create(); + NodeId dir = harness.RegisterDirectory( + harness.Root, + new QualifiedName("Logs", 3), + new NodeId(7100, 3)); + NodeId file = harness.RegisterFile(dir, new QualifiedName("12:30.log")); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileSystemInfo entry = null; + await foreach (UaFileSystemInfo child in client.EnumerateAsync("/3:Logs") + .ConfigureAwait(false)) + { + entry = child; + } + Assert.That(entry, Is.Not.Null); + Assert.That(entry.FullPath, Is.EqualTo("/3:Logs/12&:30.log")); + + // Fresh client so the path cache does not mask the resolution. + var fresh = new FileSystemClient(harness.Session, harness.Root); + UaFileSystemInfo resolved = await fresh.GetInfoAsync(entry.FullPath) + .ConfigureAwait(false); + Assert.That(resolved, Is.Not.Null); + Assert.That(resolved.NodeId, Is.EqualTo(file)); + Assert.That(resolved.BrowseName, Is.EqualTo(new QualifiedName("12:30.log"))); + } } } diff --git a/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemSessionHarness.cs b/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemSessionHarness.cs index 95177a8cbe..b311c4ea0c 100644 --- a/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemSessionHarness.cs +++ b/tests/Opc.Ua.Client.Tests/FileSystem/FileSystemSessionHarness.cs @@ -254,6 +254,20 @@ public NodeId RegisterObject( return childId; } + /// + /// Removes a node from the address space, simulating another client + /// (or the server) deleting it behind the FileSystemClient's back. + /// + public void RemoveNode(NodeId nodeId) + { + Nodes.Remove(nodeId); + ChildrenOf.Remove(nodeId); + foreach (List children in ChildrenOf.Values) + { + children.Remove(nodeId); + } + } + private void RegisterNode( NodeId nodeId, QualifiedName name, @@ -290,6 +304,10 @@ public BrowsePathResult ResolveBrowsePathForTest(BrowsePath path) private BrowsePathResult ResolveBrowsePath(BrowsePath path) { NodeId current = path.StartingNode; + if (!Nodes.ContainsKey(current)) + { + return BadResult(StatusCodes.BadNodeIdUnknown); + } foreach (RelativePathElement element in path.RelativePath.Elements) { NodeId match = NodeId.Null; @@ -348,6 +366,16 @@ private BrowseResult ResolveBrowse(BrowseDescription description) { NodeId source = description.NodeId; var refs = new List(); + if (!Nodes.ContainsKey(source)) + { + // Like a real server: browsing a deleted node fails. + return new BrowseResult + { + StatusCode = StatusCodes.BadNodeIdUnknown, + ContinuationPoint = default, + References = refs.ToArrayOf() + }; + } // HasTypeDefinition browse — used by ReadTypeDefinitionAsync // to classify a single object. diff --git a/tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientTests.cs b/tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientTests.cs index 0cb0572dba..50ee35e724 100644 --- a/tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientTests.cs +++ b/tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientTests.cs @@ -93,6 +93,59 @@ public async Task GenerateForReadReturnsStreamThatClosesHandleOnDisposeAsync() Assert.That(harness.CloseCount, Is.EqualTo(1)); } + [Test] + public async Task GenerateForReadWaitsForReadTransferStateAsync() + { + using var harness = TempTransferHarness.Create(); + harness.UseCompletionStateMachine( + ObjectIds.FileTransferStateMachineType_ReadPrepare, + ObjectIds.FileTransferStateMachineType_ReadPrepare, + ObjectIds.FileTransferStateMachineType_ReadTransfer); + + UaFileStream stream = await harness.Client + .GenerateFileForReadAsync(default, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(harness.StateReads, Is.EqualTo(3), + "Read must only be possible once the state reached ReadTransfer."); + await stream.DisposeAsync().ConfigureAwait(false); + } + + [Test] + public async Task GenerateForReadKeepsWaitingWhileStateIsIdleAsync() + { + // Idle means generation has not started yet; only ReadTransfer + // makes Read possible (Part 20 4.4.3). + using var harness = TempTransferHarness.Create(); + harness.UseCompletionStateMachine( + ObjectIds.FileTransferStateMachineType_Idle, + ObjectIds.FileTransferStateMachineType_ReadPrepare, + ObjectIds.FileTransferStateMachineType_ReadTransfer); + + UaFileStream stream = await harness.Client + .GenerateFileForReadAsync(default, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(harness.StateReads, Is.EqualTo(3)); + await stream.DisposeAsync().ConfigureAwait(false); + } + + [Test] + public void GenerateForReadThrowsAndClosesHandleWhenStateMachineFails() + { + using var harness = TempTransferHarness.Create(); + harness.UseCompletionStateMachine( + ObjectIds.FileTransferStateMachineType_ReadPrepare, + ObjectIds.FileTransferStateMachineType_Error); + + Assert.That( + async () => await harness.Client + .GenerateFileForReadAsync(default, CancellationToken.None) + .ConfigureAwait(false), + Throws.TypeOf()); + Assert.That(harness.CloseCount, Is.EqualTo(1)); + } + [Test] public async Task TempStreamWrapperDisposeDoesNotCloseHandleAsync() { @@ -113,12 +166,84 @@ private sealed class TempTransferHarness : IDisposable { private readonly Dictionary> m_handlers = []; - private TempTransferHarness(TemporaryFileTransferClient client) + private TempTransferHarness( + TemporaryFileTransferClient client, + Mock sessionMock) { Client = client; + SessionMock = sessionMock; } public TemporaryFileTransferClient Client { get; } + public Mock SessionMock { get; } + public int StateReads { get; private set; } + + /// + /// Makes GenerateFileForRead return a completion state machine + /// whose CurrentState/Id reads as in + /// turn (the last one repeats). + /// + public void UseCompletionStateMachine(params NodeId[] states) + { + var stateMachine = new NodeId(500); + var stateIdNode = new NodeId(501); + m_handlers[Methods.TemporaryFileTransferType_GenerateFileForRead] = _ => + [ + new Variant(new NodeId(123)), + new Variant(7u), + new Variant(stateMachine) + ]; + SessionMock + .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, paths, _) => + { + Assert.That(paths[0].StartingNode, Is.EqualTo(stateMachine)); + return new ValueTask( + new TranslateBrowsePathsToNodeIdsResponse + { + ResponseHeader = new ResponseHeader(), + Results = new[] + { + new BrowsePathResult + { + StatusCode = StatusCodes.Good, + Targets = new[] + { + new BrowsePathTarget + { + TargetId = stateIdNode, + RemainingPathIndex = uint.MaxValue + } + }.ToArrayOf() + } + }.ToArrayOf() + }); + }); + SessionMock + .Setup(s => s.ReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, + CancellationToken>( + (_, _, _, nodes, _) => + { + Assert.That(nodes[0].NodeId, Is.EqualTo(stateIdNode)); + NodeId state = states[Math.Min(StateReads, states.Length - 1)]; + StateReads++; + return new ValueTask(new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = new[] { new DataValue(new Variant(state)) }.ToArrayOf() + }); + }); + } public int CloseAndCommitCount { get; private set; } public int CloseCount { get; private set; } @@ -160,7 +285,7 @@ public static TempTransferHarness Create() sessionMock.Object, new NodeId(1000)); - harness = new TempTransferHarness(client); + harness = new TempTransferHarness(client, sessionMock); harness.RegisterHandlers(); return harness; } diff --git a/tests/Opc.Ua.Client.Tests/FileSystem/UaPathTests.cs b/tests/Opc.Ua.Client.Tests/FileSystem/UaPathTests.cs index d14125866a..86bd61c182 100644 --- a/tests/Opc.Ua.Client.Tests/FileSystem/UaPathTests.cs +++ b/tests/Opc.Ua.Client.Tests/FileSystem/UaPathTests.cs @@ -146,6 +146,83 @@ public void FormatRoundTripsThroughParse() Assert.That(roundTripped, Is.EqualTo(original)); } + [Test] + public void FormatEscapesReservedCharactersAndRoundTrips() + { + QualifiedName[] segments = + [ + new QualifiedName("12:30.log"), + new QualifiedName("a/b"), + new QualifiedName("x&:y", 2), + new QualifiedName("a&b"), + new QualifiedName("c:d") + ]; + + string formatted = UaPath.Format(segments); + + Assert.That(formatted, Is.EqualTo("/12&:30.log/a&/b/2:x&&&:y/a&b/c&:d")); + Assert.That(UaPath.Parse(formatted), Is.EqualTo(segments)); + } + + /// + /// Review G12: Format and Parse round-trip names ending in '&' + /// (as a middle or final segment) and a final name ending in '/'. + /// + [TestCase("Q&")] + [TestCase("a&b")] + [TestCase("&")] + [TestCase("x/")] + [TestCase("12:30.log")] + [TestCase("&&/")] + [TestCase("&&")] + [TestCase("/")] + [TestCase("a&:")] + public void FormatParseRoundTripsTrickyNames(string name) + { + QualifiedName[][] paths = + [ + [new QualifiedName(name)], + [new QualifiedName(name), new QualifiedName("x")], + [new QualifiedName("x"), new QualifiedName(name)], + [new QualifiedName(name, 3), new QualifiedName(name)], + [new QualifiedName(name), new QualifiedName(name, 2)] + ]; + + foreach (QualifiedName[] segments in paths) + { + string formatted = UaPath.Format(segments); + Assert.That(UaPath.Parse(formatted), Is.EqualTo(segments), formatted); + Assert.That(UaPath.Normalize(formatted), Is.EqualTo(formatted)); + } + } + + [Test] + public void FormatEscapesTrailingAmpersandBeforeSeparator() + { + QualifiedName[] segments = [new QualifiedName("Q&"), new QualifiedName("x")]; + + Assert.That(UaPath.Format(segments), Is.EqualTo("/Q&&/x")); + Assert.That(UaPath.Parse("/Q&&/x"), Is.EqualTo(segments)); + } + + [Test] + public void ParseKeepsEscapedFinalSeparatorAndTrimsUnescapedOne() + { + Assert.That(UaPath.Parse("/x&/"), Is.EqualTo(new[] { new QualifiedName("x/") })); + Assert.That(UaPath.Parse("/x&&/"), Is.EqualTo(new[] { new QualifiedName("x&") })); + Assert.That(UaPath.Parse("/a/b/"), Is.EqualTo(new[] { new QualifiedName("a"), new QualifiedName("b") })); + // An '&' at the very end with nothing to escape stays literal. + Assert.That(UaPath.Parse("/Q&"), Is.EqualTo(new[] { new QualifiedName("Q&") })); + } + + [Test] + public void ParseTreatsAmpersandBeforeOrdinaryCharacterAsLiteral() + { + QualifiedName[] segments = UaPath.Parse("/R&D/a&b"); + Assert.That(segments[0], Is.EqualTo(new QualifiedName("R&D"))); + Assert.That(segments[1], Is.EqualTo(new QualifiedName("a&b"))); + } + [Test] public void FormatSegmentNs0OmitsPrefix() { diff --git a/tests/Opc.Ua.Client.Tests/Historian/HistoryClientUnitTests.cs b/tests/Opc.Ua.Client.Tests/Historian/HistoryClientUnitTests.cs index 3b0bd73071..f636d225f9 100644 --- a/tests/Opc.Ua.Client.Tests/Historian/HistoryClientUnitTests.cs +++ b/tests/Opc.Ua.Client.Tests/Historian/HistoryClientUnitTests.cs @@ -673,6 +673,49 @@ public async Task GetConformanceInfoAsyncFiltersHistoricalClaimsAsync() Is.EqualTo("Aggregate Average")); } + /// + /// Verifies that a null ServerProfileArray element is skipped instead of + /// throwing a NullReferenceException. + /// + [Test] + public async Task GetConformanceInfoAsyncSkipsNullProfileElementAsync() + { + var mockSession = new Mock(); + mockSession + .Setup(s => s.ReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(new ValueTask(new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = + [ + new DataValue(Variant.From( + [ + (string)null!, + "http://opcfoundation.org/UA-Profile/Server/HistoricalRawData2022" + ])), + new DataValue(Variant.From( + [ + new QualifiedName("Attribute Read") + ])) + ], + DiagnosticInfos = [] + })); + var client = new HistoryClient(mockSession.Object); + + HistoricalConformanceInfo info = + await client.GetConformanceInfoAsync().ConfigureAwait(false); + + Assert.That(info.ServerProfiles, Has.Count.EqualTo(1)); + Assert.That( + info.ServerProfiles[0], + Does.Contain("HistoricalRawData2022")); + } + /// /// Verifies that modified-history reads yield the returned data and request modified-history details. /// diff --git a/tests/Opc.Ua.Client.Tests/ModelChange/ModelChangeTrackerTests.cs b/tests/Opc.Ua.Client.Tests/ModelChange/ModelChangeTrackerTests.cs index 2c39c25760..d870a6f277 100644 --- a/tests/Opc.Ua.Client.Tests/ModelChange/ModelChangeTrackerTests.cs +++ b/tests/Opc.Ua.Client.Tests/ModelChange/ModelChangeTrackerTests.cs @@ -117,6 +117,53 @@ public async Task StartTrackingAsyncWaitsUntilEventMonitoredItemIsCreated() Assert.That(raised, Is.EqualTo(1)); } + /// + /// L9-11: the first caller's cancelled start tore down the pump that a + /// second, uncancelled caller had joined, failing that caller too. + /// + [Test] + public async Task CancelledFirstStartDoesNotFailAJoinedStartAsync() + { + var fake = new FakeStreamingSubscription(eventMonitoredItemCreated: false); + await using var tracker = new ModelChangeTracker(fake); + + using var firstCts = new CancellationTokenSource(); + Task first = tracker.StartTrackingAsync(firstCts.Token).AsTask(); + await fake.WaitForSubscribeAsync().ConfigureAwait(false); + Task second = tracker.StartTrackingAsync().AsTask(); + + firstCts.Cancel(); + Assert.That( + async () => await first.ConfigureAwait(false), + Throws.InstanceOf()); + + fake.CreateEventMonitoredItem(); + await second.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); + + Assert.That(tracker.IsTracking, Is.True); + Assert.That(fake.SubscribeCallCount, Is.EqualTo(1)); + } + + /// + /// A start abandoned by its only waiter still stops the pump. + /// + [Test] + public async Task CancelledOnlyStartStopsTrackingAsync() + { + var fake = new FakeStreamingSubscription(eventMonitoredItemCreated: false); + await using var tracker = new ModelChangeTracker(fake); + + using var cts = new CancellationTokenSource(); + Task start = tracker.StartTrackingAsync(cts.Token).AsTask(); + await fake.WaitForSubscribeAsync().ConfigureAwait(false); + + cts.Cancel(); + Assert.That( + async () => await start.ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That(tracker.IsTracking, Is.False); + } + [Test] public async Task StartTrackingAsyncIsIdempotent() { diff --git a/tests/Opc.Ua.Client.Tests/NodeCache/NodeCacheContextTests.cs b/tests/Opc.Ua.Client.Tests/NodeCache/NodeCacheContextTests.cs index e2a4aabf74..33e6f07bdb 100644 --- a/tests/Opc.Ua.Client.Tests/NodeCache/NodeCacheContextTests.cs +++ b/tests/Opc.Ua.Client.Tests/NodeCache/NodeCacheContextTests.cs @@ -728,6 +728,75 @@ public async Task FetchNodesAsyncShouldReturnErrorsForBadNodeClassTypeAsync() Assert.That(result.Errors[1].StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError)); } + /// + /// L9-7: reading with a NodeClass hint that does not match the node's + /// real NodeClass threw KeyNotFoundException out of the whole batch + /// instead of failing just that node. + /// + [Test] + public async Task FetchNodesAsyncWithMismatchedNodeClassFailsOnlyThatNodeAsync() + { + using var session = SessionMock.Create(); + var sut = new NodeCacheContext(session); + ArrayOf nodeIds = + [ + NodeId.Parse("ns=2;s=Object"), + NodeId.Parse("ns=2;s=Variable") + ]; + Node[] nodes = + [ + new ObjectNode + { + NodeId = nodeIds[0], + NodeClass = NodeClass.Object, + DisplayName = LocalizedText.From("Object"), + BrowseName = QualifiedName.From("Object") + }, + new VariableNode + { + NodeId = nodeIds[1], + NodeClass = NodeClass.Variable, + AccessLevel = 1, + DataType = NodeId.Parse("ns=2;s=TestDataType"), + DisplayName = LocalizedText.From("Variable"), + BrowseName = QualifiedName.From("Variable"), + UserAccessLevel = 1 + } + ]; + + session.Channel + .Setup(c => c.SendRequestAsync( + It.IsAny(), + It.IsAny())) + .Returns((request, ct) => + { + ArrayOf results = request.NodesToRead + .ConvertAll(r => + { + var value = new DataValue(); + (r.NodeId == nodeIds[0] ? nodes[0] : nodes[1]) + .Read(null, r.AttributeId, ref value); + return value; + }); + return new ValueTask(new ReadResponse + { + Results = results, + DiagnosticInfos = results.ConvertAll(r => new DiagnosticInfo()) + }); + }); + + ResultSet result = await sut + .FetchNodesAsync(null, nodeIds, NodeClass.Variable) + .ConfigureAwait(false); + + Assert.That(result.Errors.Count, Is.EqualTo(2)); + Assert.That( + result.Errors[0].StatusCode, + Is.EqualTo((StatusCode)StatusCodes.BadNodeClassInvalid)); + Assert.That(ServiceResult.IsGood(result.Errors[1]), Is.True); + Assert.That(result.Results[1].NodeClass, Is.EqualTo(NodeClass.Variable)); + } + [Test] public void FetchNodeAsyncShouldHandleCancellation() { diff --git a/tests/Opc.Ua.Client.Tests/NodeCache/NodeCacheTests.cs b/tests/Opc.Ua.Client.Tests/NodeCache/NodeCacheTests.cs index 24a4b7f646..6287c37c7d 100644 --- a/tests/Opc.Ua.Client.Tests/NodeCache/NodeCacheTests.cs +++ b/tests/Opc.Ua.Client.Tests/NodeCache/NodeCacheTests.cs @@ -1369,6 +1369,69 @@ public async Task GetValuesAsyncShouldHandleErrorsAsync() context.Verify(); } + /// + /// L9-8: the lookups swallowed every exception, so a cancelled lookup + /// reported "node does not exist" instead of honouring cancellation. + /// + [Test] + public void LookupsPropagateCancellation() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + var context = new Mock(); + context.Setup(c => c.NamespaceUris).Returns(new NamespaceTable()); + var nodeCache = new NodeCache(context.Object, telemetry); + var id = new NodeId("uncached", 0); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Multiple(() => + { + Assert.That( + async () => await nodeCache.FindAsync(id, cts.Token).ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That( + async () => await nodeCache.IsKnownAsync(id, cts.Token).ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That( + async () => await nodeCache.FindReferenceTypeNameAsync(id, cts.Token) + .ConfigureAwait(false), + Throws.InstanceOf()); + }); + } + + /// + /// L9-6: an empty DataValue (legal on the wire) collided with the + /// "missing slot" sentinel, so the next node's value landed in its + /// slot and the next slot stayed empty. + /// + [Test] + public async Task GetValuesAsyncKeepsEmptyDataValueInItsOwnSlotAsync() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + ArrayOf ids = [new("empty", 0), new("value", 0)]; + var context = new Mock(); + context + .Setup(c => c.FetchValuesAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new ResultSet + { + Results = [default, new(new Variant(456), StatusCodes.Good, DateTime.UtcNow)], + Errors = [ServiceResult.Good, ServiceResult.Good] + }); + var nodeCache = new NodeCache(context.Object, telemetry); + + ArrayOf result = await nodeCache.GetValuesAsync(ids, default) + .ConfigureAwait(false); + + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result[0].IsNull, Is.True, "the empty value belongs to the first node"); + Assert.That(result[1].WrappedValue, Is.EqualTo(new Variant(456))); + } + [Test] public async Task GetValuesAsyncShouldReturnValuesFromCacheAsync() { diff --git a/tests/Opc.Ua.Client.Tests/Roles/RoleManagementClientCoverageTests.cs b/tests/Opc.Ua.Client.Tests/Roles/RoleManagementClientCoverageTests.cs index e27a14f60a..ad6f878268 100644 --- a/tests/Opc.Ua.Client.Tests/Roles/RoleManagementClientCoverageTests.cs +++ b/tests/Opc.Ua.Client.Tests/Roles/RoleManagementClientCoverageTests.cs @@ -333,6 +333,210 @@ public void WritePropertyAsyncThrowsWhenWriteResultIsBad() Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); } + [Test] + public async Task ListRolesAsyncFollowsBrowseContinuationPointAsync() + { + var firstRole = new NodeId(6101u); + var secondRole = new NodeId(6102u); + var continuationPoint = ByteString.From(new byte[] { 1, 2, 3 }); + m_session.Setup(s => s.BrowseAsync( + null, null, 0, It.IsAny>(), It.IsAny())) + .ReturnsAsync(new BrowseResponse + { + Results = + [ + new BrowseResult + { + ContinuationPoint = continuationPoint, + References = [new ReferenceDescription { NodeId = firstRole }] + } + ] + }); + m_session.Setup(s => s.BrowseNextAsync( + null, false, + It.Is>(cps => cps.Count == 1), + It.IsAny())) + .ReturnsAsync(new BrowseNextResponse + { + Results = [new BrowseResult { References = [new ReferenceDescription { NodeId = secondRole }] }] + }); + m_session.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + null, It.IsAny>(), It.IsAny())) + .ReturnsAsync((RequestHeader _, ArrayOf paths, CancellationToken _) => + { + var results = new BrowsePathResult[paths.Count]; + for (int i = 0; i < results.Length; i++) + { + results[i] = new BrowsePathResult { StatusCode = StatusCodes.BadNoMatch }; + } + return new TranslateBrowsePathsToNodeIdsResponse { Results = results.ToArrayOf() }; + }); + m_session.Setup(s => s.ReadAsync( + null, 0, TimestampsToReturn.Neither, + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new ReadResponse + { + Results = [new DataValue(Variant.From(new QualifiedName("Role")))] + }); + + IReadOnlyList roles = await m_client.ListRolesAsync().ConfigureAwait(false); + + Assert.That(roles, Has.Count.EqualTo(2)); + Assert.That(roles[0].RoleId, Is.EqualTo(firstRole)); + Assert.That(roles[1].RoleId, Is.EqualTo(secondRole)); + } + + /// + /// Review G16: a server that answers every BrowseNext with an empty + /// page and a continuation point must not keep ListRolesAsync looping + /// forever; the point is released when the client gives up. + /// + [Test] + public void ListRolesAsyncStopsOnEndlessEmptyBrowseNextPages() + { + var continuationPoint = ByteString.From(new byte[] { 1, 2, 3 }); + m_session.SetupGet(s => s.MessageContext) + .Returns(ServiceMessageContext.Create(Opc.Ua.Tests.NUnitTelemetryContext.Create())); + m_session.Setup(s => s.BrowseAsync( + null, null, 0, It.IsAny>(), It.IsAny())) + .ReturnsAsync(new BrowseResponse + { + Results = [new BrowseResult { ContinuationPoint = continuationPoint }] + }); + int browseNextCalls = 0; + m_session.Setup(s => s.BrowseNextAsync( + null, false, + It.Is>(cps => cps.Count == 1), + It.IsAny())) + .ReturnsAsync(() => + { + browseNextCalls++; + return new BrowseNextResponse + { + Results = [new BrowseResult { ContinuationPoint = continuationPoint }] + }; + }); + m_session.Setup(s => s.BrowseNextAsync( + null, true, + It.Is>(cps => cps.Count == 1), + It.IsAny())) + .ReturnsAsync(new BrowseNextResponse { Results = [new BrowseResult()] }); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await m_client.ListRolesAsync().ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNoData)); + Assert.That(browseNextCalls, Is.LessThan(20)); + m_session.Verify(s => s.BrowseNextAsync( + null, true, + It.Is>(cps => cps.Count == 1), + It.IsAny()), Times.Once); + } + + /// + /// Review G16: the continuation point is released on any BrowseNext + /// failure, not only on cancellation, and the original error surfaces. + /// + [Test] + public void ListRolesAsyncReleasesContinuationPointWhenBrowseNextFails() + { + var continuationPoint = ByteString.From(new byte[] { 1, 2, 3 }); + m_session.SetupGet(s => s.MessageContext) + .Returns(ServiceMessageContext.Create(Opc.Ua.Tests.NUnitTelemetryContext.Create())); + m_session.Setup(s => s.BrowseAsync( + null, null, 0, It.IsAny>(), It.IsAny())) + .ReturnsAsync(new BrowseResponse + { + Results = + [ + new BrowseResult + { + ContinuationPoint = continuationPoint, + References = [new ReferenceDescription { NodeId = new NodeId(6101u) }] + } + ] + }); + m_session.Setup(s => s.BrowseNextAsync( + null, false, + It.Is>(cps => cps.Count == 1), + It.IsAny())) + .ThrowsAsync(new ServiceResultException(StatusCodes.BadTimeout)); + m_session.Setup(s => s.BrowseNextAsync( + null, true, + It.Is>(cps => cps.Count == 1), + It.IsAny())) + .ReturnsAsync(new BrowseNextResponse { Results = [new BrowseResult()] }); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await m_client.ListRolesAsync().ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTimeout)); + m_session.Verify(s => s.BrowseNextAsync( + null, true, + It.Is>(cps => cps.Count == 1), + It.IsAny()), Times.Once); + } + + [Test] + public void ListRolesAsyncThrowsWhenRoleSetBrowseFails() + { + m_session.Setup(s => s.BrowseAsync( + null, null, 0, It.IsAny>(), It.IsAny())) + .ReturnsAsync(new BrowseResponse + { + Results = [new BrowseResult { StatusCode = StatusCodes.BadNodeIdUnknown }] + }); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await m_client.ListRolesAsync().ConfigureAwait(false)); + + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public async Task ReadRoleAsyncKeepsValuesAlignedWhenATargetIsNotLocalAsync() + { + var roleId = new NodeId(6201u); + var applicationsId = new NodeId(7202u); + m_session.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + null, It.Is>(paths => paths.Count == 6), It.IsAny())) + .ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse + { + Results = + [ + // Identities resolves to a node in a namespace the session does not know. + new BrowsePathResult + { + StatusCode = StatusCodes.Good, + Targets = [new BrowsePathTarget { TargetId = new ExpandedNodeId(7201u, "urn:not-in-table") }] + }, + ResultFor(applicationsId), + new BrowsePathResult { StatusCode = StatusCodes.BadNoMatch }, + new BrowsePathResult { StatusCode = StatusCodes.BadNoMatch }, + new BrowsePathResult { StatusCode = StatusCodes.BadNoMatch }, + new BrowsePathResult { StatusCode = StatusCodes.BadNoMatch } + ] + }); + m_session.Setup(s => s.ReadAsync( + null, 0, TimestampsToReturn.Neither, + It.Is>(nodes => + nodes.Count == 2 && nodes[0].NodeId == roleId && nodes[1].NodeId == applicationsId), + It.IsAny())) + .ReturnsAsync(new ReadResponse + { + Results = + [ + new DataValue(Variant.From(new QualifiedName("Role"))), + new DataValue(Variant.From(s_applications.ToArrayOf())) + ] + }); + + RoleInfo role = await m_client.ReadRoleAsync(roleId).ConfigureAwait(false); + + Assert.That(role.Identities, Is.Empty); + Assert.That(role.Applications, Is.EqualTo(s_applications)); + } + private static BrowsePathResult ResultFor(NodeId nodeId) { return new BrowsePathResult diff --git a/tests/Opc.Ua.Client.Tests/Session/ClassicSubscriptionEngineTests.cs b/tests/Opc.Ua.Client.Tests/Session/ClassicSubscriptionEngineTests.cs index 3f1e2e1240..b6d3a08bba 100644 --- a/tests/Opc.Ua.Client.Tests/Session/ClassicSubscriptionEngineTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/ClassicSubscriptionEngineTests.cs @@ -123,6 +123,51 @@ public void PublishResponsePreservesServerSequenceAvailability() Assert.That(subscription.AvailableSequenceNumbers.ToArray(), Is.EquivalentTo(available.ToArray())); } + /// + /// A Republish response has no available sequence numbers. It must + /// neither wipe the subscription's list (so other missing messages are + /// still republished) nor drop pending acknowledgements (L7-1). + /// + [Test] + public void RepublishResponseKeepsAvailableSequenceNumbersAndAcknowledgements() + { + m_mockContext.Setup(c => c.ServerState).Returns(ServerState.Running); + using var subscription = new Subscription(m_telemetry); + m_mockContext.Setup(context => context.Subscriptions).Returns([subscription]); + using var engine = new ClassicSubscriptionEngine(m_mockContext.Object); + ArrayOf available = new uint[] { 7, 8, 9 }.ToArrayOf(); + + engine.ProcessPublishResponse( + new ResponseHeader { Timestamp = DateTimeUtc.Now }, + subscription.Id, + available, + false, + new NotificationMessage + { + SequenceNumber = 9, + PublishTime = DateTimeUtc.Now, + NotificationData = [new ExtensionObject(new DataChangeNotification())] + }); + engine.AddPendingAcknowledgement(subscription.Id, 30); + + engine.ProcessPublishResponse( + new ResponseHeader { Timestamp = DateTimeUtc.Now }, + subscription.Id, + default, + false, + new NotificationMessage + { + SequenceNumber = 7, + PublishTime = DateTimeUtc.Now, + NotificationData = [new ExtensionObject(new DataChangeNotification())] + }, + republished: true); + + Assert.That(subscription.AvailableSequenceNumbers.ToArray(), Is.EquivalentTo(available.ToArray())); + Assert.That(engine.RemoveAcknowledgementsForSubscription(subscription.Id), Is.EqualTo(3), + "The acks for 9 and 30 are kept and the republished 7 is added."); + } + [Test] public void StartPublishingWithNoSubscriptionsDoesNothing() { @@ -411,6 +456,50 @@ public void ProcessPublishResponseHandlesKeepAliveUnknownAndAcknowledgements() It.IsAny()), Times.Never); } + /// + /// A pending acknowledgement exactly 2^31 away from the latest + /// sequence number must not throw from Math.Abs(int.MinValue) and + /// abort the publish response (L4-1). + /// + [Test] + public void ProcessPublishResponseToleratesAcknowledgementHalfTheSequenceSpaceAway() + { + m_mockContext.Setup(c => c.ServerState).Returns(ServerState.Running); + m_mockContext.Setup(c => c.Subscriptions).Returns([]); + m_mockContext.Setup(c => c.DeleteSubscriptionsOnClose).Returns(false); + using var engine = new ClassicSubscriptionEngine(m_mockContext.Object); + engine.AddPendingAcknowledgement(7, 0x80000000u); + + Assert.That(() => engine.ProcessPublishResponse( + new ResponseHeader { Timestamp = DateTime.UtcNow }, + 7, + [], + false, + new NotificationMessage + { + SequenceNumber = 1, + PublishTime = DateTime.UtcNow, + NotificationData = [] + }), Throws.Nothing); + + Assert.That(engine.RemoveAcknowledgementsForSubscription(7), Is.Zero, + "An acknowledgement far outside the tolerance is dropped."); + } + + [TestCase(10u, 1u, true)] + [TestCase(1u, 10u, true)] + [TestCase(1u, uint.MaxValue, true)] + [TestCase(20u, 1u, false)] + [TestCase(0x80000000u, 0u, false)] + [TestCase(0u, 0x80000000u, false)] + public void OutOfOrderThresholdIsWrapAwareAndNeverThrows( + uint sequenceNumber, uint latest, bool expected) + { + Assert.That( + ClassicSubscriptionEngine.IsWithinOutOfOrderThreshold(sequenceNumber, latest), + Is.EqualTo(expected)); + } + [Test] public async Task ProcessPublishResponseDispatchesKnownSubscriptionNotificationAsync() { diff --git a/tests/Opc.Ua.Client.Tests/Session/ClientFailoverCoordinatorTests.cs b/tests/Opc.Ua.Client.Tests/Session/ClientFailoverCoordinatorTests.cs index aac2529e64..c82abb6883 100644 --- a/tests/Opc.Ua.Client.Tests/Session/ClientFailoverCoordinatorTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/ClientFailoverCoordinatorTests.cs @@ -85,6 +85,49 @@ public async Task TransferActiveSubscriptionsUsesDiagnosticsAndTransferServiceAs Assert.That(transferredIds, Is.EqualTo(new uint[] { 11, 12 })); } + [Test] + public async Task TransferActiveSubscriptionsBindsPreparedSubscriptionsThroughTheSessionAsync() + { + var sessionId = new NodeId(42); + Mock session = CreateSession("operator"); + SetupDiagnostics(session, sessionId, "active-client", [11u, 12u]); + using var prepared = new Subscription(Opc.Ua.Tests.NUnitTelemetryContext.Create()) + { + TransferId = 11u + }; + session.SetupGet(s => s.Subscriptions).Returns([prepared]); + SubscriptionCollection? transferredTemplates = null; + session.Setup(s => s.TransferSubscriptionsAsync( + It.IsAny(), true, It.IsAny())) + .Callback((templates, _, _) => + transferredTemplates = templates) + .ReturnsAsync(false); + ArrayOf rawIds = []; + session.Setup(s => s.TransferSubscriptionsAsync( + null, It.IsAny>(), true, It.IsAny())) + .Callback, bool, CancellationToken>((_, ids, _, _) => rawIds = ids) + .ReturnsAsync(new TransferSubscriptionsResponse + { + Results = [new TransferResult { StatusCode = StatusCodes.Good }], + DiagnosticInfos = [] + }); + + ArrayOf results = await new ClientFailoverCoordinator() + .TransferActiveSubscriptionsAsync( + session.Object, + new ClientRedundancyTransferOptions { ActiveSessionName = "active-client" }) + .ConfigureAwait(false); + + Assert.That(transferredTemplates, Is.Not.Null); + Assert.That(transferredTemplates!, Has.Count.EqualTo(1)); + Assert.That(transferredTemplates![0], Is.SameAs(prepared)); + Assert.That(rawIds, Is.EqualTo(new uint[] { 12 })); + Assert.That(results, Has.Count.EqualTo(2)); + // The mocked session did not bind the template, so it is reported as not transferred. + Assert.That(StatusCode.IsBad(results[0].StatusCode), Is.True); + Assert.That(results[1].StatusCode, Is.EqualTo(StatusCodes.Good)); + } + [TestCase(true)] [TestCase(false)] public async Task NameBasedTakeoverExcludesBackupSessionAsync(bool backupFirst) diff --git a/tests/Opc.Ua.Client.Tests/Session/ConnectionStateMachineTests.cs b/tests/Opc.Ua.Client.Tests/Session/ConnectionStateMachineTests.cs index f3af62b02d..35bea76153 100644 --- a/tests/Opc.Ua.Client.Tests/Session/ConnectionStateMachineTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/ConnectionStateMachineTests.cs @@ -1045,6 +1045,149 @@ await WaitForStateAsync(sm, ConnectionState.Connected) } } + /// + /// Work spawned by a worker step (keep-alive, publish loops) must not + /// pass as the worker once the step is over (L2-1). + /// + [Test] + public async Task WorkSpawnedByWorkerStepLeavesWorkerFlowAsync() + { + ConnectionStateMachine sm = CreateMachine(); + await using (sm.ConfigureAwait(false)) + { + var gate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bool inWorkerDuringConnect = false; + Task spawned = null; + sm.ConnectAsync = _ => + { + inWorkerDuringConnect = sm.IsWorkerFlow; + spawned = Task.Run(async () => + { + await gate.Task.ConfigureAwait(false); + for (int ii = 0; ii < 250 && sm.IsWorkerFlow; ii++) + { + await Task.Delay(20).ConfigureAwait(false); + } + return sm.IsWorkerFlow; + }); + return Task.FromResult(ServiceResult.Good); + }; + + sm.Start(); + sm.RequestConnect(); + await WaitForStateAsync(sm, ConnectionState.Connected) + .ConfigureAwait(false); + gate.SetResult(true); + + Assert.That(inWorkerDuringConnect, Is.True); + Assert.That(await spawned.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), Is.False, + "Spawned background work must not be treated as the worker after the step ended."); + } + } + + /// + /// A negative reconnect budget is rejected at construction instead of + /// crashing the worker on the first reconnect (L3-7). + /// + [Test] + public void NegativeMaxTotalReconnectTimeIsRejected() + { + Assert.That( + () => new ConnectionStateMachine( + new ReconnectPolicy(), + m_logger, + TimeSpan.FromSeconds(-5)), + Throws.TypeOf()); + } + + /// + /// A zero reconnect budget means unlimited and still reconnects (L3-7). + /// + [Test] + public async Task ZeroMaxTotalReconnectTimeStillReconnectsAsync() + { + var sm = new ConnectionStateMachine( + new ReconnectPolicy + { + JitterFactor = 0.0, + Strategy = BackoffStrategy.Constant, + InitialDelay = TimeSpan.FromMilliseconds(10) + }, + m_logger, + TimeSpan.Zero); + await using (sm.ConfigureAwait(false)) + { + int reconnects = 0; + sm.ConnectAsync = _ => Task.FromResult(ServiceResult.Good); + sm.ReconnectAsync = _ => + { + Interlocked.Increment(ref reconnects); + return Task.FromResult(ServiceResult.Good); + }; + StateTransitionRecorder recorder = AttachRecorder(sm); + + sm.Start(); + sm.RequestConnect(); + await WaitForStateAsync(sm, ConnectionState.Connected) + .ConfigureAwait(false); + + sm.TriggerReconnect(); + await WaitForStateAsync(sm, ConnectionState.Connected) + .ConfigureAwait(false); + + Assert.That(Volatile.Read(ref reconnects), Is.EqualTo(1)); + Assert.That(recorder.HasVisited(ConnectionState.Failover), Is.False); + } + } + + /// + /// A close requested while the reconnect policy runs out must not be + /// overwritten by the transition to Failover (L3-4). + /// + [Test] + public async Task CloseRacingPolicyExhaustionIsNotLostAsync() + { + ConnectionStateMachine sm = null; + var mockPolicy = new Mock(); + mockPolicy.Setup(p => p.GetNextDelay( + It.IsAny(), + It.IsAny())) + .Returns(() => + { + // Close arrives right before the policy reports exhaustion. + sm.RequestClose(); + return (TimeSpan?)null; + }); + + sm = new ConnectionStateMachine(mockPolicy.Object, m_logger); + await using (sm.ConfigureAwait(false)) + { + int closeCalls = 0; + sm.ConnectAsync = _ => Task.FromResult(ServiceResult.Good); + sm.CloseSessionAsync = _ => + { + Interlocked.Increment(ref closeCalls); + return Task.CompletedTask; + }; + StateTransitionRecorder recorder = AttachRecorder(sm); + + sm.Start(); + sm.RequestConnect(); + await WaitForStateAsync(sm, ConnectionState.Connected) + .ConfigureAwait(false); + + sm.TriggerReconnect(); + + await sm.WaitForClosedAsync(CancellationToken.None).AsTask() + .WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Assert.That(recorder.HasVisited(ConnectionState.Failover), Is.False); + Assert.That(Volatile.Read(ref closeCalls), Is.EqualTo(1), + "The clean close must run when the close raced the policy exhaustion."); + } + } + [Test] public async Task WaitForConnectedAsyncCompletesAfterReconnect() { diff --git a/tests/Opc.Ua.Client.Tests/Session/ManagedSessionReconnectTests.cs b/tests/Opc.Ua.Client.Tests/Session/ManagedSessionReconnectTests.cs index 73721bc3da..477487af2b 100644 --- a/tests/Opc.Ua.Client.Tests/Session/ManagedSessionReconnectTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/ManagedSessionReconnectTests.cs @@ -262,6 +262,9 @@ await WaitForPhaseAsync(harness.OuterRecoveryCompleted, "outer recovery and subs .ConfigureAwait(false); Assert.That(harness.Lease, stallActivation ? Is.Not.SameAs(originalLease) : Is.SameAs(originalLease)); Assert.That(harness.Lease.EndpointDescription.EndpointUrl, Is.EqualTo(expectedEndpoint)); + // L2-6: ConfiguredEndpoint must follow the failover like Endpoint. + Assert.That(harness.Session.Endpoint.EndpointUrl, Is.EqualTo(expectedEndpoint)); + Assert.That(harness.Session.ConfiguredEndpoint.Description.EndpointUrl, Is.EqualTo(expectedEndpoint)); await AssertRecreatedSubscriptionAsync(harness, live, 2, ackTimer, publishBackoff) .ConfigureAwait(false); Assert.That(harness.Requests.ToList().OfType().Count(), Is.EqualTo(2)); @@ -551,6 +554,150 @@ await WaitForPhaseAsync( Throws.InstanceOf()); } + /// + /// L1-6: a second completion of the same pending subscription recovery (e.g. the channel manager's + /// CompleteRecoveryAsync racing the managed session's) must wait for the running one instead of + /// recreating the subscriptions a second time. + /// + [Test] + public async Task ConcurrentRecoveryCompletionRecreatesSubscriptionsOnceAsync() + { + await using var harness = new ManagedSessionReconnectHarness(TimeSpan.Zero) + { + HoldSubscriptionRestoration = true + }; + await harness.ConnectAsync(false).ConfigureAwait(false); + harness.AddSubscription(); + await WaitForPhaseAsync(harness.NextMonitoredItemsAsync().AsTask(), "initial monitored item") + .ConfigureAwait(false); + harness.TransportReconnect.Release(); + harness.RecoveryActivation.Release(); + harness.ReplacementSession.Release(); + + Task recovery = harness.StartOuterRecoveryAsync(); + await WaitForPhaseAsync( + harness.ReplacementSubscription.Entered, "outer-owned deferred subscription restoration") + .ConfigureAwait(false); + + Task concurrent = harness.Session.InnerSession.CompleteSessionRecoveryAsync(harness.CancellationToken); + await Task.Delay(100).ConfigureAwait(false); + Assert.That(concurrent.IsCompleted, Is.False, "the second completion must wait for the running one"); + Assert.That(harness.Requests.ToList().OfType().Count(), Is.EqualTo(2)); + + harness.ReplacementSubscription.Release(); + await WaitForPhaseAsync(recovery, "outer recovery").ConfigureAwait(false); + await WaitForPhaseAsync(concurrent, "concurrent completion").ConfigureAwait(false); + Assert.That(harness.Requests.ToList().OfType().Count(), Is.EqualTo(2)); + } + + /// + /// L1-2: an identity update holds the session's reconnect lock while its ActivateSession waits for the + /// shared channel to become Ready; the channel only becomes Ready after this session's reconnect callback + /// returned, which waited for the same lock. The callback must interrupt the update instead. + /// + [Test] + public async Task ChannelRecoveryInterruptsIdentityUpdateHoldingReconnectLockAsync() + { + await using var harness = new ManagedSessionReconnectHarness(TimeSpan.Zero) + { + RecoveryActivationStatus = StatusCodes.Good + }; + await harness.ConnectAsync(false).ConfigureAwait(false); + Task recovery = harness.StartRecoveryAsync(); + await WaitForPhaseAsync(harness.TransportReconnect.Entered, "held transport reconnect") + .ConfigureAwait(false); + + Task update = harness.Session.InnerSession + .UpdateIdentityAsync(new Opc.Ua.Identity.AnonymousIdentityProvider(), ct: harness.CancellationToken) + .AsTask(); + await Task.Delay(200).ConfigureAwait(false); + Assert.That(update.IsCompleted, Is.False, "the update must wait for the recovering channel"); + + harness.TransportReconnect.Release(); + harness.RecoveryActivation.Release(); + await WaitForPhaseAsync(recovery, "channel recovery while an identity update holds the lock") + .ConfigureAwait(false); + Assert.That(harness.Lease.State, Is.EqualTo(ChannelState.Ready)); + ServiceResultException ex = Assert.ThrowsAsync( + async () => await update.WaitAsync(PhaseTimeout).ConfigureAwait(false)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelClosed)); + Assert.That(harness.Session.InnerSession.Reconnecting, Is.False); + } + + /// + /// G9 (review of L1-2): only the operation holding the reconnect lock was interrupted. A second identity + /// update queued on the lock took it next, ahead of the waiting reconnect callback, and re-created the + /// deadlock (broken only by the participant timeout, infinite here). + /// + [Test] + public async Task ChannelRecoveryInterruptsIdentityUpdateQueuedOnReconnectLockAsync() + { + await using var harness = new ManagedSessionReconnectHarness(TimeSpan.Zero) + { + RecoveryActivationStatus = StatusCodes.Good + }; + await harness.ConnectAsync(false).ConfigureAwait(false); + Task recovery = harness.StartRecoveryAsync(); + await WaitForPhaseAsync(harness.TransportReconnect.Entered, "held transport reconnect") + .ConfigureAwait(false); + + Task holding = harness.Session.InnerSession + .UpdateIdentityAsync(new Opc.Ua.Identity.AnonymousIdentityProvider(), ct: harness.CancellationToken) + .AsTask(); + await Task.Delay(200).ConfigureAwait(false); + Task queued = harness.Session.InnerSession + .UpdateIdentityAsync(new Opc.Ua.Identity.AnonymousIdentityProvider(), ct: harness.CancellationToken) + .AsTask(); + await Task.Delay(200).ConfigureAwait(false); + Assert.That(holding.IsCompleted, Is.False, "the first update must wait for the recovering channel"); + Assert.That(queued.IsCompleted, Is.False, "the second update must wait for the reconnect lock"); + + harness.TransportReconnect.Release(); + harness.RecoveryActivation.Release(); + await WaitForPhaseAsync(recovery, "channel recovery while identity updates hold and queue on the lock") + .ConfigureAwait(false); + Assert.That(harness.Lease.State, Is.EqualTo(ChannelState.Ready)); + foreach (Task update in new[] { holding, queued }) + { + ServiceResultException ex = Assert.ThrowsAsync( + async () => await update.WaitAsync(PhaseTimeout).ConfigureAwait(false)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelClosed)); + } + Assert.That(harness.Session.InnerSession.Reconnecting, Is.False); + + // The interruption ends with the recovery: a later update goes through. + await harness.Session.InnerSession + .UpdateIdentityAsync(new Opc.Ua.Identity.AnonymousIdentityProvider(), ct: harness.CancellationToken) + .AsTask().WaitAsync(PhaseTimeout).ConfigureAwait(false); + } + + /// + /// L2-4: a channel reconnect tracked on a lease that is then swapped out (in-place recreate) was never + /// cleared, because the old lease's Ready/Faulted is no longer observed; every later bad keep-alive was + /// suppressed. Rebinding must re-derive the tracking from the new lease. + /// + [Test] + public async Task LeaseSwapClearsStaleChannelReconnectTrackingAsync() + { + await using var harness = new ManagedSessionReconnectHarness(TimeSpan.Zero); + await harness.ConnectAsync(false).ConfigureAwait(false); + Assert.That(harness.Lease.State, Is.EqualTo(ChannelState.Ready)); + Type type = harness.Session.GetType(); + System.Reflection.FieldInfo counter = type.GetField( + "m_channelReconnectInProgress", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + counter.SetValue(harness.Session, 1); + + var previousLease = new Mock(); + type.GetMethod( + "RebindManagedChannelEvents", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .Invoke(harness.Session, [harness.Session.InnerSession, previousLease.Object]); + + Assert.That(counter.GetValue(harness.Session), Is.Zero, + "the new lease is Ready, so keep-alive failures must no longer be suppressed"); + } + /// /// A completed session cannot take over recovery while another shared-channel participant still restores. /// diff --git a/tests/Opc.Ua.Client.Tests/Session/ManagedSessionTests.cs b/tests/Opc.Ua.Client.Tests/Session/ManagedSessionTests.cs index 72353fcde0..5848906cea 100644 --- a/tests/Opc.Ua.Client.Tests/Session/ManagedSessionTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/ManagedSessionTests.cs @@ -31,6 +31,7 @@ // making CA2000 noisy without a real leak risk. Disabled file-level for the suite. using System; using System.Collections.Generic; +using System.Diagnostics; using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -1361,6 +1362,60 @@ public async Task CloseAsyncWithCloseChannelFalseKeepsTransportChannelAsync() channel.Verify(c => c.Dispose(), Times.Never); } + /// + /// G6 (review of L1-3): disposing or closing a managed session from a + /// Notification handler of its inner session made the state machine + /// worker dispose the inner session, which waited the full drain + /// timeout (30 s) for the very handler that waited for the close. + /// + [TestCase(true)] + [TestCase(false)] + public async Task CloseFromInnerNotificationHandlerDoesNotWaitForItsDrainAsync(bool dispose) + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + ApplicationConfiguration configuration = CreateClientConfiguration(telemetry); + ConfiguredEndpoint endpoint = CreateEndpoint(); + + var channel = new Mock(); + channel.SetupGet(c => c.MessageContext).Returns( + configuration.CreateMessageContext()); + + var innerSession = new BlockingCloseSession( + channel.Object, + configuration, + endpoint); + + using Client.ManagedSession managedSession = CreateManagedSessionWithInner( + configuration, + endpoint, + innerSession, + telemetry); + managedSession.StateMachine.Start(); + + var closed = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Assert.That(innerSession.RunBackgroundWork("Notification", () => + { + var watch = Stopwatch.StartNew(); + if (dispose) + { + managedSession.Dispose(); + } + else + { + managedSession.CloseAsync(0, closeChannel: true) + .GetAwaiter().GetResult(); + } + closed.SetResult(watch.Elapsed); + }), Is.True); + + TimeSpan elapsed = await closed.Task + .WaitAsync(TimeSpan.FromSeconds(60)) + .ConfigureAwait(false); + Assert.That(elapsed, Is.LessThan(TimeSpan.FromSeconds(8))); + Assert.That(innerSession.Disposed, Is.True); + } + [Test] public async Task CreateAsyncLogsDisposeFailureAndPreservesCancellationAsync() { diff --git a/tests/Opc.Ua.Client.Tests/Session/ProactiveRefreshTests.cs b/tests/Opc.Ua.Client.Tests/Session/ProactiveRefreshTests.cs index 3e90f69c3e..1b596afc6c 100644 --- a/tests/Opc.Ua.Client.Tests/Session/ProactiveRefreshTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/ProactiveRefreshTests.cs @@ -117,6 +117,73 @@ public async Task RefreshFailureKeepsSessionAliveAndRetries() } } + /// + /// L2-2: a token that expires more than ~49.7 days out made the + /// refresh timer throw (due time above the timer limit), so the loop + /// failed and "refreshed" every backoff interval. The loop must wait + /// in chunks and refresh only shortly before the real expiry. + /// + [Test] + public async Task ProactiveRefreshWaitsForExpiryBeyondTimerLimit() + { + var timeProvider = new FakeTimeProvider(); + var provider = new RefreshingUserNameProvider(timeProvider, TimeSpan.FromDays(100)); + ManagedSessionClass session = await CreateManagedSessionAsync(provider, timeProvider) + .ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + Assert.That(provider.CallCount, Is.EqualTo(1)); + + // Past the first timer chunk (~49.7 days) and a whole + // backoff window: nothing may be refreshed yet. + timeProvider.Advance(TimeSpan.FromDays(50)); + await Task.Delay(200).ConfigureAwait(false); + timeProvider.Advance(TimeSpan.FromMinutes(2)); + await Task.Delay(200).ConfigureAwait(false); + Assert.That(provider.CallCount, Is.EqualTo(1)); + + // Past the second chunk; the remaining (short) delay timer is + // created asynchronously, so keep nudging the clock. + timeProvider.Advance(TimeSpan.FromDays(50)); + for (int ii = 0; ii < 100 && provider.CallCount < 2; ii++) + { + await Task.Delay(20).ConfigureAwait(false); + timeProvider.Advance(TimeSpan.FromSeconds(1)); + } + Assert.That(provider.CallCount, Is.EqualTo(2)); + await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); + } + } + + /// + /// L2-7: an explicit UpdateSessionAsync(identity) on a provider-backed + /// session was silently reverted by the next proactive refresh, which + /// re-activated the provider's identity. + /// + [Test] + public async Task ExplicitIdentityUpdateStopsProactiveRefresh() + { + var timeProvider = new FakeTimeProvider(); + var provider = new RefreshingUserNameProvider(timeProvider); + ManagedSessionClass session = await CreateManagedSessionAsync(provider, timeProvider) + .ConfigureAwait(false); + await using (session.ConfigureAwait(false)) + { + Assert.That(provider.CallCount, Is.EqualTo(1)); + + var explicitIdentity = new UserIdentity("user1", "password"u8); + await session.UpdateSessionAsync(explicitIdentity, default).ConfigureAwait(false); + + timeProvider.Advance(TimeSpan.FromMinutes(3)); + await Task.Delay(300).ConfigureAwait(false); + + Assert.That(provider.CallCount, Is.EqualTo(1), + "the refresh loop must not re-activate the provider identity"); + Assert.That(session.Connected, Is.True); + await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); + } + } + private async Task CreateManagedSessionAsync( IClientIdentityProvider provider, TimeProvider timeProvider) @@ -167,11 +234,18 @@ private static async Task WaitUntilAsync(Func predicate) private sealed class RefreshingUserNameProvider : IClientIdentityProvider { private readonly TimeProvider m_timeProvider; + private readonly TimeSpan m_lifetime; public RefreshingUserNameProvider(TimeProvider timeProvider) + : this(timeProvider, TimeSpan.FromMinutes(2)) + { + } + + public RefreshingUserNameProvider(TimeProvider timeProvider, TimeSpan lifetime) { m_timeProvider = timeProvider; - ExpiresAt = m_timeProvider.GetUtcNow().UtcDateTime.AddMinutes(2); + m_lifetime = lifetime; + ExpiresAt = m_timeProvider.GetUtcNow().UtcDateTime + m_lifetime; } public int CallCount { get; private set; } @@ -209,7 +283,7 @@ public ValueTask GetIdentityAsync( "Synthetic refresh failure."); } - ExpiresAt = m_timeProvider.GetUtcNow().UtcDateTime.AddMinutes(2); + ExpiresAt = m_timeProvider.GetUtcNow().UtcDateTime + m_lifetime; var identity = new UserIdentity("user1", "password"u8) { PolicyId = policy.PolicyId @@ -256,6 +330,11 @@ public override ITimer CreateTimer( TimeSpan dueTime, TimeSpan period) { + // Mirror the system timer limit (dueTime <= 0xFFFFFFFE ms). + if (dueTime > TimeSpan.FromMilliseconds(uint.MaxValue - 1)) + { + throw new ArgumentOutOfRangeException(nameof(dueTime)); + } var timer = new FakeTimer(this, callback, state, dueTime, period); lock (m_timers) { diff --git a/tests/Opc.Ua.Client.Tests/Session/ServerRedundancyHandlerTests.cs b/tests/Opc.Ua.Client.Tests/Session/ServerRedundancyHandlerTests.cs index 64edc21944..e93476e034 100644 --- a/tests/Opc.Ua.Client.Tests/Session/ServerRedundancyHandlerTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/ServerRedundancyHandlerTests.cs @@ -469,6 +469,66 @@ public void HealthyServerDoesNotProactivelyFailOverButRetainsRecoveryTarget() Is.EqualTo("urn:backup")); } + /// + /// Verifies that an interface-typed call (the extension method) applies the + /// same health checks as the concrete handler. + /// + [Test] + public void InterfaceShouldFailoverDoesNotFailOverFromHealthyServer() + { + var info = new ServerRedundancyInfo + { + Mode = RedundancySupport.Hot, + ServiceLevel = ServiceLevels.HealthyMinimum, + ServiceLevelAccessible = true, + ServiceLevelSubrange = ServiceLevelSubrange.Healthy, + RedundantServers = + [ + CreateServerInfo("urn:backup", ServiceLevels.Maximum, ServerState.Running) + ] + }; + IServerRedundancyHandler handler = m_handler; + + ServerFailoverDecision decision = handler.ShouldFailover( + info, CreateCurrentEndpoint("urn:current")); + + Assert.That(decision.IsFailoverWarranted, Is.False); + } + + /// + /// Verifies that the extension fallback for a custom handler does not fail + /// over from a Healthy server even when a target is available. + /// + [Test] + public void CustomHandlerShouldFailoverDoesNotFailOverFromHealthyServer() + { + var info = new ServerRedundancyInfo + { + Mode = RedundancySupport.Hot, + ServiceLevel = ServiceLevels.Maximum, + ServiceLevelAccessible = true, + ServiceLevelSubrange = ServiceLevelSubrange.Healthy + }; + var custom = new Mock(); + custom.Setup(h => h.SelectFailoverTarget(It.IsAny(), It.IsAny())) + .Returns(CreateCurrentEndpoint("urn:backup")); + + ServerFailoverDecision healthy = custom.Object.ShouldFailover( + info, CreateCurrentEndpoint("urn:current")); + ServerFailoverDecision degraded = custom.Object.ShouldFailover( + new ServerRedundancyInfo + { + Mode = RedundancySupport.Hot, + ServiceLevel = ServiceLevels.DegradedMaximum, + ServiceLevelAccessible = true, + ServiceLevelSubrange = ServiceLevelSubrange.Degraded + }, + CreateCurrentEndpoint("urn:current")); + + Assert.That(healthy.IsFailoverWarranted, Is.False); + Assert.That(degraded.IsFailoverWarranted, Is.True); + } + /// /// Verifies that a degraded server fails over to a healthy peer. /// diff --git a/tests/Opc.Ua.Client.Tests/Session/SessionClientBatchTests.cs b/tests/Opc.Ua.Client.Tests/Session/SessionClientBatchTests.cs index 9326437873..0c2bf2015d 100644 --- a/tests/Opc.Ua.Client.Tests/Session/SessionClientBatchTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/SessionClientBatchTests.cs @@ -584,6 +584,152 @@ public async Task BrowseAsyncShouldBatchRequestsWhenExceedingOperationLimitsAsyn Times.Exactly(2)); } + /// + /// When a later batch fails, the continuation points returned by the + /// completed batches are released before the error propagates (L3-2). + /// + [Test] + public void BrowseAsyncReleasesContinuationPointsOfCompletedBatchesOnFailure() + { + var nodesToBrowse = Enumerable.Repeat(new BrowseDescription(), 15).ToArrayOf(); + using var sessionMock = SessionMock.Create(); + sessionMock.OperationLimits.MaxNodesPerBrowse = 10; + + var firstBatch = Enumerable.Range(0, 10).Select(_ => new BrowseResult()).ToList(); + firstBatch[3].ContinuationPoint = ByteString.From([1, 2, 3]); + sessionMock.Channel + .SetupSequence(c => c.SendRequestAsync( + It.Is(r => r is BrowseRequest), + It.IsAny())) + .ReturnsAsync(new BrowseResponse { Results = firstBatch.ToArrayOf() }) + .ThrowsAsync(new ServiceResultException(StatusCodes.BadTimeout)); + + var released = new List(); + sessionMock.Channel + .Setup(c => c.SendRequestAsync( + It.Is(r => r is BrowseNextRequest), + It.IsAny())) + .Returns((r, _) => + { + var request = (BrowseNextRequest)r; + released.Add(request); + return new ValueTask(new BrowseNextResponse + { + Results = Enumerable.Repeat(new BrowseResult(), request.ContinuationPoints.Count) + .ToArrayOf() + }); + }); + + ServiceResultException sre = Assert.ThrowsAsync( + async () => await sessionMock.BrowseAsync( + null, + new ViewDescription(), + 1, + nodesToBrowse, + CancellationToken.None).ConfigureAwait(false)); + + Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadTimeout)); + Assert.That(released, Has.Count.EqualTo(1)); + Assert.That(released[0].ReleaseContinuationPoints, Is.True); + Assert.That(released[0].ContinuationPoints.Count, Is.EqualTo(1)); + Assert.That(released[0].ContinuationPoints[0], Is.EqualTo(ByteString.From([1, 2, 3]))); + } + + /// + /// Monitored items created by completed batches are deleted when a later + /// batch fails (L3-2). + /// + [Test] + public void CreateMonitoredItemsAsyncDeletesItemsOfCompletedBatchesOnFailure() + { + var itemsToCreate = Enumerable.Repeat(new MonitoredItemCreateRequest(), 15).ToArrayOf(); + using var sessionMock = SessionMock.Create(); + sessionMock.OperationLimits.MaxMonitoredItemsPerCall = 10; + + ArrayOf firstBatch = Enumerable.Range(1, 10) + .Select(ii => new MonitoredItemCreateResult + { + StatusCode = ii == 5 ? StatusCodes.BadNodeIdUnknown : StatusCodes.Good, + MonitoredItemId = (uint)ii + }) + .ToArrayOf(); + sessionMock.Channel + .SetupSequence(c => c.SendRequestAsync( + It.Is(r => r is CreateMonitoredItemsRequest), + It.IsAny())) + .ReturnsAsync(new CreateMonitoredItemsResponse { Results = firstBatch }) + .ThrowsAsync(new ServiceResultException(StatusCodes.BadTooManyOperations)); + + var deleted = new List(); + sessionMock.Channel + .Setup(c => c.SendRequestAsync( + It.Is(r => r is DeleteMonitoredItemsRequest), + It.IsAny())) + .Returns((r, _) => + { + var request = (DeleteMonitoredItemsRequest)r; + deleted.Add(request); + return new ValueTask(new DeleteMonitoredItemsResponse + { + Results = Enumerable.Repeat((StatusCode)StatusCodes.Good, request.MonitoredItemIds.Count) + .ToArrayOf() + }); + }); + + Assert.ThrowsAsync( + async () => await sessionMock.CreateMonitoredItemsAsync( + null, + 42, + TimestampsToReturn.Both, + itemsToCreate, + CancellationToken.None).ConfigureAwait(false)); + + Assert.That(deleted, Has.Count.EqualTo(1)); + Assert.That(deleted[0].SubscriptionId, Is.EqualTo(42u)); + Assert.That(deleted[0].MonitoredItemIds.ToArray(), + Is.EqualTo(new uint[] { 1, 2, 3, 4, 6, 7, 8, 9, 10 })); + } + + /// + /// Padding missing diagnostics must not skip rebasing the string table + /// indexes of the batch diagnostics (L3-3). + /// + [Test] + public void AddResponsesRebasesStringTableIndexesWhenPadding() + { + System.Reflection.MethodInfo addResponses = typeof(SessionClientBatched) + .GetMethod( + "AddResponses", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static) + .MakeGenericMethod(typeof(StatusCode)); + + // An earlier batch (or the other list of SetTriggering) already filled + // the shared string table, but returned no diagnostics for this list. + var results = new List { StatusCodes.Good }; + var diagnosticInfos = new List(); + var stringTable = new List { "a", "b" }; + var batchDiagnostic = new DiagnosticInfo { SymbolicId = 0, LocalizedText = 1 }; + string[] batchStrings = ["c", "d"]; + string[] expectedStrings = ["a", "b", "c", "d"]; + object[] args = + [ + results, + diagnosticInfos, + stringTable, + new StatusCode[] { StatusCodes.BadNodeIdUnknown }.ToArrayOf(), + new[] { batchDiagnostic }.ToArrayOf(), + batchStrings.ToArrayOf() + ]; + + addResponses.Invoke(null, args); + + Assert.That(diagnosticInfos, Has.Count.EqualTo(2)); + Assert.That(diagnosticInfos[0], Is.Null); + Assert.That(stringTable, Is.EqualTo(expectedStrings)); + Assert.That(batchDiagnostic.SymbolicId, Is.EqualTo(2)); + Assert.That(batchDiagnostic.LocalizedText, Is.EqualTo(3)); + } + [Theory] public async Task BrowseAsyncShouldContainTraceContextInRequestHeaderAsync( RequestHeader requestHeader) @@ -1985,9 +2131,16 @@ public void CreateMonitoredItemsAsyncShouldHandleBatchingWhenSecondOperationFail sessionMock.Channel .Verify(c => c.SendRequestAsync( - It.IsAny(), + It.Is(r => r is CreateMonitoredItemsRequest), It.IsAny()), Times.Exactly(2)); + + // The items created by the first batch are deleted again (L3-2). + sessionMock.Channel + .Verify(c => c.SendRequestAsync( + It.Is(r => r is DeleteMonitoredItemsRequest), + It.IsAny()), + Times.Once); } [Theory] diff --git a/tests/Opc.Ua.Client.Tests/Session/SessionClientExtensionsTests.cs b/tests/Opc.Ua.Client.Tests/Session/SessionClientExtensionsTests.cs index be36ae6a10..4c542711b8 100644 --- a/tests/Opc.Ua.Client.Tests/Session/SessionClientExtensionsTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/SessionClientExtensionsTests.cs @@ -249,6 +249,31 @@ public async Task ReadBytesAsyncWithSmallChunkReturnsChunkAsync() Assert.That(result.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 })); } + [Test] + public void ReadBytesAsyncWithChunkLongerThanRangeThrows() + { + // A server ignoring the IndexRange returns the whole value on every + // read; the client must not loop appending it forever (L3-5). + int reads = 0; + using ISession session = CreateSession( + readResponseFactory: _ => + { + reads++; + return new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new DataValue(new Variant(ByteString.From(new byte[12])))], + DiagnosticInfos = [] + }; + }); + + ServiceResultException sre = Assert.ThrowsAsync( + async () => await session.ReadBytesAsync(new NodeId(1, 0), 10).ConfigureAwait(false)); + + Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadUnknownResponse)); + Assert.That(reads, Is.EqualTo(1)); + } + [Test] public async Task ReadBytesAsyncWithChunkedResponseConcatenatesChunksAsync() { diff --git a/tests/Opc.Ua.Client.Tests/Session/SessionExtensionsTests.cs b/tests/Opc.Ua.Client.Tests/Session/SessionExtensionsTests.cs index 0c5019e7d7..0163905428 100644 --- a/tests/Opc.Ua.Client.Tests/Session/SessionExtensionsTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/SessionExtensionsTests.cs @@ -27,10 +27,12 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Moq; using NUnit.Framework; +using Opc.Ua.Tests; namespace Opc.Ua.Client.Tests { @@ -251,12 +253,13 @@ public void SaveWithSubscriptionsForwardsToStreamSave() } [Test] - public void ReadByteStringInChunksAsyncThrowsWhenMaxByteStringLengthTooSmall() + public void ReadByteStringInChunksAsyncThrowsWhenNoLimitIsKnown() { var capabilities = new ServerCapabilities { MaxByteStringLength = 0 }; m_session .SetupGet(s => s.ServerCapabilities) .Returns(capabilities); + SetupMessageContext(maxByteStringLength: 0); Assert.ThrowsAsync(async () => await m_session.Object.ReadByteStringInChunksAsync(new NodeId(1)).ConfigureAwait(false)); @@ -269,9 +272,63 @@ public void ReadByteStringInChunksAsyncThrowsWhenMaxByteStringLengthIsOne() m_session .SetupGet(s => s.ServerCapabilities) .Returns(capabilities); + SetupMessageContext(maxByteStringLength: 1024); Assert.ThrowsAsync(async () => await m_session.Object.ReadByteStringInChunksAsync(new NodeId(1)).ConfigureAwait(false)); } + + /// + /// A server that does not provide MaxByteStringLength (0) or reports a + /// value beyond int range is read in chunks of the client limit (L3-6). + /// + [TestCase(0u)] + [TestCase(3_000_000_000u)] + public async Task ReadByteStringInChunksAsyncUsesClientLimitWhenServerLimitUnknownAsync( + uint serverLimit) + { + var capabilities = new ServerCapabilities { MaxByteStringLength = serverLimit }; + m_session + .SetupGet(s => s.ServerCapabilities) + .Returns(capabilities); + SetupMessageContext(maxByteStringLength: 4); + var ranges = new List(); + m_session + .Setup(s => s.ReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, _, _, ids, _) => + { + ranges.Add(ids[0].IndexRange); + byte[] chunk = ranges.Count == 1 ? [1, 2, 3, 4] : [5]; + return new ValueTask(new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new DataValue(new Variant(ByteString.From(chunk)))], + DiagnosticInfos = [] + }); + }); + + ByteString result = await m_session.Object + .ReadByteStringInChunksAsync(new NodeId(1)) + .ConfigureAwait(false); + + Assert.That(result.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3, 4, 5 })); + string[] expectedRanges = ["0:3", "4:7"]; + Assert.That(ranges, Is.EqualTo(expectedRanges)); + } + + private void SetupMessageContext(int maxByteStringLength) + { + var messageContext = ServiceMessageContext.Create(NUnitTelemetryContext.Create()); + messageContext.MaxByteStringLength = maxByteStringLength; + m_session + .SetupGet(s => s.MessageContext) + .Returns(messageContext); + } } } diff --git a/tests/Opc.Ua.Client.Tests/Session/SessionReconnectHandlerTests.cs b/tests/Opc.Ua.Client.Tests/Session/SessionReconnectHandlerTests.cs index 636bc05203..588c3820b1 100644 --- a/tests/Opc.Ua.Client.Tests/Session/SessionReconnectHandlerTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/SessionReconnectHandlerTests.cs @@ -313,6 +313,124 @@ public async Task DoReconnectAsync_WhenEndpointUpdateSucceedsAfterFallback_Sessi Assert.That(handler.Session, Is.SameAs(mockNewSession.Object)); } + /// + /// The fallback must never downgrade a secured endpoint, even if the + /// (unauthenticated) discovery reply advertises a weaker endpoint with a + /// high SecurityLevel. + /// + [Test] + public void SelectFallbackEndpoint_OnlyWeakerEndpoints_ReturnsNull() + { + var original = new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256 + }; + ArrayOf discovered = + [ + new EndpointDescription + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + SecurityLevel = 255 + }, + new EndpointDescription + { + SecurityMode = MessageSecurityMode.Sign, + SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss, + SecurityLevel = 255 + }, + new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic128Rsa15, + SecurityLevel = 255 + } + ]; + + EndpointDescription fallback = SessionReconnectHandler.SelectFallbackEndpoint( + original, + discovered, + m_telemetry.CreateLogger()); + + Assert.That(fallback, Is.Null); + } + + /// + /// The fallback picks the strongest endpoint by the locally computed + /// security level, ignoring the server advertised SecurityLevel. + /// + [Test] + public void SelectFallbackEndpoint_PicksStrongestByLocalSecurityLevel() + { + var original = new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256 + }; + var weakerAdvertisedHigh = new EndpointDescription + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None, + SecurityLevel = 255 + }; + var aes128 = new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep, + SecurityLevel = 200 + }; + var aes256 = new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss, + SecurityLevel = 1 + }; + + EndpointDescription fallback = SessionReconnectHandler.SelectFallbackEndpoint( + original, + [weakerAdvertisedHigh, aes128, aes256], + m_telemetry.CreateLogger()); + + Assert.That(fallback, Is.SameAs(aes256)); + } + + /// + /// Even an unsecured original endpoint only falls back to a secured one. + /// + [Test] + public void SelectFallbackEndpoint_NoneOriginal_NeverSelectsNone() + { + var original = new EndpointDescription + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None + }; + var none = new EndpointDescription + { + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None + }; + var signed = new EndpointDescription + { + SecurityMode = MessageSecurityMode.Sign, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256 + }; + + Assert.That( + SessionReconnectHandler.SelectFallbackEndpoint( + original, + [none], + m_telemetry.CreateLogger()), + Is.Null); + Assert.That( + SessionReconnectHandler.SelectFallbackEndpoint( + original, + [none, signed], + m_telemetry.CreateLogger()), + Is.SameAs(signed)); + } + private static ConfiguredEndpoint CreateConfiguredEndpoint( MessageSecurityMode securityMode, string securityPolicyUri) diff --git a/tests/Opc.Ua.Client.Tests/Session/UpdateIdentityAsyncTests.cs b/tests/Opc.Ua.Client.Tests/Session/UpdateIdentityAsyncTests.cs index f2cedfbfbe..474a426e3c 100644 --- a/tests/Opc.Ua.Client.Tests/Session/UpdateIdentityAsyncTests.cs +++ b/tests/Opc.Ua.Client.Tests/Session/UpdateIdentityAsyncTests.cs @@ -99,6 +99,139 @@ public async Task UpdateIdentityAsyncSwitchesUserNameIdentity() Assert.That(status, Is.Not.Null); } + /// + /// L1-4: switching a UserName identity to an ECC user-token policy + /// cleared the ephemeral key before the activation that needed it, + /// so the token could never be encrypted. The key for the new policy + /// must be requested first (Part 6 6.8.2). + /// + [Test] + public async Task UpdateIdentityAsyncSwitchesToEccUserTokenPolicy() + { + Endpoints = await ClientFixture.GetEndpointsAsync(ServerUrl).ConfigureAwait(false); + ConfiguredEndpoint endpoint = await ClientFixture + .GetEndpointAsync(ServerUrl, SecurityPolicies.Basic256Sha256, Endpoints) + .ConfigureAwait(false); + var userIdentity = new UserIdentity("user1", "password"u8); + UserTokenPolicy identityPolicy = endpoint.Description.FindUserTokenPolicy( + userIdentity.TokenType, + userIdentity.IssuedTokenType, + endpoint.Description.SecurityPolicyUri); + string eccPolicyUri = null; + foreach (UserTokenPolicy policy in endpoint.Description.UserIdentityTokens) + { + if (policy.TokenType == UserTokenType.UserName && + policy.SecurityPolicyUri != null && + policy.SecurityPolicyUri.StartsWith( + SecurityPolicies.ECC_nistP256, + System.StringComparison.Ordinal)) + { + eccPolicyUri = policy.SecurityPolicyUri; + break; + } + } + if (identityPolicy == null || eccPolicyUri == null) + { + Assert.Ignore("The test server endpoint does not advertise UserName tokens with an ECC policy."); + } + + using ISession session = await ClientFixture + .ConnectAsync(endpoint, userIdentity) + .ConfigureAwait(false); + var rawSession = (Session)session; + SecretIdentifier passwordId = await CreatePasswordAsync("password1"u8.ToArray()) + .ConfigureAwait(false); + var provider = new UserNamePasswordIdentityProvider( + "user2", + m_secretRegistry, + passwordId); + + // The reference server only honours ECDHPolicyUri on CreateSession, + // so it may not hand out the key for the new policy; the switch must + // then fail cleanly (not with a null ephemeral key) and leave the + // current identity active. + string expectedUser = "user2"; + try + { + await rawSession.UpdateIdentityAsync(provider, eccPolicyUri).ConfigureAwait(false); + } + catch (ServiceResultException sre) when (sre.StatusCode == StatusCodes.BadSecurityPolicyRejected) + { + expectedUser = "user1"; + } + + Assert.That(session.Identity.DisplayName, Is.EqualTo(expectedUser)); + ServerStatusDataType status = await session + .ReadValueAsync(VariableIds.Server_ServerStatus) + .ConfigureAwait(false); + Assert.That(status, Is.Not.Null); + } + + /// + /// G4 (review of L1-4): an ECC-to-ECC override fetches the ephemeral + /// key for the new policy by reactivating the current identity, which + /// replaces its key. When the new identity is then rejected, the + /// current identity must keep an ephemeral key: without one every + /// later reconnect fails before it reaches the server. + /// + [Test] + public async Task FailedEccToEccOverrideKeepsSessionReconnectable() + { + Endpoints = await ClientFixture.GetEndpointsAsync(ServerUrl).ConfigureAwait(false); + ConfiguredEndpoint endpoint = await ClientFixture + .GetEndpointAsync(ServerUrl, SecurityPolicies.ECC_nistP256, Endpoints) + .ConfigureAwait(false); + var userIdentity = new UserIdentity("user1", "password"u8); + UserTokenPolicy identityPolicy = endpoint.Description.FindUserTokenPolicy( + userIdentity.TokenType, + userIdentity.IssuedTokenType, + endpoint.Description.SecurityPolicyUri); + string currentPolicyUri = string.IsNullOrEmpty(identityPolicy?.SecurityPolicyUri) + ? endpoint.Description.SecurityPolicyUri + : identityPolicy.SecurityPolicyUri; + string eccPolicyUri = null; + foreach (UserTokenPolicy policy in endpoint.Description.UserIdentityTokens) + { + if (policy.TokenType == UserTokenType.UserName && + policy.SecurityPolicyUri != null && + CryptoUtils.IsEccPolicy(policy.SecurityPolicyUri) && + policy.SecurityPolicyUri != currentPolicyUri) + { + eccPolicyUri = policy.SecurityPolicyUri; + break; + } + } + if (identityPolicy == null || eccPolicyUri == null) + { + Assert.Ignore("The test server endpoint does not advertise two ECC UserName token policies."); + } + TestContext.Out.WriteLine($"Token policy {currentPolicyUri}, override {eccPolicyUri}."); + + using ISession session = await ClientFixture + .ConnectAsync(endpoint, userIdentity) + .ConfigureAwait(false); + var rawSession = (Session)session; + SecretIdentifier passwordId = await CreatePasswordAsync("wrong-password"u8.ToArray()) + .ConfigureAwait(false); + var provider = new UserNamePasswordIdentityProvider( + "user2", + m_secretRegistry, + passwordId); + + Assert.That( + async () => await rawSession.UpdateIdentityAsync(provider, eccPolicyUri).ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That(session.Identity.DisplayName, Is.EqualTo("user1")); + + await session.ReconnectAsync(null, null, default).ConfigureAwait(false); + + ServerStatusDataType status = await session + .ReadValueAsync(VariableIds.Server_ServerStatus) + .ConfigureAwait(false); + Assert.That(status, Is.Not.Null); + Assert.That(session.Identity.DisplayName, Is.EqualTo("user1")); + } + private async Task CreatePasswordAsync(byte[] password) { var id = new SecretIdentifier( diff --git a/tests/Opc.Ua.Client.Tests/StateMachines/FiniteStateMachineTypeClientExtensionsTests.cs b/tests/Opc.Ua.Client.Tests/StateMachines/FiniteStateMachineTypeClientExtensionsTests.cs index d15477bbb3..2b01251967 100644 --- a/tests/Opc.Ua.Client.Tests/StateMachines/FiniteStateMachineTypeClientExtensionsTests.cs +++ b/tests/Opc.Ua.Client.Tests/StateMachines/FiniteStateMachineTypeClientExtensionsTests.cs @@ -512,7 +512,9 @@ public async Task HappyPathReadsSnapshotStatesTransitionsAndSubStateMachineAsync Assert.That(transitions[0].TransitionNumber, Is.EqualTo(20u)); Assert.That(child, Is.Not.Null); Assert.That(child!.ObjectId, Is.EqualTo(subMachine)); - Assert.That(translateRequests, Has.Count.EqualTo(5)); + // The sixth translate resolves the sub-state machine's instance component; it + // does not match here, so the reference target is used as-is. + Assert.That(translateRequests, Has.Count.EqualTo(6)); Assert.That(readRequests, Has.Count.EqualTo(5)); Assert.That(browseDescriptions, Has.Count.EqualTo(1)); Assert.Multiple(() => @@ -547,6 +549,11 @@ public async Task HappyPathReadsSnapshotStatesTransitionsAndSubStateMachineAsync startTransition, ReferenceTypeIds.HasProperty, BrowseNames.TransitionNumber); + AssertPath( + translateRequests[5][0], + client.ObjectId, + ReferenceTypeIds.HasComponent, + "SubMachine"); AssertRead(readRequests[1][0], availableStatesNode, Attributes.Value); AssertRead(readRequests[2][0], runningState, Attributes.BrowseName); AssertRead(readRequests[2][1], stateNumber, Attributes.Value); @@ -567,6 +574,80 @@ public async Task HappyPathReadsSnapshotStatesTransitionsAndSubStateMachineAsync It.IsAny()), Times.Never); } + [Test] + public void WaitForStateAsyncThrowsBadNotFoundWhenCurrentStateIdUnresolved() + { + var sessionMock = new Mock(MockBehavior.Loose); + FiniteStateMachineTypeClient client = CreateClient(sessionMock); + SetupTranslateAllEmpty(sessionMock); + + // Previously the observation ended at once and was mis-reported as a + // cancellation although neither the token nor a timeout fired. + Assert.That( + async () => await client + .WaitForStateAsync(new EmptyStreamingSubscription(), new NodeId(1u, 2)) + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadNotFound)); + } + + [Test] + public async Task GetSubStateMachineResolvesTheInstanceComponentNotTheTypeDeclarationAsync() + { + var sessionMock = new Mock(MockBehavior.Strict); + FiniteStateMachineTypeClient client = CreateClient(sessionMock); + var typeLevelState = new NodeId(601u, 2); + var typeLevelSubMachine = new NodeId(602u, 2); + var instanceSubMachine = new NodeId(603u, 2); + var subMachineName = new QualifiedName("SubMachine", 2); + sessionMock.Setup(s => s.BrowseAsync( + null, null, 0, + It.Is>(b => b.Count == 1 && b[0].NodeId == typeLevelState), + It.IsAny())) + .ReturnsAsync(new BrowseResponse + { + Results = + [ + new BrowseResult + { + References = + [ + new ReferenceDescription + { + NodeId = typeLevelSubMachine, + BrowseName = subMachineName + } + ] + } + ] + }); + sessionMock.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + null, + It.Is>(p => + p.Count == 1 && + p[0].StartingNode == client.ObjectId && + p[0].RelativePath.Elements[0].TargetName == subMachineName), + It.IsAny())) + .ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse + { + Results = + [ + new BrowsePathResult + { + Targets = [new BrowsePathTarget { TargetId = instanceSubMachine }] + } + ] + }); + + FiniteStateMachineTypeClient? child = await client + .GetSubStateMachineAsync(typeLevelState, NUnitTelemetryContext.Create()) + .ConfigureAwait(false); + + Assert.That(child, Is.Not.Null); + Assert.That(child!.ObjectId, Is.EqualTo(instanceSubMachine)); + } + private static void SetupTranslateAllEmpty(Mock sessionMock) { sessionMock.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( diff --git a/tests/Opc.Ua.Client.Tests/StateMachines/StateMachineTypeClientExtensionsTests.cs b/tests/Opc.Ua.Client.Tests/StateMachines/StateMachineTypeClientExtensionsTests.cs index f56a1891b2..237e6c8f0c 100644 --- a/tests/Opc.Ua.Client.Tests/StateMachines/StateMachineTypeClientExtensionsTests.cs +++ b/tests/Opc.Ua.Client.Tests/StateMachines/StateMachineTypeClientExtensionsTests.cs @@ -220,6 +220,47 @@ public void WaitForStateAsyncThrowsBadNotFoundWhenCurrentStateUnresolved() .EqualTo(StatusCodes.BadNotFound)); } + [Test] + public void WaitForStateAsyncReportsBadInvalidStateWhenObservationEndsWithoutCancellation() + { + var session = new Mock(); + StateMachineTypeClient client = CreateClient(session); + session.Setup(value => value.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new TranslateBrowsePathsToNodeIdsResponse + { + Results = + [ + new BrowsePathResult + { + Targets = [new BrowsePathTarget { TargetId = new ExpandedNodeId(new NodeId(8u, 2)) }] + } + ] + }); + session.Setup(value => value.ReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new ReadResponse + { + Results = [new DataValue(LocalizedText.From("Idle"))] + }); + + // The stream completes (e.g. the subscription was disposed) without the + // target state; neither the token nor a timeout fired. + Assert.That( + async () => await client + .WaitForStateAsync(new EmptyStreamingSubscription(), LocalizedText.From("Running")) + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadInvalidState)); + } + private static void SetupTranslateEmpty(Mock sessionMock) { sessionMock.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( diff --git a/tests/Opc.Ua.Client.Tests/Streaming/StreamingSubscriptionTests.cs b/tests/Opc.Ua.Client.Tests/Streaming/StreamingSubscriptionTests.cs index e2627da62a..64c9773966 100644 --- a/tests/Opc.Ua.Client.Tests/Streaming/StreamingSubscriptionTests.cs +++ b/tests/Opc.Ua.Client.Tests/Streaming/StreamingSubscriptionTests.cs @@ -165,6 +165,42 @@ public async Task SubscribeEventsAsyncWhenMonitoredItemCannotBeAddedThrowsAsync( Is.True); } + /// + /// DisposeAsync racing the first subscriber (which is still creating + /// the shared subscription) must dispose the subscription that + /// subscriber creates and fail the subscriber with + /// , instead of leaking it. + /// + [Test] + public async Task DisposeRacingFirstSubscribeDisposesCreatedSubscriptionAsync() + { + var subscription = new Mock(); + var manager = new Mock(); + StreamingSubscription? streaming = null; + Task? disposal = null; + manager + .Setup(value => value.Add( + It.IsAny(), + It.IsAny>())) + .Callback(() => disposal = streaming!.DisposeAsync().AsTask()) + .Returns(subscription.Object); + + streaming = new StreamingSubscription(manager.Object); + await using (streaming) + { + await using IAsyncEnumerator enumerator = streaming + .SubscribeEventsAsync(ObjectIds.Server, new EventFilter()) + .GetAsyncEnumerator(); + + Assert.That( + async () => await enumerator.MoveNextAsync().ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That(disposal, Is.Not.Null); + await disposal!.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + subscription.Verify(value => value.DisposeAsync(), Times.Once); + } + } + /// /// Stub subscription manager — its methods are never invoked /// because the argument guards trip synchronously before diff --git a/tests/Opc.Ua.Client.Tests/Subscription/Classic/ClassicMonitoredItemCoverageTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/Classic/ClassicMonitoredItemCoverageTests.cs index 68bda76385..c9369a8199 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/Classic/ClassicMonitoredItemCoverageTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/Classic/ClassicMonitoredItemCoverageTests.cs @@ -643,6 +643,46 @@ public void CloneMonitoredItemWithCopyClientHandleKeepsHandle() Assert.That(clone.ClientHandle, Is.EqualTo(item.ClientHandle)); } + /// + /// Triggering links reference client handles, so a clone that keeps + /// the client handle must keep them for restoration after a session + /// recreate (L7-5). A clone with a fresh handle must not. + /// + [Test] + public void CloneWithCopyClientHandleKeepsTriggeringLinks() + { + MonitoredItem item = CreateItem(); + item.TriggeredItems = [11u, 12u]; + item.TriggeringItemId = 99; + + MonitoredItem sameHandle = item.CloneMonitoredItem(false, true); + MonitoredItem newHandle = item.CloneMonitoredItem(false, false); + + Assert.Multiple(() => + { + Assert.That(sameHandle.TriggeredItems.ToArray(), Is.EqualTo(new uint[] { 11, 12 })); + Assert.That(sameHandle.TriggeringItemId, Is.Zero); + Assert.That(newHandle.TriggeredItems.IsEmpty, Is.True); + }); + } + + [Test] + public void SubscriptionCloneKeepsTriggeringLinksOfItems() + { + using var subscription = new Subscription(m_telemetry); + MonitoredItem triggering = CreateItem(); + MonitoredItem triggered = CreateItem(); + triggering.TriggeredItems = [triggered.ClientHandle]; + subscription.AddItems([triggering, triggered]); + + using Subscription clone = subscription.CloneSubscription(false); + + MonitoredItem clonedTriggering = clone.FindItemByClientHandle(triggering.ClientHandle); + Assert.That(clonedTriggering, Is.Not.Null); + Assert.That(clonedTriggering.TriggeredItems.ToArray(), + Is.EqualTo(new[] { triggered.ClientHandle })); + } + [Test] public void TemplateConstructorTruncatesDisplayNameAtLastSpace() { @@ -899,6 +939,39 @@ public void LastValueAndLastMessageReflectDataChange() }); } + /// + /// A notification of the other kind for the item's client handle + /// (buggy server or handle clash) must not make LastMessage throw. + /// + [Test] + public void LastMessageIgnoresNotificationOfTheOtherKind() + { + MonitoredItem eventItem = CreateItem(); + eventItem.NodeClass = NodeClass.Object; + eventItem.SaveValueInCache(new MonitoredItemNotification + { + ClientHandle = eventItem.ClientHandle, + Value = new DataValue(new Variant(1), StatusCodes.Good, DateTime.UtcNow), + Message = new NotificationMessage() + }); + + MonitoredItem dataItem = CreateItem(); + dataItem.SaveValueInCache(new EventFieldList + { + ClientHandle = dataItem.ClientHandle, + EventFields = [new Variant(1)], + Message = new NotificationMessage() + }); + + Assert.Multiple(() => + { + Assert.That(() => eventItem.LastMessage, Throws.Nothing); + Assert.That(eventItem.LastMessage, Is.Null); + Assert.That(() => dataItem.LastMessage, Throws.Nothing); + Assert.That(dataItem.LastMessage, Is.Null); + }); + } + [Test] public void DequeueEventsReturnsQueuedEvents() { diff --git a/tests/Opc.Ua.Client.Tests/Subscription/Classic/ClassicSubscriptionCoverageTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/Classic/ClassicSubscriptionCoverageTests.cs index 9ff8cd5213..a6e7b8550b 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/Classic/ClassicSubscriptionCoverageTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/Classic/ClassicSubscriptionCoverageTests.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -701,5 +702,290 @@ public void DisposeIsIdempotent() Assert.DoesNotThrow(subscription.Dispose); } + + /// + /// A restored item keeps the server id of the previous session. When + /// the subscription is created instead of transferred, that item must + /// still be created on the new server subscription (L7-6). + /// + [Test] + public async Task CreateAsyncCreatesItemsThatCarryAStaleServerIdAsync() + { + using Subscription subscription = CreateSubscription(); + MonitoredItem item = CreateItem(4321u, "Restored"); + item.ServerId = 55; + subscription.AddItem(item); + Mock session = CreateItemSession(7); + subscription.Session = session.Object; + + await subscription.CreateAsync().ConfigureAwait(false); + + session.Verify(s => s.CreateMonitoredItemsAsync( + It.IsAny(), + 7, + It.IsAny(), + It.Is>(requests => + requests.Count == 1 && + requests[0].RequestedParameters.ClientHandle == 4321u), + It.IsAny()), Times.Once); + Assert.That(item.ServerId, Is.EqualTo(100u)); + } + + /// + /// An item removed while its create request is in flight exists on the + /// server once the response arrives, so it must be queued for + /// deletion instead of being leaked (L7-8). + /// + [Test] + public async Task ItemRemovedDuringCreateIsQueuedForDeletionAsync() + { + using Subscription subscription = CreateSubscription(); + MonitoredItem item = CreateItem(4322u, "Removed"); + Mock session = CreateItemSession(7, () => subscription.RemoveItem(item)); + session + .Setup(s => s.DeleteMonitoredItemsAsync( + It.IsAny(), + 7, + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new DeleteMonitoredItemsResponse { Results = [StatusCodes.Good] }); + subscription.Session = session.Object; + await subscription.CreateAsync().ConfigureAwait(false); + subscription.AddItem(item); + + await subscription.CreateItemsAsync().ConfigureAwait(false); + ArrayOf deleted = await subscription.DeleteItemsAsync().ConfigureAwait(false); + + Assert.That(deleted.Count, Is.EqualTo(1)); + Assert.That(deleted[0], Is.SameAs(item)); + session.Verify(s => s.DeleteMonitoredItemsAsync( + It.IsAny(), + 7, + It.Is>(ids => ids.Count == 1 && ids[0] == 100u), + It.IsAny()), Times.Once); + } + + /// + /// A saved item that was never created does not exist on the server, + /// so it must not make the transfer of a restored subscription fail + /// (L7-4). + /// + [Test] + public async Task TransferOfRestoredSubscriptionIgnoresItemsThatWereNeverCreatedAsync() + { + using Subscription subscription = CreateSubscription(); + MonitoredItem created = CreateItem(4323u, "Created"); + created.ServerId = 55; + MonitoredItem neverCreated = CreateItem(4324u, "NeverCreated"); + subscription.AddItems([created, neverCreated]); + Mock session = CreateGetMonitoredItemsSession([55u], [4323u]); + subscription.Session = session.Object; + + bool transferred = await subscription.TransferAsync(session.Object, 9, []) + .ConfigureAwait(false); + + Assert.That(transferred, Is.True); + Assert.That(subscription.Id, Is.EqualTo(9u)); + session.Verify(s => s.DeleteSubscriptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), Times.Never); + } + + /// + /// A clone of a live subscription carries no server ids, so its items + /// are not created locally but all exist on the server: the transfer + /// must still succeed. + /// + [Test] + public async Task TransferOfClonedSubscriptionWithoutServerIdsSucceedsAsync() + { + using Subscription subscription = CreateSubscription(); + subscription.AddItems([CreateItem(4326u, "First"), CreateItem(4327u, "Second")]); + Mock session = CreateGetMonitoredItemsSession([56u, 57u], [4326u, 4327u]); + subscription.Session = session.Object; + + bool transferred = await subscription.TransferAsync(session.Object, 9, []) + .ConfigureAwait(false); + + Assert.That(transferred, Is.True); + Assert.That(subscription.Id, Is.EqualTo(9u)); + } + + /// + /// When a transferred subscription cannot be adopted, the server side + /// subscription now owned by the session is deleted instead of being + /// left alive as an orphan (L7-4). + /// + [Test] + public async Task FailedTransferOfRestoredSubscriptionDeletesTheServerSubscriptionAsync() + { + using Subscription subscription = CreateSubscription(); + MonitoredItem created = CreateItem(4325u, "Created"); + created.ServerId = 55; + subscription.AddItem(created); + Mock session = CreateGetMonitoredItemsSession([], []); + subscription.Session = session.Object; + + (bool transferred, _) = await subscription.TransferWithAcknowledgementsAsync( + session.Object, 9, [], true, CancellationToken.None).ConfigureAwait(false); + + Assert.That(transferred, Is.False); + Assert.That(subscription.Created, Is.False); + session.Verify(s => s.DeleteSubscriptionsAsync( + It.IsAny(), + It.Is>(ids => ids.Count == 1 && ids[0] == 9u), + It.IsAny()), Times.Once); + } + + /// + /// The public TransferAsync is also used by ReactivateSubscriptionsAsync, + /// which moves nothing to the session: the session's own subscription + /// must survive a failed adoption (review G27). + /// + [Test] + public async Task FailedReactivateOfRestoredSubscriptionKeepsTheServerSubscriptionAsync() + { + using Subscription subscription = CreateSubscription(); + MonitoredItem created = CreateItem(4325u, "Created"); + created.ServerId = 55; + subscription.AddItem(created); + Mock session = CreateGetMonitoredItemsSession([], []); + subscription.Session = session.Object; + + bool transferred = await subscription.TransferAsync(session.Object, 9, []) + .ConfigureAwait(false); + + Assert.That(transferred, Is.False); + session.Verify(s => s.DeleteSubscriptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), Times.Never); + } + + /// + /// A transient GetMonitoredItems error does not prove the transferred + /// subscription cannot be adopted, so it is not deleted; a definitive + /// one does, so it is (review G27). + /// + [TestCaseSource(nameof(GetMonitoredItemsFailures))] + public async Task FailedGetMonitoredItemsDeletesTransferredSubscriptionOnlyWhenDefinitiveAsync( + StatusCode statusCode, + bool deleted) + { + using Subscription subscription = CreateSubscription(); + MonitoredItem created = CreateItem(4325u, "Created"); + created.ServerId = 55; + subscription.AddItem(created); + Mock session = CreateGetMonitoredItemsSession([], []); + session + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new ServiceResultException(statusCode)); + subscription.Session = session.Object; + + (bool transferred, _) = await subscription.TransferWithAcknowledgementsAsync( + session.Object, 9, [], true, CancellationToken.None).ConfigureAwait(false); + + Assert.That(transferred, Is.False); + session.Verify(s => s.DeleteSubscriptionsAsync( + It.IsAny(), + It.Is>(ids => ids.Count == 1 && ids[0] == 9u), + It.IsAny()), deleted ? Times.Once() : Times.Never()); + } + + private static IEnumerable GetMonitoredItemsFailures() + { + yield return new TestCaseData(StatusCodes.BadTimeout, false); + yield return new TestCaseData(StatusCodes.BadTooManyOperations, false); + yield return new TestCaseData(StatusCodes.BadServerTooBusy, false); + yield return new TestCaseData(StatusCodes.BadCommunicationError, false); + yield return new TestCaseData(StatusCodes.BadMethodInvalid, true); + yield return new TestCaseData(StatusCodes.BadNotSupported, true); + } + + private static Mock CreateItemSession(uint subscriptionId, Action onCreateItems = null) + { + var session = new Mock(); + session + .Setup(s => s.CreateSubscriptionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new CreateSubscriptionResponse + { + SubscriptionId = subscriptionId, + RevisedPublishingInterval = 1000, + RevisedMaxKeepAliveCount = 10, + RevisedLifetimeCount = 100 + }); + session + .Setup(s => s.CreateMonitoredItemsAsync( + It.IsAny(), + subscriptionId, + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(( + RequestHeader _, + uint _, + TimestampsToReturn _, + ArrayOf requests, + CancellationToken _) => + { + onCreateItems?.Invoke(); + var results = new MonitoredItemCreateResult[requests.Count]; + for (int ii = 0; ii < results.Length; ii++) + { + results[ii] = new MonitoredItemCreateResult + { + StatusCode = StatusCodes.Good, + MonitoredItemId = 100u + (uint)ii + }; + } + return new CreateMonitoredItemsResponse + { + Results = new ArrayOf(results) + }; + }); + return session; + } + + private static Mock CreateGetMonitoredItemsSession( + ArrayOf serverHandles, + ArrayOf clientHandles) + { + var session = new Mock(); + session + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new CallResponse + { + Results = + [ + new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = [Variant.From(serverHandles), Variant.From(clientHandles)] + } + ] + }); + session + .Setup(s => s.DeleteSubscriptionsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new DeleteSubscriptionsResponse { Results = [StatusCodes.Good] }); + return session; + } } } diff --git a/tests/Opc.Ua.Client.Tests/Subscription/Classic/SubscriptionUnitTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/Classic/SubscriptionUnitTests.cs index 10b51bccf9..d3726c051e 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/Classic/SubscriptionUnitTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/Classic/SubscriptionUnitTests.cs @@ -782,6 +782,256 @@ public async Task RecreatePolicyDoesNotRecreateWhileSessionReconnectingAsync(Can "Reconnecting session must keep the spec-strict path."); } + /// + /// The Good_SubscriptionTransferred a server sends to the old session + /// when this client transfers the subscription itself must neither + /// stop the publish worker nor recreate the subscription (L7-7). + /// + [TestCase(SubscriptionRecoveryPolicy.ReportOnly)] + [TestCase(SubscriptionRecoveryPolicy.RecreateOnUnsolicitedTransfer)] + [CancelAfter(5000)] + public async Task OwnTransferDoesNotStopOrRecreateTheSubscriptionAsync( + SubscriptionRecoveryPolicy policy, + CancellationToken ct) + { + NotificationMessage[] messages = BuildMessages(2); + using SubscriptionContainer container = await BuildSubscriptionAsync( + messages, sequentialPublishing: false, ct).ConfigureAwait(false); + Subscription subscription = container.Subscription; + subscription.RecoveryPolicy = policy; + uint originalId = subscription.Id; + var transferredReported = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + subscription.PublishStatusChanged += (_, args) => + { + if ((args.Status & PublishStateChangedMask.Transferred) != 0) + { + transferredReported.TrySetResult(true); + } + }; + + subscription.OnTransferStarting(); + subscription.SaveMessageInCache(default, BuildStatusChangeMessage( + sequenceNumber: 1, StatusCodes.GoodSubscriptionTransferred)); + await transferredReported.Task.WaitAsync(ct).ConfigureAwait(false); + subscription.OnTransferFinished(); + + // the publish worker must still be running + subscription.SaveMessageInCache(default, messages[2]); + await Task.WhenAny(container.ProcessedMessages[2], Task.Delay(1000, ct)).ConfigureAwait(false); + + Assert.That(container.ProcessedMessages[2].IsCompleted, Is.True, + "The publish worker was stopped by the notification of the own transfer."); + Assert.That(subscription.Id, Is.EqualTo(originalId), + "The subscription must not be recreated after the own transfer."); + } + + /// + /// A late copy of a message that was already processed (the original + /// Publish response racing its Republish) must not be delivered again + /// (L7-2). + /// + [TestCase(false)] + [TestCase(true)] + [CancelAfter(5000)] + public async Task LateCopyOfProcessedMessageIsNotDeliveredAgainAsync( + bool sequentialPublishing, + CancellationToken ct) + { + var received = new ConcurrentQueue(); + var completions = new ConcurrentDictionary>(); + TaskCompletionSource Completion(uint sequenceNumber) => completions.GetOrAdd( + sequenceNumber, + _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + ISession session = BuildSessionMock((_, _) => false); + using var subscription = new Subscription(NUnitTelemetryContext.Create()) + { + Session = session, + SequentialPublishing = sequentialPublishing, + FastDataChangeCallback = (_, notification, _) => + { + received.Enqueue(notification.SequenceNumber); + Completion(notification.SequenceNumber).TrySetResult(true); + } + }; + await subscription.CreateAsync(ct).ConfigureAwait(false); + NotificationMessage[] messages = BuildMessages(4); + + for (uint sequenceNumber = 1; sequenceNumber <= 3; sequenceNumber++) + { + subscription.SaveMessageInCache([], messages[sequenceNumber]); + await Completion(sequenceNumber).Task.WaitAsync(ct).ConfigureAwait(false); + } + + // late copies: 3 still has a processed entry, 2 was cleaned up + subscription.SaveMessageInCache([], messages[3]); + subscription.SaveMessageInCache([], messages[2]); + subscription.SaveMessageInCache([], messages[4]); + await Completion(4).Task.WaitAsync(ct).ConfigureAwait(false); + + Assert.That(received, Is.EqualTo(new uint[] { 1, 2, 3, 4 })); + Mock.Get(session).Verify(value => value.RepublishAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Disposing the subscription from a PublishStatusChanged handler that + /// the keep-alive timer raised must not wait for the running timer + /// callback, which is the caller itself (L7-3). + /// + [Test] + [CancelAfter(Subscription.MinKeepAliveTimerInterval * 10)] + public async Task DisposeFromKeepAliveStoppedHandlerDoesNotDeadlockAsync(CancellationToken ct) + { + var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = new Subscription( + NUnitTelemetryContext.Create(), + new() { PublishingEnabled = true }) + { + Session = BuildSessionMock(setup: mock => mock.Setup(x => x.Connected).Returns(false)) + }; + subscription.PublishStatusChanged += (s, e) => + { + if ((e.Status & PublishStateChangedMask.Stopped) != 0) + { + s.Dispose(); + disposed.TrySetResult(true); + } + }; + await subscription.CreateAsync(ct).ConfigureAwait(false); + + await Task.WhenAny(disposed.Task, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false); + + Assert.That(disposed.Task.IsCompleted, Is.True, + "Dispose from the keep-alive handler did not return."); + } + + /// + /// G6 (review of L7-3): a PublishStatusChanged handler raised by the + /// keep-alive timer that disposes the subscription on another flow and + /// waits for it (managed session Dispose: its state machine worker + /// disposes the inner session and its subscriptions) deadlocked, + /// because the dispose waited for the running timer callback. + /// + [Test] + [CancelAfter(Subscription.MinKeepAliveTimerInterval * 10)] + public async Task DisposeOnOtherFlowFromKeepAliveStoppedHandlerDoesNotDeadlockAsync( + CancellationToken ct) + { + var disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = new Subscription( + NUnitTelemetryContext.Create(), + new() { PublishingEnabled = true }) + { + Session = BuildSessionMock(setup: mock => mock.Setup(x => x.Connected).Returns(false)) + }; + subscription.PublishStatusChanged += (s, e) => + { + if ((e.Status & PublishStateChangedMask.Stopped) != 0 && !disposed.Task.IsCompleted) + { + // The dispose runs without the handler's execution context. + Task disposal; + using (ExecutionContext.SuppressFlow()) + { + disposal = Task.Run(s.Dispose, CancellationToken.None); + } + disposed.TrySetResult(disposal.Wait((int)TimeSpan.FromSeconds(5).TotalMilliseconds, CancellationToken.None)); + } + }; + await subscription.CreateAsync(ct).ConfigureAwait(false); + + await Task.WhenAny(disposed.Task, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false); + + Assert.That(disposed.Task.IsCompleted, Is.True, "The keep-alive handler was not raised."); + Assert.That(disposed.Task.Result, Is.True, + "Dispose on another flow did not complete while the keep-alive handler waited for it."); + } + + /// + /// A negative MaxMessageCount must not make every worker pass throw + /// (L7-10): the notification is still delivered and cached. + /// + [Test] + [CancelAfter(5000)] + public async Task NegativeMaxMessageCountStillDeliversNotificationsAsync(CancellationToken ct) + { + var delivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var received = new ConcurrentQueue(); + using var subscription = new Subscription( + NUnitTelemetryContext.Create(), + new() { MaxMessageCount = -1 }) + { + Session = BuildSessionMock(), + FastDataChangeCallback = (_, notification, _) => + { + received.Enqueue(notification.SequenceNumber); + if (received.Count == 2) + { + delivered.TrySetResult(true); + } + } + }; + await subscription.CreateAsync(ct).ConfigureAwait(false); + + NotificationMessage[] messages = BuildMessages(2); + subscription.SaveMessageInCache([], messages[1]); + subscription.SaveMessageInCache([], messages[2]); + await Task.WhenAny(delivered.Task, Task.Delay(Timeout.Infinite, ct)).ConfigureAwait(false); + + Assert.That(delivered.Task.IsCompleted, Is.True, "No notification was delivered."); + Assert.That(received, Is.EqualTo(new uint[] { 1, 2 })); + Assert.That(subscription.Notifications, Is.EqualTo(new[] { messages[2] })); + } + + /// + /// Very long keep-alive intervals must not overflow the publish + /// timeout or the publishing-stopped check (L7-9). + /// + [TestCase(3_600_000d, 200u)] + [TestCase((double)int.MaxValue, 10u)] + [CancelAfter(5000)] + public async Task LongKeepAliveIntervalDoesNotOverflowAsync( + double revisedPublishingInterval, + uint revisedKeepAliveCount, + CancellationToken ct) + { + var publishTimeouts = new ConcurrentQueue(); + ISession session = BuildSessionMock(setup: mock => + { + mock + .Setup(x => x.CreateSubscriptionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new CreateSubscriptionResponse + { + SubscriptionId = 1, + RevisedPublishingInterval = revisedPublishingInterval, + RevisedMaxKeepAliveCount = revisedKeepAliveCount, + RevisedLifetimeCount = revisedKeepAliveCount * 3 + }); + mock + .Setup(x => x.StartPublishing(It.IsAny(), It.IsAny())) + .Callback((timeout, _) => publishTimeouts.Enqueue(timeout)); + }); + using var subscription = new Subscription(NUnitTelemetryContext.Create()) + { + Session = session + }; + + await subscription.CreateAsync(ct).ConfigureAwait(false); + + Assert.That(publishTimeouts, Is.Not.Empty); + Assert.That(publishTimeouts, Has.All.EqualTo(int.MaxValue), + "Three keep-alive intervals exceed int.MaxValue, so the timeout must saturate."); + Assert.That(subscription.PublishingStopped, Is.False, + "A subscription that was just created has not stopped publishing."); + } + private static NotificationMessage BuildStatusChangeMessage( uint sequenceNumber, StatusCode status) { diff --git a/tests/Opc.Ua.Client.Tests/Subscription/Fakes/FakeManagedSubscription.cs b/tests/Opc.Ua.Client.Tests/Subscription/Fakes/FakeManagedSubscription.cs index 1b2a98f7df..2be6b277e9 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/Fakes/FakeManagedSubscription.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/Fakes/FakeManagedSubscription.cs @@ -54,7 +54,19 @@ internal sealed class FakeManagedSubscription : IManagedSubscription public uint ServerId => Id; public bool Created { get; set; } - public bool IsCreationInProgress { get; set; } + public bool IsCreationInProgress + { + get => OnIsCreationInProgress?.Invoke() ?? m_isCreationInProgress; + set => m_isCreationInProgress = value; + } + + /// + /// Optional override for reads of , + /// e.g. to complete a creation exactly when the manager checks it. + /// + public Func? OnIsCreationInProgress { get; set; } + + private bool m_isCreationInProgress; public bool IsIntentionallyDeleted { get; set; } public TimeSpan CurrentPublishingInterval { get; set; } public byte CurrentPriority { get; set; } diff --git a/tests/Opc.Ua.Client.Tests/Subscription/Fakes/FakeMessageAckQueue.cs b/tests/Opc.Ua.Client.Tests/Subscription/Fakes/FakeMessageAckQueue.cs index 4edc1771a2..84a4b96b62 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/Fakes/FakeMessageAckQueue.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/Fakes/FakeMessageAckQueue.cs @@ -141,11 +141,20 @@ public ValueTask RunWithPublishingQuiescedAsync( return operation(ct); } + /// + /// Optional override for , + /// e.g. to simulate a session that is not available. If null, runs + /// the operation. + /// + public Func, CancellationToken, ValueTask>? + OnRunWithSessionAvailableAsync + { get; set; } + public ValueTask RunWithSessionAvailableAsync( Func operation, CancellationToken ct = default) { - return operation(ct); + return OnRunWithSessionAvailableAsync?.Invoke(operation, ct) ?? operation(ct); } /// diff --git a/tests/Opc.Ua.Client.Tests/Subscription/MessageProcessorTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/MessageProcessorTests.cs index a39cf35b84..42f25d686d 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/MessageProcessorTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/MessageProcessorTests.cs @@ -777,6 +777,64 @@ await sut.DataChangeNotificationReceived.WaitAsync() } } + /// + /// A keep-alive carries the next sequence number to be sent (Part 4 + /// §5.14.1.1), so a keep-alive two ahead of the last data message + /// proves the message in between was lost. It must be republished + /// right away instead of waiting for the next data message. + /// + [Test] + public async Task KeepAliveAfterGapRepublishesMissingMessageAsync() + { + SetupRepublish(); + + var sut = new TestMessageProcessor(m_mockServices.Object, + m_completion, m_telemetry) + { + Id = 28 + }; + await using (sut.ConfigureAwait(false)) + { + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(5), + [5], []).ConfigureAwait(false); + await WaitForLastSeqNumberAsync(sut, 5).ConfigureAwait(false); + + // Data message 6 is lost; the keep-alive names 7 as next. + sut.KeepAliveNotificationReceived.Reset(); + await sut.OnPublishReceivedAsync( + new NotificationMessage { SequenceNumber = 7 }, + [5, 6], []).ConfigureAwait(false); + await sut.KeepAliveNotificationReceived.WaitAsync() + .WaitAsync(TimeSpan.FromSeconds(5)) + .ConfigureAwait(false); + + Assert.That(sut.ReceivedSequenceNumbers, + Is.EqualTo(new uint[] { 5, 6, 7 })); + Assert.That(sut.MissingMessageCount, Is.EqualTo(1)); + Assert.That(sut.RepublishMessageCount, Is.EqualTo(1)); + Assert.That(sut.LastDataSequenceNumberProcessed, Is.EqualTo(6)); + + // A second keep-alive with the same number does not republish + // again, and the data message that reuses 7 is not a gap. + await sut.OnPublishReceivedAsync( + new NotificationMessage { SequenceNumber = 7 }, + [6], []).ConfigureAwait(false); + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(7), + [6, 7], []).ConfigureAwait(false); + await WaitForDataSeqNumberAsync(sut, 7).ConfigureAwait(false); + + Assert.That(sut.MissingMessageCount, Is.EqualTo(1)); + Assert.That(sut.RepublishMessageCount, Is.EqualTo(1)); + m_mockServices.Verify( + c => c.RepublishAsync( + It.IsAny(), + It.IsAny(), + It.Is(s => s == 6), + It.IsAny()), + Times.Once); + } + } + [Test] public async Task EmptyNotificationDataIsTreatedAsKeepAliveAsync() { @@ -1203,8 +1261,12 @@ await releaseFirstRepublish.Task .AsTask(); await firstRepublishStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + // The keep-alive names 9, the message right after the + // recovered ones, so processing it after the recovery does + // not start a keep-alive gap recovery that races the + // republish count asserted below. await sut.OnPublishReceivedAsync( - new NotificationMessage { SequenceNumber = 20 }, + new NotificationMessage { SequenceNumber = 9 }, [11, 12], []).ConfigureAwait(false); @@ -1276,6 +1338,54 @@ await sut.RecoverTransferredMessagesAsync( } } + /// + /// A reconnect that transfers the subscription onto the same object + /// keeps the dedup gate. Messages that were already dispatched but + /// not yet acknowledged are still listed as available by the server + /// (Part 4 §5.14.7.2); they must be acknowledged, not republished and + /// dispatched a second time. + /// + [Test] + public async Task RecoverTransferredMessagesSkipsAlreadyProcessedMessagesAsync() + { + SetupRepublish(); + + var sut = new TestMessageProcessor(m_mockServices.Object, + m_completion, m_telemetry) + { + Id = 27 + }; + await using (sut.ConfigureAwait(false)) + { + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(9), + [], []).ConfigureAwait(false); + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(10), + [], []).ConfigureAwait(false); + await WaitForLastSeqNumberAsync(sut, 10).ConfigureAwait(false); + await m_completion.WaitForQueuedAckAsync(2).ConfigureAwait(false); + m_completion.QueuedAcks.Clear(); + + await sut.RecoverTransferredMessagesAsync([9, 10, 11], default) + .ConfigureAwait(false); + + Assert.That(sut.ReceivedSequenceNumbers, + Is.EqualTo(new uint[] { 9, 10, 11 })); + Assert.That(sut.RepublishMessageCount, Is.EqualTo(1)); + m_mockServices.Verify( + c => c.RepublishAsync( + It.IsAny(), + It.IsAny(), + It.Is(s => s == 9 || s == 10), + It.IsAny()), + Times.Never); + await m_completion.WaitForQueuedAckAsync(3).ConfigureAwait(false); + Assert.That( + m_completion.QueuedAcks.Select(a => a.SequenceNumber), + Is.EquivalentTo(new uint[] { 9, 10, 11 })); + Assert.That(sut.LastDataSequenceNumberProcessed, Is.EqualTo(11)); + } + } + [Test] public async Task RecoverTransferredMessagesAdvancesGateWhenRepublishFailsAsync() { @@ -1316,6 +1426,148 @@ await sut.OnPublishReceivedAsync(BuildDataChangeMessage(9), } } + /// + /// A gap republish that failed leaves the message undelivered although + /// the data gate moved past it. Transfer recovery must republish it + /// instead of acknowledging it as already dispatched, while messages + /// really dispatched are still only acknowledged. + /// + [Test] + public async Task RecoverTransferredMessagesRepublishesMessageWhoseGapRepublishFailedAsync() + { + SetupRepublishFailingFirst(11, 1); + + var sut = new TestMessageProcessor(m_mockServices.Object, + m_completion, m_telemetry) + { + Id = 29 + }; + await using (sut.ConfigureAwait(false)) + { + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(10), + [], []).ConfigureAwait(false); + // Data 11 is lost and its republish fails. + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(12), + [11, 12], []).ConfigureAwait(false); + await WaitForReceivedCountAsync(sut, 2).ConfigureAwait(false); + Assert.That(sut.ReceivedSequenceNumbers, + Is.EqualTo(new uint[] { 10, 12 })); + + await sut.RecoverTransferredMessagesAsync([11, 12], default) + .ConfigureAwait(false); + + Assert.That(sut.ReceivedSequenceNumbers, + Is.EqualTo(new uint[] { 10, 12, 11 })); + m_mockServices.Verify( + c => c.RepublishAsync( + It.IsAny(), + It.IsAny(), + It.Is(s => s == 11), + It.IsAny()), + Times.Exactly(2)); + m_mockServices.Verify( + c => c.RepublishAsync( + It.IsAny(), + It.IsAny(), + It.Is(s => s == 12), + It.IsAny()), + Times.Never); + Assert.That(sut.LastDataSequenceNumberProcessed, Is.EqualTo(12)); + } + } + + /// + /// A keep-alive recovery whose republish fails must not advance the + /// data gate over the unrecovered message, so the gap-walk of the + /// next data message retries it. + /// + [Test] + public async Task KeepAliveRecoveryFailureLeavesGapForNextDataMessageAsync() + { + SetupRepublishFailingFirst(11, 1); + + var sut = new TestMessageProcessor(m_mockServices.Object, + m_completion, m_telemetry) + { + Id = 30 + }; + await using (sut.ConfigureAwait(false)) + { + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(10), + [], []).ConfigureAwait(false); + await WaitForDataSeqNumberAsync(sut, 10).ConfigureAwait(false); + + sut.KeepAliveNotificationReceived.Reset(); + await sut.OnPublishReceivedAsync( + new NotificationMessage { SequenceNumber = 12 }, + [11], []).ConfigureAwait(false); + await sut.KeepAliveNotificationReceived.WaitAsync() + .WaitAsync(TimeSpan.FromSeconds(5)) + .ConfigureAwait(false); + Assert.That(sut.LastDataSequenceNumberProcessed, Is.EqualTo(10)); + + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(12), + [11, 12], []).ConfigureAwait(false); + await WaitForReceivedCountAsync(sut, 4).ConfigureAwait(false); + + // 10, the keep-alive 12, then 11 recovered by the gap-walk + // and data 12. + Assert.That(sut.ReceivedSequenceNumbers, + Is.EqualTo(new uint[] { 10, 12, 11, 12 })); + } + } + + /// + /// A keep-alive that proves a gap nothing can be recovered for must + /// not advance the data gate: the missing message may still be in + /// flight and must be delivered when it arrives. + /// + [Test] + public async Task KeepAliveWithoutRecoverableMessagesKeepsDataGateAsync() + { + SetupRepublish(); + + var sut = new TestMessageProcessor(m_mockServices.Object, + m_completion, m_telemetry) + { + Id = 31 + }; + await using (sut.ConfigureAwait(false)) + { + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(10), + [], []).ConfigureAwait(false); + await WaitForDataSeqNumberAsync(sut, 10).ConfigureAwait(false); + + // The keep-alive overtakes data 11, which the server does + // not list (no retransmission queue). + sut.KeepAliveNotificationReceived.Reset(); + await sut.OnPublishReceivedAsync( + new NotificationMessage { SequenceNumber = 12 }, + [], []).ConfigureAwait(false); + await sut.KeepAliveNotificationReceived.WaitAsync() + .WaitAsync(TimeSpan.FromSeconds(5)) + .ConfigureAwait(false); + + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(11), + [], []).ConfigureAwait(false); + // Data 13 follows, so a dropped 11 shows in the content below + // instead of as a timeout. + await sut.OnPublishReceivedAsync(BuildDataChangeMessage(13), + [], []).ConfigureAwait(false); + DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (!sut.ReceivedSequenceNumbers.ToArray().Contains(13u) && + DateTimeOffset.UtcNow < deadline) + { + await Task.Delay(10).ConfigureAwait(false); + } + + // 10, the keep-alive 12, then the late original 11, then 13. + Assert.That(sut.ReceivedSequenceNumbers, + Is.EqualTo(new uint[] { 10, 12, 11, 13 })); + Assert.That(sut.RepublishMessageCount, Is.Zero); + } + } + [Test] public async Task RecoverTransferredMessagesPropagatesCancellationDuringRepublishCallbackAsync() { @@ -1358,6 +1610,30 @@ await sut.DataChangeNotificationReceived.WaitAsync() } } + private void SetupRepublishFailingFirst(uint failingSequenceNumber, int failures) + { + int remaining = failures; + m_mockServices + .Setup(c => c.RepublishAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((RequestHeader _, uint _, uint sequenceNumber, CancellationToken _) => + { + if (sequenceNumber == failingSequenceNumber && + Interlocked.Decrement(ref remaining) >= 0) + { + throw new ServiceResultException(StatusCodes.BadSecureChannelClosed); + } + return new ValueTask(new RepublishResponse + { + ResponseHeader = new ResponseHeader(), + NotificationMessage = BuildDataChangeMessage(sequenceNumber) + }); + }); + } + private void SetupRepublish() { m_mockServices @@ -1427,6 +1703,42 @@ private static async Task WaitForLastSeqNumberAsync( } } + private static async Task WaitForReceivedCountAsync( + TestMessageProcessor sut, + int expected, + int timeoutSeconds = 5) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds); + while (sut.ReceivedSequenceNumbers.Count < expected) + { + if (DateTimeOffset.UtcNow > deadline) + { + Assert.Fail( + $"Timeout: received {sut.ReceivedSequenceNumbers.Count}, " + + $"expected = {expected}"); + } + await Task.Delay(10).ConfigureAwait(false); + } + } + + private static async Task WaitForDataSeqNumberAsync( + TestMessageProcessor sut, + uint expected, + int timeoutSeconds = 5) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds); + while (sut.LastDataSequenceNumberProcessed != expected) + { + if (DateTimeOffset.UtcNow > deadline) + { + Assert.Fail( + $"Timeout: LastDataSequenceNumberProcessed = " + + $"{sut.LastDataSequenceNumberProcessed}, expected = {expected}"); + } + await Task.Delay(10).ConfigureAwait(false); + } + } + private sealed class TestMessageProcessor : MessageProcessor { public TestMessageProcessor(ISubscriptionServiceSetClientMethods session, diff --git a/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemManagerTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemManagerTests.cs index fe9aceed02..799b56c517 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemManagerTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemManagerTests.cs @@ -163,6 +163,61 @@ public async Task TryRequeueFailedItemSucceedsAsync() } } + /// + /// A transient per-item create failure must not be retried + /// back-to-back inside one apply pass: that would exhaust the retry + /// budget within milliseconds and leave the item un-created with no + /// pending change for the owner's backoff to reschedule. + /// + [Test] + public async Task ApplyChangesDoesNotRetryFailedCreateWithinOnePassAsync() + { + var services = new Mock(); + int createCalls = 0; + services + .Setup(s => s.CreateMonitoredItemsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(() => + { + createCalls++; + return new CreateMonitoredItemsResponse + { + Results = + [ + new MonitoredItemCreateResult + { + StatusCode = StatusCodes.BadResourceUnavailable + } + ] + }; + }); + m_context.MonitoredItemServiceSet = services.Object; + + var sut = new MonitoredItemManager(m_context, m_telemetry); + await using (sut.ConfigureAwait(false)) + { + Assert.That(sut.TryAdd("Item", OptionsFactory.Create(), + out IMonitoredItem monitoredItem), Is.True); + var item = (TestMonitoredItem)monitoredItem; + + await sut.ApplyChangesAsync(false, false, default).ConfigureAwait(false); + + Assert.That(createCalls, Is.EqualTo(1)); + Assert.That(item.Created, Is.False); + Assert.That(item.HasPendingChanges, Is.True, + "the failed create must stay queued for the next pass"); + Assert.That(sut.HasPendingChanges, Is.True); + + // The next pass (after the owner's backoff) retries it. + await sut.ApplyChangesAsync(false, false, default).ConfigureAwait(false); + Assert.That(createCalls, Is.EqualTo(2)); + } + } + [Test] public async Task PauseAndUnpauseMonitoredItemsAsync() { diff --git a/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemTests.cs index 2dcd499b01..dc488b069f 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemTests.cs @@ -95,6 +95,56 @@ public void ServerIdShouldGet() Assert.That(((IMonitoredItemApplyState)sut).HasPendingChanges, Is.False); } + /// + /// A null and an empty IndexRange both mean "not used" (Part 4 + /// §7.27). A restored item carries "" from the snapshot while the + /// application re-applies null; that must not force a recreate. + /// + [Test] + public void NullAndEmptyIndexRangeDoNotForceRecreate() + { + m_options.Configure(o => o with + { + StartNodeId = new NodeId("test", 0) + }); + var sut = new TestMonitoredItem(m_context, + m_options, m_telemetry.CreateLogger("MonitoredItem")); + Assert.That(sut.TryGetPendingChange(out MonitoredItem.Change change), Is.True); + change.SetCreateResult(new MonitoredItemCreateRequest + { + MonitoringMode = MonitoringMode.Sampling, + RequestedParameters = new MonitoringParameters + { + ClientHandle = sut.ClientHandle, + SamplingInterval = 1000, + QueueSize = 5, + DiscardOldest = true + } + }, new MonitoredItemCreateResult + { + StatusCode = StatusCodes.Good, + MonitoredItemId = 5, + RevisedSamplingInterval = 1000, + RevisedQueueSize = 5 + }, 0, [], new ResponseHeader()); + Assert.That(sut.Created, Is.True); + + MonitoredItemOptions options = m_options.CurrentValue; + Assert.That(options.IndexRange, Is.Null); + var sameRange = new MonitoredItem.Change(sut, options, + options with { IndexRange = string.Empty }); + Assert.That(sameRange.ForceRecreate, Is.False); + var otherRange = new MonitoredItem.Change(sut, options, + options with { IndexRange = "1:2" }); + Assert.That(otherRange.ForceRecreate, Is.True); + + // The snapshot round trip restores the options default. + MonitoredItemOptions restored = MonitoredItemStateSnapshot + .AsOptions("test", options, sut.ClientHandle, sut.ServerId, null) + .ToOptions(); + Assert.That(restored.IndexRange, Is.Null); + } + [Test] public void FailedChangeCanBeRequeued() { diff --git a/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionManagerTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionManagerTests.cs index ab3fdd5bda..5dab4619f2 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionManagerTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionManagerTests.cs @@ -348,6 +348,117 @@ public async Task SendPublishRequestsWithSuccessAsync() .Count(c => ReferenceEquals(c.Options, so1)), Is.EqualTo(1)); } + /// + /// A revised publishing interval times keep-alive count beyond + /// must not fault the publish workers + /// (which the controller would respawn in a hot loop without ever + /// sending a Publish). + /// + [Test] + [CancelAfter(10_000)] + public async Task PublishWorkerSurvivesOverflowingKeepAliveProductAsync( + CancellationToken testCt) + { + ILoggerFactory loggerFactory = m_telemetry.LoggerFactory; + var session = new FakeSubscriptionManagerContext(); + OptionsMonitor so1 = OptionsFactory.Create(); + var ms1 = new FakeManagedSubscription + { + Id = 1, + Created = true, + CurrentPublishingInterval = TimeSpan.FromHours(1), + CurrentKeepAliveCount = uint.MaxValue + }; + var sut = new SubscriptionManager(session, + loggerFactory, DiagnosticsMasks.None); + session.CreateSubscriptionFactory = (_, _, _) => ms1; + sut.Add(m_mockNotificationDataHandler.Object, so1); + sut.MaxPublishWorkerCount = 1; + + var published = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + session.OnPublishAsync = (h, s, ct) => + { + published.TrySetResult(h.TimeoutHint); + return new ValueTask(new PublishResponse + { + AvailableSequenceNumbers = [], + NotificationMessage = new NotificationMessage(), + Results = s.ConvertAll(_ => StatusCodes.Good), + SubscriptionId = 1, + MoreNotifications = false, + ResponseHeader = new ResponseHeader + { + ServiceResult = StatusCodes.Good, + StringTable = [] + } + }); + }; + + sut.Resume(); + try + { + uint timeoutHint = await published.Task.WaitAsync(testCt).ConfigureAwait(false); + Assert.That(timeoutHint, Is.GreaterThan(0u)); + } + finally + { + await sut.DisposeAsync().ConfigureAwait(false); + } + } + + /// + /// When a secondary partition of a multi-partition snapshot fails to + /// attach, the already registered primary must be rolled back: the + /// caller never receives the wrapper and could not dispose it. + /// + [Test] + public async Task RestoreGroupRollsBackPrimaryWhenSecondaryFailsAsync() + { + var session = new FakeSubscriptionManagerContext + { + OnTransferSubscriptionsAsync = (_, _, _, _) => + new ValueTask( + new TransferSubscriptionsResponse + { + ResponseHeader = new ResponseHeader + { + ServiceResult = StatusCodes.BadServiceUnsupported + } + }) + }; + var primary = new FakeManagedSubscription { Id = 7, Created = true }; + int created = 0; + session.CreateSubscriptionFactory = (_, _, _) => + { + if (++created == 1) + { + return primary; + } + throw ServiceResultException.Create(StatusCodes.BadOutOfMemory, + "Secondary partition could not be created."); + }; + var sut = new SubscriptionManager(session, + m_telemetry.LoggerFactory, DiagnosticsMasks.None); + await using (sut.ConfigureAwait(false)) + { + var options = new SubscriptionOptions(); + SubscriptionStateSnapshot[] snapshots = + [ + SubscriptionStateSnapshot.AsOptions(options, 7, [], []), + SubscriptionStateSnapshot.AsOptions(options, 8, [], []) + ]; + + Assert.ThrowsAsync(async () => + await sut.RestoreGroupAsync(m_mockNotificationDataHandler.Object, + snapshots, true, default).ConfigureAwait(false)); + + Assert.That(sut.Count, Is.Zero); + Assert.That(sut.Items, Is.Empty); + Assert.That(primary.DisposeAsyncCalls, Is.GreaterThan(0)); + } + } + [Test] public void TransferSubscriptionsOnRecreateSetAndGet() { @@ -1174,6 +1285,114 @@ await WaitUntilAsync(() => session.DeleteCallsCount > 0, testCt) } } + /// + /// The creation can complete between the worker's id lookup and its + /// pending-creation check (the id is assigned before the in-progress + /// flag clears). The freshly created subscription must receive the + /// response instead of being deleted as an orphan. + /// + [Test] + [CancelAfter(30_000)] + public async Task PublishWorkerDoesNotDeleteSubscriptionCreatedDuringLookupAsync( + CancellationToken testCt) + { + var session = new FakeSubscriptionManagerContext(); + OptionsMonitor options = + OptionsFactory.Create(); + var creating = new FakeManagedSubscription { Id = 0u, Created = true }; + int armed = 0; + creating.OnIsCreationInProgress = () => + { + if (Volatile.Read(ref armed) != 0) + { + // The creation completes right as the worker checks. + creating.Id = 4242u; + } + return false; + }; + + var sut = new SubscriptionManager(session, + m_telemetry.LoggerFactory, DiagnosticsMasks.None); + await using (sut.ConfigureAwait(false)) + { + session.CreateSubscriptionFactory = (_, _, _) => creating; + sut.Add(m_mockNotificationDataHandler.Object, options); + + session.OnPublishAsync = (h, a, ct) => + { + Volatile.Write(ref armed, 1); + return new ValueTask( + CreatePublishResponse(4242u, h.RequestHandle)); + }; + + sut.MinPublishWorkerCount = 1; + sut.MaxPublishWorkerCount = 1; + sut.Resume(); + + await WaitUntilAsync(() => creating.OnPublishReceivedCalls.Count > 0, + testCt).ConfigureAwait(false); + Assert.That(session.DeleteCalls, Is.Empty); + } + } + + /// + /// The creation is still pending when the worker first looks, and + /// completes (id assigned, in-progress flag cleared) before the + /// worker decides what to do with the unresolved response. The + /// orphan delete must be decided on the same pending snapshot the + /// lookup used, so the healthy subscription is never deleted. + /// + [Test] + [CancelAfter(30_000)] + public async Task PublishWorkerDoesNotDeleteSubscriptionCreatedAfterPendingCheckAsync( + CancellationToken testCt) + { + var session = new FakeSubscriptionManagerContext(); + OptionsMonitor options = + OptionsFactory.Create(); + var creating = new FakeManagedSubscription { Id = 0u, Created = true }; + int armed = 0; + int pendingReads = 0; + creating.OnIsCreationInProgress = () => + { + if (Volatile.Read(ref armed) == 0) + { + return true; + } + if (Interlocked.Increment(ref pendingReads) == 1) + { + // Still pending at the first check after the response. + return true; + } + // The creation completes right after that first check. + creating.Id = 4242u; + return false; + }; + + var sut = new SubscriptionManager(session, + m_telemetry.LoggerFactory, DiagnosticsMasks.None); + await using (sut.ConfigureAwait(false)) + { + session.CreateSubscriptionFactory = (_, _, _) => creating; + sut.Add(m_mockNotificationDataHandler.Object, options); + + session.OnPublishAsync = (h, a, ct) => + { + Volatile.Write(ref armed, 1); + return new ValueTask( + CreatePublishResponse(4242u, h.RequestHandle)); + }; + + sut.MinPublishWorkerCount = 1; + sut.MaxPublishWorkerCount = 1; + sut.Resume(); + + await WaitUntilAsync(() => creating.OnPublishReceivedCalls.Count > 0, + testCt).ConfigureAwait(false); + Assert.That(session.DeleteCalls, Is.Empty); + } + } + /// /// A subscription created through the classic Session.AddSubscription /// API is unknown to this manager's registry, but it is live and owned by diff --git a/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.cs index 12eda8955e..23623da183 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.cs @@ -212,6 +212,73 @@ public async Task ChangeSubscriptionOptionsShouldCallCreateAsyncIfNotCreatedAsyn } } + /// + /// Server-revised values at the edge of their wire types (an + /// hour-scale interval times a UInt32 keep-alive count exceeds + /// ) or a NaN interval must not abort + /// the create after the server already created the subscription. + /// + [TestCase(3_600_000d, uint.MaxValue - 1)] + [TestCase(double.NaN, 10u)] + [TestCase(1e300, 10u)] + [CancelAfter(10_000)] + public async Task CreateToleratesExtremeRevisedValuesAsync( + double revisedPublishingInterval, + uint revisedKeepAliveCount, + CancellationToken testCt) + { + m_mockSubscriptionServices + .Setup(s => s.CreateSubscriptionAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new CreateSubscriptionResponse + { + SubscriptionId = 22, + RevisedLifetimeCount = uint.MaxValue, + RevisedMaxKeepAliveCount = revisedKeepAliveCount, + RevisedPublishingInterval = revisedPublishingInterval + }); + m_mockSubscriptionServices + .Setup(s => s.DeleteSubscriptionsAsync( + It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new DeleteSubscriptionsResponse { Results = [StatusCodes.Good] }); + + var sut = new TestSubscription(m_session, m_mockNotificationDataHandler.Object, + m_completion, m_options, m_telemetry); + await using (sut.ConfigureAwait(false)) + { + await sut.WaitForCreatedAsync(testCt).ConfigureAwait(false); + + Assert.That(sut.Created, Is.True); + Assert.That(sut.Id, Is.EqualTo(22)); + Assert.That(sut.CurrentKeepAliveCount, Is.EqualTo(revisedKeepAliveCount)); + m_mockSubscriptionServices.Verify(s => s.CreateSubscriptionAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + } + } + + [Test] + public void SaturatingTimeSpanNeverThrows() + { + Assert.That(SaturatingTimeSpan.FromMilliseconds(double.NaN, TimeSpan.FromSeconds(3)), + Is.EqualTo(TimeSpan.FromSeconds(3))); + Assert.That(SaturatingTimeSpan.FromMilliseconds(double.PositiveInfinity, TimeSpan.Zero), + Is.EqualTo(TimeSpan.MaxValue)); + Assert.That(SaturatingTimeSpan.FromMilliseconds(-5, TimeSpan.FromSeconds(1)), + Is.EqualTo(TimeSpan.Zero)); + Assert.That(SaturatingTimeSpan.FromMilliseconds(1500, TimeSpan.Zero), + Is.EqualTo(TimeSpan.FromMilliseconds(1500))); + Assert.That(SaturatingTimeSpan.Multiply(TimeSpan.FromHours(1), uint.MaxValue), + Is.EqualTo(TimeSpan.MaxValue)); + Assert.That(SaturatingTimeSpan.Multiply(TimeSpan.FromSeconds(2), 3), + Is.EqualTo(TimeSpan.FromSeconds(6))); + Assert.That(SaturatingTimeSpan.FromHours(uint.MaxValue), Is.EqualTo(TimeSpan.MaxValue)); + Assert.That(SaturatingTimeSpan.FromHours(2), Is.EqualTo(TimeSpan.FromHours(2))); + } + [TestCase(false)] [TestCase(true)] [CancelAfter(10_000)] @@ -1258,6 +1325,128 @@ async Task WaitForServerAsync(CancellationToken tok } } + /// + /// A notification callback that awaits SetTriggeringAsync holds the + /// dispatch gate while waiting for an apply pass, which needs the + /// state lock. Dispose holds the state lock while it waits for the + /// dispatch gate. The pending operation must be abandoned so both + /// sides progress instead of deadlocking. + /// + [Test] + [CancelAfter(15_000)] + public async Task DisposeDoesNotDeadlockWithCallbackAwaitingSetTriggeringAsync( + CancellationToken testCt) + { + m_mockSubscriptionServices.Setup(service => service.DeleteSubscriptionsAsync( + It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new DeleteSubscriptionsResponse { Results = [StatusCodes.Good] }); + // Keep the apply pass from running so the queued triggering + // operation stays pending, as it would while the state lock is + // held by the delete. + m_completion.OnRunWithSessionAvailableAsync = (_, ct) => + new ValueTask(Task.Delay(Timeout.Infinite, ct)); + var subscription = new TestSubscription( + m_session, m_mockNotificationDataHandler.Object, m_completion, m_options, m_telemetry, + subscriptionIdForAlreadyCreatedState: 22); + Assert.That(subscription.MonitoredItems.TryAdd( + "Trigger", OptionsFactory.Create(), + out IMonitoredItem trigger), Is.True); + Assert.That(subscription.MonitoredItems.TryAdd( + "Triggered", OptionsFactory.Create(), + out IMonitoredItem triggered), Is.True); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var result = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + subscription.OnDataChangeAsync = async _ => + { + entered.TrySetResult(true); + try + { + result.TrySetResult(await subscription.SetTriggeringAsync( + trigger, [triggered]).ConfigureAwait(false)); + } + catch (Exception ex) + { + result.TrySetException(ex); + } + }; + + await subscription.OnPublishReceivedAsync(BuildDataMessage(1), null, []).ConfigureAwait(false); + await entered.Task.WaitAsync(testCt).ConfigureAwait(false); + + await subscription.DisposeAsync().AsTask().WaitAsync(testCt).ConfigureAwait(false); + + SetTriggeringResult setTriggering = await result.Task.WaitAsync(testCt).ConfigureAwait(false); + Assert.That(StatusCode.IsBad(setTriggering.ServiceResult), Is.True); + Assert.That(subscription.Disposed, Is.True); + } + + /// + /// A reset that gives up waiting for a running callback (its token + /// is cancelled) must withdraw its release request. Otherwise every + /// later SetTriggeringAsync from a callback is abandoned at once, + /// although no reset waits for the dispatch gate any more. + /// + [Test] + [CancelAfter(15_000)] + public async Task CancelledResetDoesNotAbandonLaterSetTriggeringFromCallbackAsync( + CancellationToken testCt) + { + m_completion.OnRunWithSessionAvailableAsync = (_, ct) => + new ValueTask(Task.Delay(Timeout.Infinite, ct)); + await using var subscription = new TestSubscription( + m_session, m_mockNotificationDataHandler.Object, m_completion, m_options, m_telemetry, + subscriptionIdForAlreadyCreatedState: 22); + Assert.That(subscription.MonitoredItems.TryAdd( + "Trigger", OptionsFactory.Create(), + out IMonitoredItem trigger), Is.True); + Assert.That(subscription.MonitoredItems.TryAdd( + "Triggered", OptionsFactory.Create(), + out IMonitoredItem triggered), Is.True); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var hold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calling = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var result = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var setTriggeringCts = new CancellationTokenSource(); + subscription.OnDataChangeAsync = async _ => + { + entered.TrySetResult(true); + await hold.Task.ConfigureAwait(false); + calling.TrySetResult(true); + try + { + result.TrySetResult(await subscription.SetTriggeringAsync( + trigger, [triggered], ct: setTriggeringCts.Token).ConfigureAwait(false)); + } + catch (Exception ex) + { + result.TrySetException(ex); + } + }; + + await subscription.OnPublishReceivedAsync(BuildDataMessage(1), null, []).ConfigureAwait(false); + await entered.Task.WaitAsync(testCt).ConfigureAwait(false); + + // The reset contends for the gate the callback holds, then gives up. + using (var resetCts = new CancellationTokenSource()) + { + resetCts.CancelAfter(200); + Assert.That(async () => await subscription.ResetToRecreateAsync(resetCts.Token) + .ConfigureAwait(false), Throws.InstanceOf()); + } + + hold.TrySetResult(true); + await calling.Task.WaitAsync(testCt).ConfigureAwait(false); + await Task.Delay(200, testCt).ConfigureAwait(false); + Assert.That(result.Task.IsCompleted, Is.False, + "No reset waits for the gate, so the operation must stay pending."); + + setTriggeringCts.Cancel(); + Assert.That(() => result.Task.WaitAsync(testCt), + Throws.InstanceOf()); + } + [Test] public async Task DisposalDrainsCallbackPastCleanupBudgetAndDisposesOwnedItemsAsync() { @@ -2088,6 +2277,70 @@ public async Task TryCompleteTransferAsyncShouldCallGetMonitoredItemsAsyncAndDoN } } + /// + /// A create whose response was lost and that was then issued again + /// leaves two server items with the same client handle (client + /// handles need not be unique, Part 4 §7.21). Synchronizing must keep + /// the bound item, delete the extra server item and not throw. + /// + [Test] + public async Task TryCompleteTransferAsyncShouldTolerateDuplicateClientHandlesAsync() + { + var sut = new TestSubscription(m_session, m_mockNotificationDataHandler.Object, + m_completion, m_options, m_telemetry, 2); + await using (sut.ConfigureAwait(false)) + { + OptionsMonitor options = OptionsFactory.Create(); + bool success = sut.MonitoredItems.TryAdd("Test", options, out IMonitoredItem monitoredItem); + Assert.That(success, Is.True); + uint clientId = monitoredItem.ClientHandle; + uint serverId = monitoredItem.ServerId; + const uint duplicateServerId = 55555u; + Assert.That(serverId, Is.Not.EqualTo(duplicateServerId)); + + m_mockMethodServices + .Setup(s => s.CallAsync( + It.IsAny(), + It.Is>(r => + r.Count == 1 && + r[0].MethodId == MethodIds.Server_GetMonitoredItems), It.IsAny())) + .ReturnsAsync(new CallResponse + { + Results = + [ + new () + { + StatusCode = StatusCodes.Good, + OutputArguments = + [ + new Variant([duplicateServerId, serverId]), // serverHandles + new Variant([clientId, clientId]) // clientHandles + ] + } + ] + }) + .Verifiable(Times.Once); + m_mockMonitoredItemServices + .Setup(s => s.DeleteMonitoredItemsAsync(It.IsAny(), 2, + It.Is>(a => a.Count == 1 && a[0] == duplicateServerId), + It.IsAny())) + .ReturnsAsync(new DeleteMonitoredItemsResponse + { + Results = [StatusCodes.Good] + }) + .Verifiable(Times.Once); + + success = await sut.TryCompleteTransferAsync([], default).ConfigureAwait(false); + + Assert.That(success, Is.True); + Assert.That(monitoredItem.ServerId, Is.EqualTo(serverId)); + Assert.That(sut.MonitoredItems.TryGetMonitoredItemByClientHandle( + clientId, out IMonitoredItem resolved), Is.True); + Assert.That(resolved, Is.SameAs(monitoredItem)); + m_mockMonitoredItemServices.Verify(); + } + } + [Test] public async Task TryCompleteTransferAsyncShouldCallGetMonitoredItemsAsyncAndDeleteItemIfNotInSubscriptionAsync() { diff --git a/tests/Opc.Ua.Client.Tests/Utils/AsyncReaderWriterLockTests.cs b/tests/Opc.Ua.Client.Tests/Utils/AsyncReaderWriterLockTests.cs index cfc1b19012..73a6d4f406 100644 --- a/tests/Opc.Ua.Client.Tests/Utils/AsyncReaderWriterLockTests.cs +++ b/tests/Opc.Ua.Client.Tests/Utils/AsyncReaderWriterLockTests.cs @@ -72,6 +72,102 @@ public async Task ReaderAvailabilityHookIsCapturedOnceAndNullIsAllowedAsync(bool Assert.That(calls, Is.EqualTo(hasHook ? 1 : 0)); } + /// + /// L2-3: recovery must not wait for readers stuck on a dead channel + /// until their request timeout. With a drain timeout the writer + /// proceeds while stale readers are still active, and new readers + /// stay excluded until the writer releases. + /// + [Test] + public async Task WriterWithDrainTimeoutProceedsPastStuckReaderAsync() + { + using var rwLock = new AsyncReaderWriterLock(); + AsyncReaderWriterLock.Releaser stuckReader = + await rwLock.ReaderLockAsync().ConfigureAwait(false); + + Task writerTask = rwLock + .WriterLockAsync(TimeSpan.FromMilliseconds(100), TimeProvider.System, () => true) + .AsTask(); + AsyncReaderWriterLock.Releaser writer = + await writerTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Task newReader = rwLock.ReaderLockAsync().AsTask(); + await Task.Delay(50).ConfigureAwait(false); + Assert.That(newReader.IsCompleted, Is.False, + "New readers must wait while the writer holds the lock."); + + stuckReader.Dispose(); + writer.Dispose(); + using AsyncReaderWriterLock.Releaser reader = + await newReader.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + // The lock is consistent afterwards: a plain writer drains normally. + reader.Dispose(); + using AsyncReaderWriterLock.Releaser next = await rwLock.WriterLockAsync() + .AsTask().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + + /// + /// Review G8a: while the readers can still complete normally (healthy + /// channel), the recovery writer stays exclusive past the drain + /// timeout and proceeds only once the readers leave. + /// + [Test] + public async Task WriterWithDrainTimeoutStaysExclusiveWhileReadersMayCompleteAsync() + { + using var rwLock = new AsyncReaderWriterLock(); + AsyncReaderWriterLock.Releaser reader = + await rwLock.ReaderLockAsync().ConfigureAwait(false); + int checks = 0; + + Task writerTask = rwLock + .WriterLockAsync(TimeSpan.FromMilliseconds(50), TimeProvider.System, () => + { + Interlocked.Increment(ref checks); + return false; + }) + .AsTask(); + + await Task.Delay(400).ConfigureAwait(false); + Assert.That(writerTask.IsCompleted, Is.False, + "The writer must keep waiting for readers on a healthy channel."); + Assert.That(Volatile.Read(ref checks), Is.GreaterThan(1), + "The channel health must be re-checked at every drain timeout."); + + reader.Dispose(); + using AsyncReaderWriterLock.Releaser writer = + await writerTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + + /// + /// Review G8a: the channel may die while the writer waits for the + /// readers; the next check then lets the writer proceed. + /// + [Test] + public async Task WriterWithDrainTimeoutProceedsOnceChannelBecomesUnhealthyAsync() + { + using var rwLock = new AsyncReaderWriterLock(); + AsyncReaderWriterLock.Releaser stuckReader = + await rwLock.ReaderLockAsync().ConfigureAwait(false); + bool unhealthy = false; + + Task writerTask = rwLock + .WriterLockAsync(TimeSpan.FromMilliseconds(50), TimeProvider.System, () => Volatile.Read(ref unhealthy)) + .AsTask(); + + await Task.Delay(200).ConfigureAwait(false); + Assert.That(writerTask.IsCompleted, Is.False); + + Volatile.Write(ref unhealthy, true); + using (await writerTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false)) + { + stuckReader.Dispose(); + } + + using AsyncReaderWriterLock.Releaser next = await rwLock.WriterLockAsync() + .AsTask().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + [Test] public async Task ReadersDoNotMutuallyExcludeAsync() { diff --git a/tests/Opc.Ua.Client.Tests/WebApi/WebApiTransportChannelTests.cs b/tests/Opc.Ua.Client.Tests/WebApi/WebApiTransportChannelTests.cs index e129b8dc8c..1cf52d9927 100644 --- a/tests/Opc.Ua.Client.Tests/WebApi/WebApiTransportChannelTests.cs +++ b/tests/Opc.Ua.Client.Tests/WebApi/WebApiTransportChannelTests.cs @@ -28,6 +28,10 @@ * ======================================================================*/ using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Sockets; using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -181,5 +185,267 @@ public void NormalizeUrlMapsOpcUaSchemesToHttps() Assert.That(https.Scheme, Is.EqualTo(Uri.UriSchemeHttps)); Assert.That(unchanged, Is.EqualTo(new Uri("https://localhost:4843/ua"))); } + + [TestCase(503)] + [TestCase(429)] + public async Task SendRequestMapsThrottlingToServerTooBusyAsync(int status) + { + using WebApiTransportChannel channel = await OpenChannelAsync( + new StubHandler((_, _) => + { + var response = new HttpResponseMessage((HttpStatusCode)status); + response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(2)); + return Task.FromResult(response); + })) + .ConfigureAwait(false); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await channel.SendRequestAsync(NewReadRequest(), CancellationToken.None) + .ConfigureAwait(false)); + // Same on every target framework (the library maps the status code + // itself instead of relying on HttpRequestException.StatusCode). + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadServerTooBusy)); + Assert.That(ex.Result.AdditionalInfo, Is.EqualTo("RetryAfterMs=2000")); + } + + [Test] + public async Task SendRequestMapsHttpErrorStatusToServiceResultExceptionAsync() + { + using WebApiTransportChannel channel = await OpenChannelAsync( + new StubHandler((_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.InternalServerError)))) + .ConfigureAwait(false); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await channel.SendRequestAsync(NewReadRequest(), CancellationToken.None) + .ConfigureAwait(false)); + Assert.That(StatusCode.IsBad(ex.StatusCode), Is.True); + Assert.That(ex.InnerException, Is.TypeOf()); + } + + [Test] + public async Task SendRequestMapsConnectionFailureToServiceResultExceptionAsync() + { + using WebApiTransportChannel channel = await OpenChannelAsync( + new StubHandler((_, _) => throw new HttpRequestException( + "connection refused", + new SocketException((int)SocketError.ConnectionRefused)))) + .ConfigureAwait(false); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await channel.SendRequestAsync(NewReadRequest(), CancellationToken.None) + .ConfigureAwait(false)); + Assert.That(StatusCode.IsBad(ex.StatusCode), Is.True); + } + + [Test] + public async Task SendRequestAppliesOperationTimeoutAsync() + { + using WebApiTransportChannel channel = await OpenChannelAsync( + new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + })) + .ConfigureAwait(false); + channel.OperationTimeout = 200; + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await channel.SendRequestAsync(NewReadRequest(), CancellationToken.None) + .ConfigureAwait(false)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadRequestTimeout)); + } + + [Test] + public async Task SendRequestPropagatesCallerCancellationAsync() + { + using WebApiTransportChannel channel = await OpenChannelAsync( + new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + })) + .ConfigureAwait(false); + using var cts = new CancellationTokenSource(100); + + Assert.That( + async () => await channel.SendRequestAsync(NewReadRequest(), cts.Token) + .ConfigureAwait(false), + Throws.InstanceOf()); + } + + /// + /// Review G14: the HttpClient timeout is infinite (OperationTimeout + /// governs each request), so the GetEndpoints hydrate issued by + /// OpenAsync must be bounded by OperationTimeout as well - a server + /// that never answers must not hang the open forever. + /// + [Test] + public async Task OpenBoundsEndpointHydrateByOperationTimeoutAsync() + { + using var channel = new WebApiTransportChannel( + NUnitTelemetryContext.Create(), + new WebApiClientOptions + { + HttpMessageHandler = new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + }) + }); + TransportChannelSettings settings = CreateSettings(); + settings.Configuration!.OperationTimeout = 200; + settings.Description = new EndpointDescription + { + EndpointUrl = "https://localhost:4843/", + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None + }; + + Task open = channel.OpenAsync( + new Uri("https://localhost:4843/"), + settings, + CancellationToken.None).AsTask(); + Task completed = await Task.WhenAny(open, Task.Delay(30_000)).ConfigureAwait(false); + + Assert.That(completed, Is.SameAs(open), "OpenAsync hung on the endpoint hydrate."); + await open.ConfigureAwait(false); + Assert.That(settings.Description.ServerCertificate.Length, Is.Zero); + } + + /// + /// Review G14: caller cancellation of the open is not swallowed by the + /// best-effort endpoint hydrate. + /// + [Test] + public void OpenPropagatesCallerCancellationDuringEndpointHydrate() + { + using var channel = new WebApiTransportChannel( + NUnitTelemetryContext.Create(), + new WebApiClientOptions + { + HttpMessageHandler = new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + }) + }); + TransportChannelSettings settings = CreateSettings(); + settings.Description = new EndpointDescription + { + EndpointUrl = "https://localhost:4843/", + SecurityMode = MessageSecurityMode.None, + SecurityPolicyUri = SecurityPolicies.None + }; + using var cts = new CancellationTokenSource(100); + + Assert.That( + async () => await channel.OpenAsync( + new Uri("https://localhost:4843/"), + settings, + cts.Token).ConfigureAwait(false), + Throws.InstanceOf()); + } + + [Test] + public void OpenRejectsCredentialsOverPlainHttp() + { + using var channel = new WebApiTransportChannel( + NUnitTelemetryContext.Create(), + new WebApiClientOptions + { + BearerToken = "secret", + HttpMessageHandler = new StubHandler((_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK))) + }); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await channel.OpenAsync( + new Uri("http://localhost:4843/"), + CreateSettings(), + CancellationToken.None).ConfigureAwait(false)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public void PublicClientConstructorRejectsCredentialsOverPlainHttp() + { + using var httpClient = new HttpClient(new StubHandler((_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK)))) + { + BaseAddress = new Uri("http://localhost:4843/") + }; + + ServiceResultException ex = Assert.Throws( + () => new WebApiClient(httpClient, new WebApiClientOptions { BearerToken = "secret" })); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + Assert.That(httpClient.DefaultRequestHeaders.Authorization, Is.Null, + "The credentials must not be installed on the caller's client."); + } + + [Test] + public void PublicClientRejectsCredentialsWhenPlainHttpBaseAddressIsSetLater() + { + int sent = 0; + using var httpClient = new HttpClient(new StubHandler((_, _) => + { + sent++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + })); + using var client = new WebApiClient( + httpClient, + new WebApiClientOptions { BearerToken = "secret" }); + httpClient.BaseAddress = new Uri("http://localhost:4843/"); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await client.ReadAsync(NewReadRequest(), CancellationToken.None) + .ConfigureAwait(false)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + Assert.That(sent, Is.Zero); + } + + private static ReadRequest NewReadRequest() + { + return new ReadRequest { RequestHeader = new RequestHeader() }; + } + + private static TransportChannelSettings CreateSettings() + { + return new TransportChannelSettings + { + Configuration = EndpointConfiguration.Create(), + Factory = ServiceMessageContext.Create(NUnitTelemetryContext.Create()).Factory, + NamespaceUris = new NamespaceTable() + }; + } + + private static async Task OpenChannelAsync(HttpMessageHandler handler) + { + var channel = new WebApiTransportChannel( + NUnitTelemetryContext.Create(), + new WebApiClientOptions { HttpMessageHandler = handler }); + await channel.OpenAsync( + new Uri("https://localhost:4843/"), + CreateSettings(), + CancellationToken.None).ConfigureAwait(false); + return channel; + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func> m_send; + + public StubHandler(Func> send) + { + m_send = send; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return m_send(request, cancellationToken); + } + } } } diff --git a/tests/Opc.Ua.Redundancy.Client.Tests/ClientReplicaCoordinatorTests.cs b/tests/Opc.Ua.Redundancy.Client.Tests/ClientReplicaCoordinatorTests.cs index 459d039e9e..10b2123017 100644 --- a/tests/Opc.Ua.Redundancy.Client.Tests/ClientReplicaCoordinatorTests.cs +++ b/tests/Opc.Ua.Redundancy.Client.Tests/ClientReplicaCoordinatorTests.cs @@ -316,6 +316,342 @@ public async Task LeaderPromotionRecoversWithFreshSessionWhenTokenReuseReactivat "the coordinator must not be left holding the broken, half-reactivated session"); } + [TestCase(ClientStandbyMode.Warm)] + [TestCase(ClientStandbyMode.Hot)] + public async Task DemotedWarmOrHotLeaderReplacesItsLeaderSessionWithAFreshStandbyAsync( + ClientStandbyMode mode) + { + using var store = new InMemorySharedKeyValueStore(); + var endpoint = new ConfiguredEndpoint(null!, new EndpointDescription("opc.tcp://demote:4840")); + ManagedSession leaderSession = CreateManagedSessionForTokenReuse(endpoint, NodeId.Parse("s=auth")); + ManagedSession standbySession = CreateManagedSessionForTokenReuse(endpoint, NodeId.Parse("s=auth")); + int created = 0; + var configured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var demoted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var election = new ControllableLeaderElection(); + + var options = new ClientReplicaOptions + { + Mode = mode, + EnableTokenReuse = false, + CreateSessionAsync = _ => + { + created++; + return new ValueTask(created == 1 ? leaderSession : standbySession); + }, + ConfigureLeaderAsync = (_, fastActivated, _) => + { + configured.TrySetResult(fastActivated); + return default; + } + }; + await using var coordinator = new ClientReplicaCoordinator( + options, election, store, NullRecordProtector.Instance, m_telemetry); + coordinator.RoleChanged += isLeader => + { + if (!isLeader) + { + demoted.TrySetResult(true); + } + }; + + await coordinator.StartAsync().ConfigureAwait(false); + election.SetLeader(true); + await configured.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(coordinator.CurrentSession, Is.SameAs(leaderSession)); + + // The leader loses the lease while still alive. + election.SetLeader(false); + await demoted.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + + Assert.That( + leaderSession.Disposed, + Is.True, + "the ex-leader must not keep running the leader session next to the new leader"); + // The standby connect follows the demotion notification. + Assert.That(() => coordinator.CurrentSession, Is.SameAs(standbySession).After(10000, 20)); + Assert.That(created, Is.EqualTo(2)); + } + + [Test] + public async Task TokenReusePromotionClosesTheFollowersOwnSessionBeforeAdoptingTheLeaderSessionAsync() + { + using var store = new InMemorySharedKeyValueStore(); + using var seedSession = SessionMock.Create(); + seedSession.SetConnected(); + SetServerNonce(seedSession, [1, 2, 3, 4]); + using var stream = new MemoryStream(); + seedSession.SaveSessionConfiguration(stream); + await store.SetAsync("client-replica/session", new ByteString(stream.ToArray())).ConfigureAwait(false); + + ConfiguredEndpoint expectedEndpoint = seedSession.ConfiguredEndpoint; + var followerToken = NodeId.Parse("s=follower-auth"); + var closedTokens = new List<(NodeId Token, bool DeleteSubscriptions)>(); + ManagedSession followerSession = CreateManagedSessionForTokenReuse( + expectedEndpoint, + NodeId.Parse("s=auth"), + followerToken, + closedTokens); + var configured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var options = new ClientReplicaOptions + { + Mode = ClientStandbyMode.Warm, + EnableTokenReuse = true, + CreateSessionAsync = _ => new ValueTask(followerSession), + ConfigureLeaderAsync = (_, fastActivated, _) => + { + configured.TrySetResult(fastActivated); + return default; + } + }; + await using var coordinator = new ClientReplicaCoordinator( + options, + new StaticLeaderElection(true), + store, + NullRecordProtector.Instance, + m_telemetry); + + await coordinator.StartAsync().ConfigureAwait(false); + + bool fastActivated = await configured.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + + Assert.That(fastActivated, Is.True); + Assert.That( + closedTokens, + Has.Count.EqualTo(1), + "the follower's own server session must be closed, not orphaned"); + Assert.That(closedTokens[0].Token, Is.EqualTo(followerToken)); + Assert.That(closedTokens[0].DeleteSubscriptions, Is.True); + } + + [Test] + public async Task PromotionRetryKeepsTheAdoptedMirroredSessionAsync() + { + using var store = new InMemorySharedKeyValueStore(); + using var seedSession = SessionMock.Create(); + seedSession.SetConnected(); + SetServerNonce(seedSession, [1, 2, 3, 4]); + using var stream = new MemoryStream(); + seedSession.SaveSessionConfiguration(stream); + await store.SetAsync("client-replica/session", new ByteString(stream.ToArray())).ConfigureAwait(false); + + ConfiguredEndpoint endpoint = seedSession.ConfiguredEndpoint; + var closedOnOwnChannel = new List<(NodeId Token, bool DeleteSubscriptions)>(); + var closedOnSharedChannel = new List<(NodeId Token, bool DeleteSubscriptions)>(); + ManagedSession followerSession = CreateManagedSessionForTokenReuse( + endpoint, + NodeId.Parse("s=auth"), + NodeId.Parse("s=follower-auth"), + closedOnOwnChannel, + closedOnSharedChannel); + int configureCalls = 0; + var configured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var options = new ClientReplicaOptions + { + Mode = ClientStandbyMode.Warm, + EnableTokenReuse = true, + CreateSessionAsync = _ => new ValueTask(followerSession), + ConfigureLeaderAsync = (_, fastActivated, _) => + { + if (++configureCalls == 1) + { + // A transient failure after the mirrored session was adopted. + throw new ServiceResultException(StatusCodes.BadCommunicationError); + } + configured.TrySetResult(fastActivated); + return default; + } + }; + await using var coordinator = new ClientReplicaCoordinator( + options, + new StaticLeaderElection(true), + store, + NullRecordProtector.Instance, + m_telemetry); + + await coordinator.StartAsync().ConfigureAwait(false); + + bool fastActivated = await configured.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + + Assert.That(configureCalls, Is.EqualTo(2)); + Assert.That(fastActivated, Is.True, "the retry still runs on the adopted mirrored session"); + Assert.That(coordinator.CurrentSession, Is.SameAs(followerSession)); + Assert.That(followerSession.SessionId, Is.EqualTo(seedSession.SessionId)); + Assert.That( + closedOnSharedChannel, + Is.Empty, + "the retry must not close the adopted mirrored leader session"); + Assert.That(closedOnOwnChannel, Has.Count.EqualTo(1), "only the follower's own session is closed"); + } + + [TestCase(false)] + [TestCase(true)] + public async Task DemotionIsReportedEvenWhenTheStandbyConnectFailsOrHangsAsync(bool hang) + { + using var store = new InMemorySharedKeyValueStore(); + var endpoint = new ConfiguredEndpoint(null!, new EndpointDescription("opc.tcp://demote:4840")); + ManagedSession leaderSession = CreateManagedSessionForTokenReuse(endpoint, NodeId.Parse("s=auth")); + int created = 0; + var releaseStandby = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var configured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var demoted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var election = new ControllableLeaderElection(); + + var options = new ClientReplicaOptions + { + Mode = ClientStandbyMode.Warm, + EnableTokenReuse = false, + CreateSessionAsync = _ => + { + created++; + if (created == 1) + { + return new ValueTask(leaderSession); + } + if (hang) + { + return new ValueTask(releaseStandby.Task); + } + throw new ServiceResultException(StatusCodes.BadTooManySessions); + }, + ConfigureLeaderAsync = (_, fastActivated, _) => + { + configured.TrySetResult(fastActivated); + return default; + } + }; + await using var coordinator = new ClientReplicaCoordinator( + options, election, store, NullRecordProtector.Instance, m_telemetry); + coordinator.RoleChanged += isLeader => + { + if (!isLeader) + { + demoted.TrySetResult(true); + } + }; + + await coordinator.StartAsync().ConfigureAwait(false); + election.SetLeader(true); + await configured.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + + election.SetLeader(false); + try + { + await demoted.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(coordinator.CurrentSession, Is.Null); + Assert.That(leaderSession.Disposed, Is.True); + } + finally + { + // Unblock the pending standby connect so the coordinator can dispose. + releaseStandby.TrySetCanceled(); + } + } + + [TestCase(ClientStandbyMode.Warm)] + [TestCase(ClientStandbyMode.Hot)] + public async Task DemotedTokenReuseLeaderAbandonsTheSharedSessionWithoutClosingItAsync( + ClientStandbyMode mode) + { + using var store = new InMemorySharedKeyValueStore(); + using var seedSession = SessionMock.Create(); + seedSession.SetConnected(); + SetServerNonce(seedSession, [1, 2, 3, 4]); + using var stream = new MemoryStream(); + seedSession.SaveSessionConfiguration(stream); + await store.SetAsync("client-replica/session", new ByteString(stream.ToArray())).ConfigureAwait(false); + + ConfiguredEndpoint endpoint = seedSession.ConfiguredEndpoint; + var closedOnOwnChannel = new List<(NodeId Token, bool DeleteSubscriptions)>(); + var closedOnSharedChannel = new List<(NodeId Token, bool DeleteSubscriptions)>(); + ManagedSession leaderSession = CreateManagedSessionForTokenReuse( + endpoint, + NodeId.Parse("s=auth"), + NodeId.Parse("s=follower-auth"), + closedOnOwnChannel, + closedOnSharedChannel); + ManagedSession standbySession = CreateManagedSessionForTokenReuse(endpoint, NodeId.Parse("s=auth")); + int created = 0; + var configured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var demoted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var election = new ControllableLeaderElection(); + + var options = new ClientReplicaOptions + { + Mode = mode, + EnableTokenReuse = true, + CreateSessionAsync = _ => + { + created++; + return new ValueTask(created == 1 ? leaderSession : standbySession); + }, + ConfigureLeaderAsync = (_, fastActivated, _) => + { + configured.TrySetResult(fastActivated); + return default; + } + }; + await using var coordinator = new ClientReplicaCoordinator( + options, election, store, NullRecordProtector.Instance, m_telemetry); + coordinator.RoleChanged += isLeader => + { + if (!isLeader) + { + demoted.TrySetResult(true); + } + }; + + await coordinator.StartAsync().ConfigureAwait(false); + election.SetLeader(true); + Assert.That( + await configured.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), + Is.True, + "the leader must have adopted the mirrored session"); + NodeId sharedSessionId = leaderSession.SessionId; + Assert.That(sharedSessionId, Is.EqualTo(seedSession.SessionId)); + + // The leader loses the lease while it can still reach the server. + election.SetLeader(false); + await demoted.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + + Assert.That(leaderSession.Disposed, Is.True, "the ex-leader must stop using the shared session"); + Assert.That( + closedOnSharedChannel, + Is.Empty, + "the ex-leader must not close the server session the new leader reactivates"); + Assert.That(closedOnOwnChannel, Has.Count.EqualTo(1), "only the follower's own session is closed"); + } + + private sealed class ControllableLeaderElection : ILeaderElection + { + public bool IsLeader { get; private set; } + + public event Action LeadershipChanged; + + public void SetLeader(bool isLeader) + { + IsLeader = isLeader; + LeadershipChanged?.Invoke(isLeader); + } + + public ValueTask TryAcquireOrRenewAsync(CancellationToken ct = default) + { + return new ValueTask(IsLeader); + } + + public void Start() + { + } + + public ValueTask DisposeAsync() + { + return default; + } + } + private sealed class FakeNetworkedStore : ISharedKeyValueStore { public ValueTask<(bool Found, ByteString Value)> TryGetAsync(string key, CancellationToken ct = default) @@ -356,7 +692,10 @@ public async IAsyncEnumerable WatchAsync( private static ManagedSession CreateManagedSessionForTokenReuse( ConfiguredEndpoint endpoint, - NodeId expectedAuthenticationToken) + NodeId expectedAuthenticationToken, + NodeId ownAuthenticationToken = default, + List<(NodeId Token, bool DeleteSubscriptions)> closedTokens = null, + List<(NodeId Token, bool DeleteSubscriptions)> closedOnReactivatedChannel = null) { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); ApplicationConfiguration configuration = new(telemetry) @@ -398,10 +737,35 @@ private static ManagedSession CreateManagedSessionForTokenReuse( DiagnosticInfos = [] })) .Verifiable(Times.Once); + if (closedOnReactivatedChannel != null) + { + // A live channel: a dispose would send CloseSession over it. + managedChannel.SetupGet(c => c.State).Returns(ChannelState.Ready); + } + managedChannel + .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .Returns((IServiceRequest request, CancellationToken _) => + { + var close = (CloseSessionRequest)request; + closedOnReactivatedChannel?.Add((close.RequestHeader.AuthenticationToken, close.DeleteSubscriptions)); + return new ValueTask(new CloseSessionResponse()); + }); + // The standby's own session still runs on its original channel until the + // mirrored reactivation builds a new one. + innerSession.Channel + .Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .Returns((IServiceRequest request, CancellationToken _) => + { + var close = (CloseSessionRequest)request; + closedTokens?.Add((close.RequestHeader.AuthenticationToken, close.DeleteSubscriptions)); + return new ValueTask(new CloseSessionResponse()); + }); var channelManager = new Mock(); + // Match by URL: ManagedSession.ConfiguredEndpoint reports the inner + // session's endpoint, which is an equal but different instance. channelManager .Setup(m => m.GetAsync( - endpoint, + It.Is(e => e.EndpointUrl == endpoint.EndpointUrl), It.IsAny>(), null, It.IsAny())) @@ -412,6 +776,10 @@ private static ManagedSession CreateManagedSessionForTokenReuse( "BindManagedChannel", BindingFlags.NonPublic | BindingFlags.Instance)! .Invoke(innerSession, [channelManager.Object, managedChannel.Object]); + if (!ownAuthenticationToken.IsNull) + { + innerSession.SessionCreated(NodeId.Parse("s=follower-session"), ownAuthenticationToken); + } ManagedSession managedSession = CreateManagedSessionWithInner( configuration, @@ -461,9 +829,11 @@ private static ManagedSession CreateManagedSessionThatFailsReactivation(Configur StatusCodes.BadSessionIdInvalid, "simulated: the prior leader's session no longer exists on the failover server")); var channelManager = new Mock(); + // Match by URL: ManagedSession.ConfiguredEndpoint reports the inner + // session's endpoint, which is an equal but different instance. channelManager .Setup(m => m.GetAsync( - endpoint, + It.Is(e => e.EndpointUrl == endpoint.EndpointUrl), It.IsAny>(), null, It.IsAny()))