diff --git a/docs/DependencyInjection.md b/docs/DependencyInjection.md index 83fddb83bf..e38ddba3b7 100644 --- a/docs/DependencyInjection.md +++ b/docs/DependencyInjection.md @@ -1016,7 +1016,7 @@ one policy per token type of the registered authenticators instead | Extension | Purpose | |---|---| -| `ConfigureRoles(Action)` / `(IConfiguration)` | Registers `RoleConfigurationOptions` for future role-related tuning. The DTO currently has no configurable members; the extension exists as a stable expansion point. | +| `ConfigureRoles(Action)` / `(IConfiguration)` | Configures roles and identity-mapping rules on the default role manager. Well-known roles are configured when the manager is resolved; custom role nodes are materialized when the server address space starts. | | `AddIdentityAuthenticator()` | Registers a single custom `IUserTokenAuthenticator` implementation. The hosted service adds it to `IServerInternal.IdentityRegistry` on startup. | | `AddIdentityAuthenticator(IUserTokenAuthenticator)` | Registers an already-constructed authenticator instance. | | `AddIdentityAuthenticator(Func)` | Creates an authenticator during startup with injected services and the effective certificate validator, which may be `null`. | @@ -1115,7 +1115,12 @@ services.AddOpcUa() })); ``` -Use `AddRoleManager(IRoleManager)` or `AddRoleManager()` to replace the default role manager. +Both `AddSecureServer` and `AddReferenceServer` honor `ConfigureRoles` and +previously registered `RoleConfigurationOptions`. They preserve an explicitly +registered `IRoleManager`; options do not silently replace or mutate that custom +manager. Configuration remains deferred until service resolution and startup. + +Use `AddRoleManager(IRoleManager)` or `AddRoleManager()` to explicitly replace the default role manager. `AddServer()` remains available for custom server implementations. Custom types that need session, subscription, or durable-subscription DI hooks must derive from `DependencyInjectionStandardServer`. The same diff --git a/docs/FileSystemClient.md b/docs/FileSystemClient.md index 2f341fb5ae..44e83e286f 100644 --- a/docs/FileSystemClient.md +++ b/docs/FileSystemClient.md @@ -333,6 +333,31 @@ or dispose the wrapper rather than closing the handle separately. Cleanup after a closed session uses the normal `BadSecureChannelClosed` protocol failure instead of dereferencing a missing channel. +For a bounded generated-file download, `CopyToAsync` accepts a caller-owned +writable stream and a non-negative byte limit: + +```csharp +var temp = new TemporaryFileTransferClient(managedSession, transferObject); +long copied = await temp.CopyToAsync( + generateOptions: default, destination, maxBytes: 64 * 1024 * 1024, ct); +``` + +With a `ManagedSession`, this operation captures one `SessionRequestContext` +for generation, readiness polling, copying and cleanup. Recovery invalidates +the operation instead of sending the old handle to a replacement session. +An already-dispatched generation response is retained even if the caller +cancels before it returns. A valid returned file node and handle are retained +for cleanup before non-Good statuses or malformed response metadata are rejected. +Close calls the standard `FileType.Close` declaration directly, without method +discovery, using an independent five-second deadline. Transfer and cleanup +failures are both reported when both occur. + +The destination remains open and may contain partial data on failure. An +ordinary or custom session remains caller-owned; prevent its replacement during +the transfer. The operation allows at most 1024 file reads of at most 64 KiB +(or a smaller configured chunk size), including the end-of-file probe, and at +most 64 generation-state reads. + ## Generation-bound transfers Use [`SessionRequestContext`](Sessions.md#generation-bound-client-requests) when diff --git a/docs/Subscriptions.md b/docs/Subscriptions.md index 83d0d04cfa..896ccab2ac 100644 --- a/docs/Subscriptions.md +++ b/docs/Subscriptions.md @@ -797,6 +797,19 @@ The restore path: subscriptions regroup items into the same affinity-pinned partition the source had. +After a snapshot transfer, V2 rebinds the saved item handles and reapplies the +saved monitoring parameters with `ModifyMonitoredItems`. The response supplies +the actual revised sampling interval, queue size and filter result: +`GetMonitoredItems` returns handles only. This does not recreate the items or +change their monitoring mode or triggering links. Live items that already have +revised parameters do not require this extra snapshot-restoration step. +Refresh enqueueing is ordered with concurrent options changes, so a restored +snapshot cannot overwrite newer requested sampling intervals, queue sizes or +filters. +A successful initiated transfer also reports `PublishState.Transferred` to the +target handler after handle synchronization, before attempting retained-message +recovery; failed synchronization does not report transfer completion. + Republishing retained notifications and requesting initial values are independent. If an origin session disconnects before acknowledging a message, a transfer can recover that message from the server's retransmission queue. With diff --git a/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.BoundedRead.cs b/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.BoundedRead.cs new file mode 100644 index 0000000000..86094f5c10 --- /dev/null +++ b/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.BoundedRead.cs @@ -0,0 +1,522 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Client.FileSystem +{ + public sealed partial class TemporaryFileTransferClient + { + /// + /// Generates one temporary read file and copies its contents to a + /// caller-owned stream without exceeding a byte or request limit. + /// + /// + /// + /// Reads request at most 64 KiB (or the smaller configured chunk size), + /// with at most 1024 file reads, including the end-of-file probe. A short + /// read is not treated as end-of-file. The first byte exceeding + /// stops the transfer before that chunk is + /// written. File size metadata is not used. Zero permits only an empty + /// file. Asynchronous generation permits at most 64 state reads. + /// + /// + /// The generation call is never replayed. Good service and method + /// statuses and correctly typed outputs are required. Once a valid file + /// NodeId and UInt32 handle have been returned, exactly one Close is + /// attempted, even if another output, reading, writing or cancellation + /// fails. Close uses the FileType declaration method directly, with a + /// fresh, independent five-second deadline. A managed session uses one + /// captured throughout the transfer + /// and cleanup; recovery invalidates it rather than sending an old + /// handle through a replacement session. + /// + /// + /// The destination and borrowed session are never closed or disposed. + /// On failure the destination may contain a partial download; the caller + /// is responsible for discarding it. Cancellation is passed to generation + /// rather than abandoning its task, so a late returned handle is still + /// closed. + /// + /// + /// Options identifying the file to generate. + /// The writable, caller-owned destination. + /// The non-negative maximum number of payload bytes. + /// Cancels generation and copying, but not cleanup. + /// The number of bytes written after successful cleanup. + /// The destination is null. + /// The destination is not writable. + /// + /// The byte limit is negative or a file-system option is outside its permitted range. + /// + /// + /// A limit is exceeded, a response is invalid, a session is replaced, + /// or a service or method reports a non-Good status. + /// + /// Generation or destination writing fails. + /// Copying or cleanup is cancelled. + /// + /// Transfer and cleanup both fail. The first inner exception is the + /// transfer failure; the second is the cleanup failure. + /// + public async ValueTask CopyToAsync( + Variant generateOptions, + Stream destination, + long maxBytes, + CancellationToken ct = default) + { + if (destination is null) + { + throw new ArgumentNullException(nameof(destination)); + } + if (!destination.CanWrite) + { + throw new ArgumentException("The destination must be writable.", nameof(destination)); + } + if (maxBytes < 0) + { + throw new ArgumentOutOfRangeException(nameof(maxBytes)); + } + FileSystemClientOptions options = Options.Clone(); + options.Validate(); + int chunkSize = Math.Min(options.ChunkSize, MaximumBoundedReadChunkSize); + ct.ThrowIfCancellationRequested(); + + SessionRequestContext? requestContext = Session is ManagedSession + ? await SessionRequestContext.CaptureAsync(Session, ct).ConfigureAwait(false) + : null; + try + { + return await CopyGeneratedFileAsync( + requestContext?.Session ?? Session, generateOptions, destination, maxBytes, chunkSize, ct) + .ConfigureAwait(false); + } + finally + { + if (requestContext != null) + { + await requestContext.DisposeAsync().ConfigureAwait(false); + } + } + } + + private async ValueTask CopyGeneratedFileAsync( + ISession session, + Variant generateOptions, + Stream destination, + long maxBytes, + int chunkSize, + CancellationToken ct) + { + NodeId sessionId = session.SessionId; + ITelemetryContext telemetry = session.MessageContext.Telemetry; + var transfer = new TemporaryFileTransferTypeClient(session, ObjectId, telemetry); + NodeId generateMethod = await ResolveBoundedReadMethodAsync( + transfer, sessionId, BrowseNames.GenerateFileForRead, ct).ConfigureAwait(false); + CallResponse generatedResponse = await SendBoundedReadMethodAsync( + transfer, + sessionId, + generateMethod, + [generateOptions], + ct).ConfigureAwait(false); + ArrayOf generated = + generatedResponse.Results.Count > 0 && generatedResponse.Results[0] is { } generationResult + ? generationResult.OutputArguments + : default; + + if (generated.Count < 2 || + !generated[0].TryGetValue(out NodeId fileNodeId) || + fileNodeId.IsNull || + !generated[1].TryGetValue(out uint handle)) + { + _ = GetBoundedReadOutputs(generatedResponse, 1); + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "GenerateFileForRead did not return a valid file NodeId and UInt32 handle."); + } + + var file = new FileTypeClient(session, fileNodeId, telemetry); + Exception? transferFailure = null; + long copied = 0; + try + { + ct.ThrowIfCancellationRequested(); + _ = GetBoundedReadOutputs(generatedResponse, 1); + if (generated.Count != 3 || + !generated[2].TryGetValue(out NodeId completionStateMachine)) + { + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "GenerateFileForRead did not return a valid completion state machine NodeId."); + } + if (!completionStateMachine.IsNull) + { + await WaitForBoundedReadTransferAsync(session, sessionId, completionStateMachine, ct) + .ConfigureAwait(false); + } + copied = await CopyBoundedFileAsync( + file, + sessionId, + handle, + destination, + maxBytes, + chunkSize, + ct).ConfigureAwait(false); + } + catch (Exception ex) + { + transferFailure = ex; + } + + using var cleanup = new CancellationTokenSource(s_boundedReadCleanupTimeout); + try + { + await CloseBoundedReadFileAsync(file, sessionId, handle, cleanup.Token) + .AsTask().WaitAsync(cleanup.Token).ConfigureAwait(false); + } + catch (Exception closeFailure) when (transferFailure is not null) + { + throw new AggregateException( + "The temporary file transfer and its cleanup both failed.", + transferFailure, + closeFailure); + } + + if (transferFailure is not null) + { + ExceptionDispatchInfo.Capture(transferFailure).Throw(); + } + return copied; + } + + private static async ValueTask CopyBoundedFileAsync( + FileTypeClient file, + NodeId sessionId, + uint handle, + Stream destination, + long maxBytes, + int chunkSize, + CancellationToken ct) + { +#if !NET + var buffer = new byte[chunkSize]; +#endif + NodeId readMethod = await ResolveBoundedReadMethodAsync( + file, sessionId, BrowseNames.Read, ct).ConfigureAwait(false); + long copied = 0; + for (int request = 0; request < MaximumBoundedFileReads; request++) + { + long remaining = maxBytes - copied; + int length = remaining < chunkSize ? (int)remaining + 1 : chunkSize; + CallResponse response = await SendBoundedReadMethodAsync( + file, + sessionId, + readMethod, + [Variant.From(handle), Variant.From(length)], + ct).ConfigureAwait(false); + ArrayOf output = GetBoundedReadOutputs(response, 2); + EnsureBoundedReadSession(file.Session, sessionId); + if (output.Count != 1 || !output[0].TryGetValue(out ByteString data)) + { + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "File Read did not return exactly one ByteString."); + } + if (data.Length > length) + { + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "File Read returned more bytes than requested."); + } + if (data.Length > remaining) + { + throw new ServiceResultException( + StatusCodes.BadEncodingLimitsExceeded, + "The temporary file exceeds the download byte limit."); + } + if (data.IsEmpty) + { + return copied; + } + ct.ThrowIfCancellationRequested(); +#if NET + await destination.WriteAsync(data.Memory, ct).ConfigureAwait(false); +#else + data.Span.CopyTo(buffer); + await destination.WriteAsync(buffer, 0, data.Length, ct).ConfigureAwait(false); +#endif + copied += data.Length; + } + throw new ServiceResultException( + StatusCodes.BadEncodingLimitsExceeded, + "The temporary file exceeds the download read-request limit."); + } + + private static async ValueTask CloseBoundedReadFileAsync( + FileTypeClient file, + NodeId sessionId, + uint handle, + CancellationToken ct) + { + CallResponse response = await SendBoundedReadMethodAsync( + file, sessionId, MethodIds.FileType_Close, [Variant.From(handle)], ct).ConfigureAwait(false); + ArrayOf closed = GetBoundedReadOutputs(response, 1); + if (closed.Count != 0) + { + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "File Close returned unexpected output arguments."); + } + } + + private static ValueTask ResolveBoundedReadMethodAsync( + ObjectTypeClient proxy, + NodeId sessionId, + string browseName, + CancellationToken ct) + { + return ResolveBoundedReadPathAsync( + proxy.Session, + sessionId, + proxy.ObjectId, + [ + new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HasComponent, + IncludeSubtypes = true, + TargetName = new QualifiedName(browseName) + } + ], + ct); + } + + private static async ValueTask WaitForBoundedReadTransferAsync( + ISession session, + NodeId sessionId, + NodeId stateMachine, + CancellationToken ct) + { + NodeId stateNode = await ResolveBoundedReadPathAsync( + session, + sessionId, + stateMachine, + [ + new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HasComponent, + IncludeSubtypes = true, + TargetName = new QualifiedName(BrowseNames.CurrentState) + }, + new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HasProperty, + IncludeSubtypes = true, + TargetName = new QualifiedName(BrowseNames.Id) + } + ], + ct).ConfigureAwait(false); + ArrayOf reads = [new ReadValueId { NodeId = stateNode, AttributeId = Attributes.Value }]; + for (int request = 0; request < MaximumBoundedStateReads; request++) + { + ct.ThrowIfCancellationRequested(); + EnsureBoundedReadSession(session, sessionId); + ReadResponse? response = await session.ReadAsync( + null, + 0, + TimestampsToReturn.Neither, + reads, + ct).ConfigureAwait(false); + if (response is null) + { + throw new ServiceResultException(StatusCodes.BadUnknownResponse); + } + ValidateBoundedReadHeader(response.ResponseHeader); + ClientBase.ValidateResponse(response.Results, reads); + ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, reads); + DataValue value = response.Results[0]; + RequireBoundedReadGoodStatus(value.StatusCode); + if (!value.WrappedValue.TryGetValue(out NodeId state) || state.IsNull) + { + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "The completion state is not a valid NodeId."); + } + if (state == ObjectIds.FileTransferStateMachineType_ReadTransfer) + { + return; + } + if (state == ObjectIds.FileTransferStateMachineType_Error) + { + throw new IOException("The server failed to generate the temporary read file."); + } + if (request + 1 < MaximumBoundedStateReads) + { + await Task.Delay(50, ct).ConfigureAwait(false); + } + } + throw new ServiceResultException( + StatusCodes.BadEncodingLimitsExceeded, + "Temporary file generation exceeds the state-read request limit."); + } + + private static async ValueTask ResolveBoundedReadPathAsync( + ISessionClient session, + NodeId sessionId, + NodeId startingNode, + ArrayOf elements, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + EnsureBoundedReadSession(session, sessionId); + ArrayOf paths = + [ + new BrowsePath { StartingNode = startingNode, RelativePath = new RelativePath { Elements = elements } } + ]; + TranslateBrowsePathsToNodeIdsResponse? response = await session + .TranslateBrowsePathsToNodeIdsAsync(null, paths, ct).ConfigureAwait(false); + if (response is null) + { + throw new ServiceResultException(StatusCodes.BadUnknownResponse); + } + ValidateBoundedReadHeader(response.ResponseHeader); + ClientBase.ValidateResponse(response.Results, paths); + ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, paths); + BrowsePathResult path = response.Results[0]; + RequireBoundedReadGoodStatus(path.StatusCode); + if (path.Targets.Count != 1 || + path.Targets[0].RemainingPathIndex != uint.MaxValue || + path.Targets[0].TargetId.ServerIndex != 0) + { + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "The file transfer path did not resolve to exactly one complete local node."); + } + NodeId node = ExpandedNodeId.ToNodeId(path.Targets[0].TargetId, session.MessageContext.NamespaceUris); + if (node.IsNull) + { + throw new ServiceResultException(StatusCodes.BadNodeIdInvalid); + } + return node; + } + + private static async ValueTask SendBoundedReadMethodAsync( + ObjectTypeClient proxy, + NodeId sessionId, + NodeId methodId, + ArrayOf arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + EnsureBoundedReadSession(proxy.Session, sessionId); + ArrayOf requests = + [ + new CallMethodRequest + { + ObjectId = proxy.ObjectId, + MethodId = methodId, + InputArguments = arguments + } + ]; + + // Generated methods discard non-Bad status metadata and may retry. + // This opt-in lifecycle must inspect the original response exactly + // once, including partially malformed outputs that contain a handle. + CallResponse? response = await proxy.Session.CallAsync(null, requests, ct).ConfigureAwait(false); + if (response is null) + { + throw new ServiceResultException(StatusCodes.BadUnknownResponse); + } + return response; + } + + private static ArrayOf GetBoundedReadOutputs(CallResponse response, int inputCount) + { + ValidateBoundedReadHeader(response.ResponseHeader); + if (response.Results.Count != 1 || response.Results[0] is not { } result) + { + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "The server did not return exactly one method result."); + } + RequireBoundedReadGoodStatus(result.StatusCode); + ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, response.Results); + if (result.InputArgumentResults.Count != 0) + { + if (result.InputArgumentResults.Count != inputCount) + { + throw new ServiceResultException( + StatusCodes.BadUnexpectedError, + "The method returned an invalid number of input argument statuses."); + } + ClientBase.ValidateDiagnosticInfos(result.InputArgumentDiagnosticInfos, result.InputArgumentResults); + for (int ii = 0; ii < result.InputArgumentResults.Count; ii++) + { + RequireBoundedReadGoodStatus(result.InputArgumentResults[ii]); + } + } + return result.OutputArguments; + } + + private static void ValidateBoundedReadHeader(ResponseHeader? header) + { + if (header is null) + { + throw new ServiceResultException(StatusCodes.BadUnknownResponse); + } + RequireBoundedReadGoodStatus(header.ServiceResult); + } + + private static void RequireBoundedReadGoodStatus(StatusCode status) + { + if (!StatusCode.IsGood(status)) + { + throw new ServiceResultException(status); + } + } + + private static void EnsureBoundedReadSession(ISessionClient session, NodeId sessionId) + { + if (session.SessionId != sessionId) + { + throw new ServiceResultException( + StatusCodes.BadSessionIdInvalid, + "The session that owns the temporary file handle has been replaced."); + } + } + + private const int MaximumBoundedReadChunkSize = 64 * 1024; + private const int MaximumBoundedFileReads = 1024; + private const int MaximumBoundedStateReads = 64; + private static readonly TimeSpan s_boundedReadCleanupTimeout = TimeSpan.FromSeconds(5); + } +} diff --git a/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs b/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs index 19df0785ae..97ac5eefed 100644 --- a/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs +++ b/src/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs @@ -60,7 +60,7 @@ namespace Opc.Ua.Client.FileSystem /// itself does NOT close the server handle on disposal. /// /// - public sealed class TemporaryFileTransferClient + public sealed partial class TemporaryFileTransferClient { /// /// Creates a new client rooted at the supplied diff --git a/src/Opc.Ua.Client/Subscription/MonitoredItem.cs b/src/Opc.Ua.Client/Subscription/MonitoredItem.cs index d079deae6d..29668f6d68 100644 --- a/src/Opc.Ua.Client/Subscription/MonitoredItem.cs +++ b/src/Opc.Ua.Client/Subscription/MonitoredItem.cs @@ -360,6 +360,7 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems // loaded state - otherwise TryRequeue force-recreates it. Error = ServiceResult.Good; CurrentMonitoringMode = m_currentOptions.MonitoringMode; + m_refreshParametersAfterTransfer = true; ClientHandle = state.ClientHandle; // Raise the global counter past the loaded handle, otherwise a // freshly started process mints handles from 1 again and the first @@ -614,6 +615,27 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems ServerId = serverHandle; } + while (m_refreshParametersAfterTransfer && Volatile.Read(ref m_currentOptions) is { } options) + { + // GetMonitoredItems supplies handles, not revised parameters. Reapply + // the saved parameters without recreating the item or changing its mode. + // A filter can invoke caller code while constructing the request, so + // build outside the enqueue gate and retry if newer options arrived. + var refresh = new Change(this, options, options); + lock (m_pendingChangesLock) + { + if (!m_refreshParametersAfterTransfer) + { + break; + } + if (!ReferenceEquals(options, Volatile.Read(ref m_currentOptions))) + { + continue; + } + m_pendingChanges.Enqueue(refresh); + m_refreshParametersAfterTransfer = false; + } + } } /// @@ -635,6 +657,7 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems m_logger.ItemRESET(this); ServerId = 0; + m_refreshParametersAfterTransfer = false; MonitoredItemOptions? options = m_currentOptions; while (TryGetPendingChange(out Change? change)) @@ -743,8 +766,12 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems } } - m_currentOptions = options; - m_pendingChanges.Enqueue(new Change(this, options, currentOptions)); + Volatile.Write(ref m_currentOptions, options); + var change = new Change(this, options, currentOptions); + lock (m_pendingChangesLock) + { + m_pendingChanges.Enqueue(change); + } } /// @@ -1265,11 +1292,14 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems } private bool m_disposedValue; + private bool m_refreshParametersAfterTransfer; private MonitoredItemOptions? m_currentOptions; #pragma warning disable CA2213 // Disposed in DisposeAsync(bool) private IDisposable? m_changeTracking; #pragma warning restore CA2213 private readonly ConcurrentQueue m_pendingChanges = new(); + /// Orders option-change enqueueing with transfer-refresh validation and enqueueing. + private readonly Lock m_pendingChangesLock = new(); private readonly ILogger m_logger; internal static uint GlobalClientHandleUint; private IOptionsMonitor m_options; diff --git a/src/Opc.Ua.Client/Subscription/MonitoredItemStateSnapshot.cs b/src/Opc.Ua.Client/Subscription/MonitoredItemStateSnapshot.cs index 56f27eabdb..7cf1b8145c 100644 --- a/src/Opc.Ua.Client/Subscription/MonitoredItemStateSnapshot.cs +++ b/src/Opc.Ua.Client/Subscription/MonitoredItemStateSnapshot.cs @@ -42,9 +42,12 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems /// Produced by and consumed by /// . /// Per-item runtime values (filter result, last sample, - /// current sampling interval) are intentionally not captured — the - /// transfer path re-binds them from the server via - /// GetMonitoredItems, and the recreate path mints fresh ones. + /// current sampling interval) are intentionally not captured. After + /// GetMonitoredItems re-binds the saved handles, transfer-on-load + /// reapplies the saved monitoring parameters using ModifyMonitoredItems + /// to obtain the revised interval, queue size and filter result. It does not + /// recreate the item or change its monitoring mode. Recreation obtains new + /// parameters and identifiers from CreateMonitoredItems. /// /// /// The snapshot is itself an via the diff --git a/src/Opc.Ua.Client/Subscription/Subscription.cs b/src/Opc.Ua.Client/Subscription/Subscription.cs index 670d364d07..1108db79a0 100644 --- a/src/Opc.Ua.Client/Subscription/Subscription.cs +++ b/src/Opc.Ua.Client/Subscription/Subscription.cs @@ -588,6 +588,8 @@ namespace Opc.Ua.Client.Subscriptions m_stateControl.Set(); } + OnPublishStateChanged(PublishState.Transferred); + // Recover the messages the server still holds for this // subscription. Doing this here rather than waiting for the next // publish response ensures the notifications are also recovered on diff --git a/src/Opc.Ua.Client/WebApi/WebApiWssTransportChannel.cs b/src/Opc.Ua.Client/WebApi/WebApiWssTransportChannel.cs index a30128a942..7138aab26e 100644 --- a/src/Opc.Ua.Client/WebApi/WebApiWssTransportChannel.cs +++ b/src/Opc.Ua.Client/WebApi/WebApiWssTransportChannel.cs @@ -279,7 +279,20 @@ namespace Opc.Ua.Client.WebApi } try { - await connection.OpenAsync(endpointUrl, ct).ConfigureAwait(false); + using CancellationTokenSource timeout = m_timeProvider.CreateCancellationTokenSource( + OperationTimeout > 0 ? TimeSpan.FromMilliseconds(OperationTimeout) : Timeout.InfiniteTimeSpan); + using var opening = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token); + try + { + await connection.OpenAsync(endpointUrl, opening.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested) + { + throw ServiceResultException.Create( + StatusCodes.BadRequestTimeout, + "WSS OpenAPI connection timed out after {0} ms.", + OperationTimeout); + } } catch { @@ -497,7 +510,10 @@ namespace Opc.Ua.Client.WebApi catch (OperationCanceledException) when ( timeout.IsCancellationRequested && !ct.IsCancellationRequested) { - throw new ServiceResultException(StatusCodes.BadRequestTimeout); + throw ServiceResultException.Create( + StatusCodes.BadRequestTimeout, + "WSS OpenAPI request timed out after {0} ms.", + OperationTimeout); } } finally diff --git a/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs b/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs index 724b7583b7..220fdafb26 100644 --- a/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs @@ -1444,6 +1444,9 @@ namespace Microsoft.Extensions.DependencyInjection /// /// Registers a batteries-included reference-style demo server. /// + /// + /// Uses configured role options while preserving an explicitly registered role manager. + /// /// public static IOpcUaServerBuilder AddReferenceServer( this IOpcUaBuilder builder, @@ -1463,7 +1466,7 @@ namespace Microsoft.Extensions.DependencyInjection options.IncludeUnsecurePolicyNone = false; configure?.Invoke(options); }); - serverBuilder.AddRoleManager(); + serverBuilder.ConfigureRoles(static _ => { }); return RegisterFluentNodeManager( serverBuilder, "http://opcfoundation.org/UA/ReferenceServer", @@ -1474,6 +1477,9 @@ namespace Microsoft.Extensions.DependencyInjection /// /// Registers a hardened hosted server preset with secure policies, identity authenticators and roles. /// + /// + /// Uses configured role options while preserving an explicitly registered role manager. + /// /// public static IOpcUaServerBuilder AddSecureServer( this IOpcUaBuilder builder, @@ -1500,7 +1506,7 @@ namespace Microsoft.Extensions.DependencyInjection serverBuilder.Services.TryAddSingleton(); serverBuilder.Services.TryAddSingleton(); return serverBuilder - .AddRoleManager() + .ConfigureRoles(static _ => { }) .AddDefaultIdentityAuthenticators(options => { options.EnableAnonymous = false; diff --git a/tests/Opc.Ua.Bindings.Https.WebApi.Tests/WebApiWssTransportChannelTests.cs b/tests/Opc.Ua.Bindings.Https.WebApi.Tests/WebApiWssTransportChannelTests.cs index 14debaec39..9a3447201f 100644 --- a/tests/Opc.Ua.Bindings.Https.WebApi.Tests/WebApiWssTransportChannelTests.cs +++ b/tests/Opc.Ua.Bindings.Https.WebApi.Tests/WebApiWssTransportChannelTests.cs @@ -35,6 +35,7 @@ using System.Net.Http; using System.Net.WebSockets; using System.Reflection; using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; @@ -68,7 +69,7 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests /// http://127.0.0.1:0 with /// enabled — plain ws:// sidesteps the TLS / certificate /// management overhead unit tests don't need. The same wire - /// envelope ({TypeId, Body} standard OPC UA JSON message) + /// envelope (UaTypeId with inline service fields) /// is reused, so the channel exercises the same encode/decode path /// it does against the real HttpsTransportListener.AcceptWebSocketOpenApiAsync. /// @@ -84,6 +85,8 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests private ServiceMessageContext? m_messageContext; private string? m_lastNegotiatedSubProtocol; private IServiceRequest? m_lastRequest; + private ByteString m_lastResponseBytes; + private TaskCompletionSource? m_handshakeReceived; private Func? m_responder; private Func? m_socketScript; @@ -101,6 +104,8 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests .Commit(); m_lastNegotiatedSubProtocol = null; m_lastRequest = null; + m_lastResponseBytes = default; + m_handshakeReceived = null; m_responder = DefaultResponder; m_socketScript = null; @@ -157,6 +162,12 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests Assert.That(response, Is.InstanceOf()); Assert.That(m_lastRequest, Is.InstanceOf()); Assert.That(response.ResponseHeader.RequestHandle, Is.EqualTo(4242u)); + using var envelope = JsonDocument.Parse(m_lastResponseBytes.Memory); + Assert.That(envelope.RootElement.GetProperty("UaTypeId").GetString(), Is.EqualTo("i=632")); + Assert.That(envelope.RootElement.GetProperty("ResponseHeader").GetProperty("RequestHandle").GetUInt32(), + Is.EqualTo(m_lastRequest!.RequestHeader.RequestHandle).And.Not.EqualTo(4242u)); + Assert.That(envelope.RootElement.TryGetProperty("UaBody", out _), Is.False); + Assert.That(request.RequestHeader.RequestHandle, Is.EqualTo(4242u)); } [Test] @@ -1251,6 +1262,59 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests AssertRequestUnchanged(request, snapshot, originalHeader); } + [TestCase(false)] + [TestCase(true)] + public async Task OpeningHandshakeHonorsDeadlineAndCallerCancellationAsync(bool cancel) + { + var time = new FakeTimeProvider(); + m_handshakeReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + using var channel = new WebApiWssTransportChannel(new TelemetryStub(), timeProvider: time); + EndpointConfiguration configuration = EndpointConfiguration.Create(); + configuration.OperationTimeout = 100; + TransportChannelSettings settings = CreateChannelSettings(configuration); + Task opening = channel.OpenAsync(m_baseUri, settings, cancellation.Token).AsTask(); + + await m_handshakeReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + time.Advance(TimeSpan.FromMilliseconds(99)); + Assert.That(opening.IsCompleted, Is.False); + if (cancel) + { + await cancellation.CancelAsync().ConfigureAwait(false); + await Assert.ThatAsync(() => opening.WaitAsync(TimeSpan.FromSeconds(5)), + Throws.InstanceOf()).ConfigureAwait(false); + } + else + { + time.Advance(TimeSpan.FromMilliseconds(1)); + await Assert.ThatAsync(() => opening.WaitAsync(TimeSpan.FromSeconds(5)), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadRequestTimeout)).ConfigureAwait(false); + } + + m_handshakeReceived = null; + await channel.OpenAsync(m_baseUri, settings, CancellationToken.None).AsTask() + .WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + IServiceResponse response = await channel.SendRequestAsync( + new ReadRequest { RequestHeader = new RequestHeader { RequestHandle = 42 } }).AsTask() + .WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + Assert.That(response, Is.TypeOf()); + Assert.That(response.ResponseHeader.RequestHandle, Is.EqualTo(42)); + } + + [Test] + public Task SilentServerRequestUsesOperationTimeoutAsync() + { + return AssertSilentServerRequestTerminationAsync(cancel: false); + } + + [Test] + public Task SilentServerRequestPreservesCallerCancellationAsync() + { + return AssertSilentServerRequestTerminationAsync(cancel: true); + } + private static void AssertRequestUnchanged( IServiceRequest request, IServiceRequest snapshot, @@ -1322,12 +1386,80 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests return channel; } + private async Task AssertSilentServerRequestTerminationAsync(bool cancel) + { + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + m_socketScript = async (socket, ct) => + { + _ = await ReceiveServiceRequestAsync(socket, ct).ConfigureAwait(false); + received.TrySetResult(); + IServiceRequest retry = await ReceiveServiceRequestAsync(socket, ct).ConfigureAwait(false); + await SendResponseAsync(socket, DefaultResponder(retry), ct).ConfigureAwait(false); + await release.Task.WaitAsync(ct).ConfigureAwait(false); + }; + + var time = new FakeTimeProvider(); + using WebApiWssTransportChannel channel = await OpenChannelAsync(timeProvider: time) + .ConfigureAwait(false); + using var cancellation = new CancellationTokenSource(); + channel.OperationTimeout = 100; + try + { + Task request = channel.SendRequestAsync(new GetEndpointsRequest + { + RequestHeader = new RequestHeader { RequestHandle = 24 }, + EndpointUrl = m_baseUri.AbsoluteUri + }, cancellation.Token).AsTask(); + await received.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + time.Advance(TimeSpan.FromMilliseconds(99)); + Assert.That(request.IsCompleted, Is.False); + if (cancel) + { + await cancellation.CancelAsync().ConfigureAwait(false); + await Assert.ThatAsync(() => request.WaitAsync(TimeSpan.FromSeconds(5)), + Throws.InstanceOf()).ConfigureAwait(false); + } + else + { + time.Advance(TimeSpan.FromMilliseconds(1)); + await Assert.ThatAsync(() => request.WaitAsync(TimeSpan.FromSeconds(5)), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadRequestTimeout) + .And.Message.Contains("100 ms")).ConfigureAwait(false); + } + + IServiceResponse response = await channel.SendRequestAsync(new GetEndpointsRequest + { + RequestHeader = new RequestHeader { RequestHandle = 25 }, + EndpointUrl = m_baseUri.AbsoluteUri + }).AsTask().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + Assert.That(response, Is.TypeOf()); + Assert.That(response.ResponseHeader.RequestHandle, Is.EqualTo(25)); + } + finally + { + release.TrySetResult(); + await channel.CloseAsync(CancellationToken.None).AsTask().WaitAsync(TimeSpan.FromSeconds(5)) + .ConfigureAwait(false); + } + } + private async Task OpenChannelAsync( WebApiClientOptions? options = null, - EndpointConfiguration? configuration = null) + EndpointConfiguration? configuration = null, + TimeProvider? timeProvider = null) { - var channel = new WebApiWssTransportChannel(new TelemetryStub(), options); - var settings = new TransportChannelSettings + var channel = new WebApiWssTransportChannel(new TelemetryStub(), options, timeProvider); + await channel.OpenAsync(m_baseUri, CreateChannelSettings(configuration), CancellationToken.None) + .ConfigureAwait(false); + return channel; + } + + private TransportChannelSettings CreateChannelSettings(EndpointConfiguration? configuration) + { + return new TransportChannelSettings { Description = new EndpointDescription { @@ -1342,9 +1474,6 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests Factory = m_messageContext!.Factory, NamespaceUris = new NamespaceTable() }; - await channel.OpenAsync(m_baseUri, settings, CancellationToken.None) - .ConfigureAwait(false); - return channel; } private async Task HandleWebSocketAsync(HttpContext context) @@ -1366,6 +1495,13 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests } m_lastNegotiatedSubProtocol = subProtocol; + if (m_handshakeReceived is TaskCompletionSource received) + { + received.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted).ConfigureAwait(false); + return; + } + using WebSocket ws = await context.WebSockets .AcceptWebSocketAsync(subProtocol) .ConfigureAwait(false); @@ -1456,7 +1592,9 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests private Task SendResponseAsync(WebSocket socket, IServiceResponse response, CancellationToken ct) { - return SendResponseBytesAsync(socket, EncodeResponse(response), ct); + byte[] responseBytes = EncodeResponse(response); + m_lastResponseBytes = ByteString.From(responseBytes); + return SendResponseBytesAsync(socket, responseBytes, ct); } private static async Task SendResponseBytesAsync(WebSocket socket, byte[] responseBytes, CancellationToken ct) diff --git a/tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientBoundedReadTests.cs b/tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientBoundedReadTests.cs new file mode 100644 index 0000000000..ee888af1f8 --- /dev/null +++ b/tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientBoundedReadTests.cs @@ -0,0 +1,568 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; +using Opc.Ua.Client.Tests.SessionContexts; +using Opc.Ua.Client.Tests.Stack.Client.Fakes; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Verifies bounded generated-file copying through real session dispatch and managed transport leases. + /// + [TestFixture] + [Parallelizable(ParallelScope.All)] + public sealed class TemporaryFileTransferClientBoundedReadTests + { + /// + /// A recreated session cannot receive an old handle even when it reuses the public session identifier. + /// + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task CopyRejectsRecreatedManagedGenerationAsync(bool reuseToken, bool duringReadiness) + { + await using var fixture = new SessionRequestContextTestContext(); + var script = new TransferScript(); + fixture.Handler = script.DispatchAsync; + NodeId originalSessionId = fixture.InnerSession.SessionId; + NodeId originalToken = fixture.InnerSession.SaveSessionConfiguration().AuthenticationToken; + ValueTask RecreateAsync(CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + fixture.InnerSession.SessionCreated(originalSessionId, + reuseToken ? originalToken : NodeId.Parse("s=replacement-token")); + return default; + } + if (duringReadiness) + { + script.GenerationResponse.Results[0].OutputArguments = + [Variant.From(s_file), Variant.From(kHandle), Variant.From(s_stateMachine)]; + script.OnStateRead = RecreateAsync; + } + else + { + script.OnGenerate = RecreateAsync; + } + script.Chunks.Enqueue(ByteString.Empty); + var client = new TemporaryFileTransferClient(fixture.ManagedSession, s_transfer); + using var destination = new MemoryStream(); + + AggregateException failure = Assert.ThrowsAsync( + async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!; + + Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2)); + Assert.That(failure.InnerExceptions, Has.All.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadSessionIdInvalid)); + Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod })); + Assert.That(fixture.Requests, Has.Count.EqualTo(duringReadiness ? 4 : 2)); + Assert.That(fixture.Requests.Select(request => request.RequestHeader.AuthenticationToken), + Is.All.EqualTo(originalToken)); + Assert.That(destination.Length, Is.Zero); + Assert.That(fixture.InnerSession.Connected, Is.True); + fixture.Channel.Verify(channel => channel.CloseAsync(It.IsAny()), Times.Never); + fixture.Channel.Verify(channel => channel.Dispose(), Times.Never); + } + + /// + /// Recovery queued behind generation is rechecked at the next dispatch, not just before entering the gate. + /// + [Test] + public async Task CopyRejectsRecoveryBetweenGenerationAndReadDispatchAsync() + { + await using var fixture = new SessionRequestContextTestContext(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + async Task RecreateBehindDispatchAsync() + { + using (await fixture.ServiceGate.WriterLockAsync().ConfigureAwait(false)) + { + entered.TrySetResult(true); + await release.Task.ConfigureAwait(false); + fixture.Recreate(); + } + } + Task? recovery = null; + var script = new TransferScript + { + OnGenerate = _ => + { + recovery = RecreateBehindDispatchAsync(); + return default; + } + }; + script.Chunks.Enqueue(ByteString.Empty); + fixture.Handler = script.DispatchAsync; + var client = new TemporaryFileTransferClient(fixture.ManagedSession, s_transfer); + using var destination = new MemoryStream(); + Task copying = client.CopyToAsync(default, destination, 3).AsTask(); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + Assert.That(copying.IsCompleted, Is.False); + Assert.That(fixture.Requests, Has.Count.EqualTo(2)); + release.TrySetResult(true); + + AggregateException failure = Assert.ThrowsAsync( + async () => await copying.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false))!; + + Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2)); + Assert.That(failure.InnerExceptions, Has.All.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadSessionIdInvalid)); + Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod })); + Assert.That(fixture.Requests, Has.Count.EqualTo(2)); + Assert.That(destination.Length, Is.Zero); + } + finally + { + release.TrySetResult(true); + if (recovery != null) + { + await recovery.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + } + } + + /// + /// Cancellation cannot discard a generated handle returned through a managed transport lease. + /// + [TestCase(false)] + [TestCase(true)] + public async Task ManagedCopyClosesHandleReturnedAfterCancellationAsync(bool delayedResponse) + { + await using var channels = new SessionChannelHarness(); + await using Client.ManagedSession owner = await Client.ManagedSession.CreateAsync( + new ManagedSessionOptions { Endpoint = SessionChannelHarness.CreateEndpoint() }, + channels.Configuration, + new DefaultSessionFactory(channels.Telemetry), + telemetry: channels.Telemetry, + channelManager: channels.Manager, + updateBeforeConnect: false).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(owner.TransportChannel, Is.InstanceOf()); + using var cancellation = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var script = new TransferScript + { + OnGenerate = async ct => + { + Assert.That(ct, Is.EqualTo(cancellation.Token)); + entered.TrySetResult(true); + if (delayedResponse) + { + await release.Task.ConfigureAwait(false); + } + else + { + await cancellation.CancelAsync().ConfigureAwait(false); + } + } + }; + ScriptedChannel transport = channels.CreatedChannels[0]; + transport.RequestHandler = (request, ct) => + request is CallRequest or TranslateBrowsePathsToNodeIdsRequest + ? script.DispatchAsync(request, ct) + : new ValueTask(transport.CreateResponse(request)); + var client = new TemporaryFileTransferClient(owner, s_transfer); + using var destination = new MemoryStream(); + Task copying = client.CopyToAsync(default, destination, 3, cancellation.Token).AsTask(); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + if (delayedResponse) + { + await cancellation.CancelAsync().ConfigureAwait(false); + release.TrySetResult(true); + } + await Assert.ThatAsync( + () => copying.WaitAsync(TimeSpan.FromSeconds(10)), + Throws.InstanceOf()).ConfigureAwait(false); + + Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close })); + Assert.That(script.CloseToken.CanBeCanceled, Is.True); + Assert.That(script.CloseToken.IsCancellationRequested, Is.False); + Assert.That(script.CloseToken, Is.Not.EqualTo(cancellation.Token)); + Assert.That(script.CloseDiscoveries, Is.Zero); + Assert.That(destination.Length, Is.Zero); + Assert.That(destination.CanWrite, Is.True); + Assert.That(owner.Connected, Is.True); + Assert.That(transport.CloseCount, Is.Zero); + Assert.That(transport.DisposeCount, Is.Zero); + } + finally + { + release.TrySetResult(true); + } + } + + /// + /// Cleanup spends its independent deadline on the declaration Close call, not optional metadata discovery. + /// + [TestCase(false)] + [TestCase(true)] + public async Task CopyUsesDeclarationCloseWithoutDiscoveryAsync(bool managed) + { + await using var fixture = new SessionRequestContextTestContext(); + var script = new TransferScript { RejectCloseDiscovery = true }; + script.Chunks.Enqueue(ByteString.From([1, 2, 3])); + script.Chunks.Enqueue(ByteString.Empty); + fixture.Handler = script.DispatchAsync; + var client = new TemporaryFileTransferClient( + managed ? fixture.ManagedSession : fixture.InnerSession, s_transfer); + using var destination = new MemoryStream(); + + long copied = await client.CopyToAsync(default, destination, 3).ConfigureAwait(false); + + Assert.That(copied, Is.EqualTo(3)); + Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 })); + Assert.That(script.Methods, Is.EqualTo( + new[] { s_generateMethod, s_readMethod, s_readMethod, MethodIds.FileType_Close })); + Assert.That(script.CloseDiscoveries, Is.Zero); + Assert.That(script.CloseToken.CanBeCanceled, Is.True); + Assert.That(script.CloseToken.IsCancellationRequested, Is.False); + Assert.That(destination.CanWrite, Is.True); + Assert.That(fixture.InnerSession.Connected, Is.True); + fixture.Channel.Verify(channel => channel.CloseAsync(It.IsAny()), Times.Never); + fixture.Channel.Verify(channel => channel.Dispose(), Times.Never); + } + + /// + /// A non-Good generation result cannot hide a valid allocated handle from cleanup. + /// + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + public async Task CopyClosesHandleBeforeRejectingGenerationStatusAsync(bool serviceStatus, bool badStatus) + { + await using var fixture = new SessionRequestContextTestContext(); + var script = new TransferScript(); + StatusCode status = badStatus ? StatusCodes.BadUserAccessDenied : StatusCodes.Uncertain; + SetStatus(script.GenerationResponse, serviceStatus, status); + fixture.Handler = script.DispatchAsync; + var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer); + using var destination = new MemoryStream(); + + ServiceResultException failure = Assert.ThrowsAsync( + async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!; + + Assert.That(failure.StatusCode, Is.EqualTo(status)); + Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close })); + Assert.That(destination.Length, Is.Zero); + } + + /// + /// Response-count and diagnostic errors are rejected only after retaining the returned file handle. + /// + [TestCase(false)] + [TestCase(true)] + public async Task CopyClosesHandleFromMalformedGenerationResponseAsync(bool extraResult) + { + await using var fixture = new SessionRequestContextTestContext(); + var script = new TransferScript(); + if (extraResult) + { + script.GenerationResponse.Results = [script.GenerationResponse.Results[0], new CallMethodResult()]; + } + else + { + script.GenerationResponse.DiagnosticInfos = [new DiagnosticInfo(), new DiagnosticInfo()]; + } + fixture.Handler = script.DispatchAsync; + var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer); + using var destination = new MemoryStream(); + + ServiceResultException failure = Assert.ThrowsAsync( + async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!; + + Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError)); + Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close })); + Assert.That(destination.Length, Is.Zero); + } + + /// + /// A rejected generation without a complete handle cannot trigger an invented Close. + /// + [TestCase(false)] + [TestCase(true)] + public async Task CopyRejectsGenerationWithoutInventingHandleAsync(bool uncertain) + { + await using var fixture = new SessionRequestContextTestContext(); + var script = new TransferScript(); + script.GenerationResponse.Results[0].StatusCode = uncertain ? StatusCodes.Uncertain : StatusCodes.Good; + script.GenerationResponse.Results[0].OutputArguments = [Variant.From(s_file)]; + fixture.Handler = script.DispatchAsync; + var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer); + using var destination = new MemoryStream(); + + ServiceResultException failure = Assert.ThrowsAsync( + async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!; + + Assert.That(failure.StatusCode, + Is.EqualTo(uncertain ? StatusCodes.Uncertain : StatusCodes.BadUnexpectedError)); + Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod })); + Assert.That(destination.Length, Is.Zero); + } + + /// + /// Generation rejection and cleanup failure remain independently visible in their original order. + /// + [Test] + public async Task CopyPreservesGenerationAndCleanupFailuresAsync() + { + await using var fixture = new SessionRequestContextTestContext(); + var script = new TransferScript(); + script.GenerationResponse.Results[0].StatusCode = StatusCodes.Uncertain; + script.CloseResponse.Results[0].StatusCode = StatusCodes.BadUserAccessDenied; + fixture.Handler = script.DispatchAsync; + var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer); + using var destination = new MemoryStream(); + + AggregateException failure = Assert.ThrowsAsync( + async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!; + + Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2)); + Assert.That(failure.InnerExceptions[0], Is.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.Uncertain)); + Assert.That(failure.InnerExceptions[1], Is.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadUserAccessDenied)); + Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close })); + } + + /// + /// Delaying generation validation does not weaken service or method validation for Read and Close. + /// + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task CopyKeepsReadAndCloseStatusValidationAsync(bool closeFails, bool serviceStatus) + { + await using var fixture = new SessionRequestContextTestContext(); + var script = new TransferScript(); + script.Chunks.Enqueue(closeFails ? ByteString.Empty : ByteString.From([1])); + SetStatus(closeFails ? script.CloseResponse : script.ReadResponse, serviceStatus, StatusCodes.Uncertain); + fixture.Handler = script.DispatchAsync; + var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer); + using var destination = new MemoryStream(); + + ServiceResultException failure = Assert.ThrowsAsync( + async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!; + + Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.Uncertain)); + Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, s_readMethod, MethodIds.FileType_Close })); + Assert.That(destination.Length, Is.Zero); + } + + /// + /// Assigns the selected service or method status without altering the returned output arguments. + /// + private static void SetStatus(CallResponse response, bool serviceStatus, StatusCode status) + { + if (serviceStatus) + { + response.ResponseHeader.ServiceResult = status; + } + else + { + response.Results[0].StatusCode = status; + } + } + + /// + /// Provides deterministic temporary-file service responses while recording actual method dispatches. + /// + private sealed class TransferScript + { + /// + /// Gets the generation response, whose metadata tests may corrupt independently of its handle. + /// + public CallResponse GenerationResponse { get; } = CreateResponse( + [Variant.From(s_file), Variant.From(kHandle), Variant.From(NodeId.Null)]); + + /// + /// Gets the Read response, with output populated from the configured chunks. + /// + public CallResponse ReadResponse { get; } = CreateResponse([]); + + /// + /// Gets the Close response. + /// + public CallResponse CloseResponse { get; } = CreateResponse([]); + + /// + /// Gets the payload chunks; unexpected Read calls fail when none remain. + /// + public Queue Chunks { get; } = new(); + + /// + /// Gets the methods that reached the transport. + /// + public ArrayOf Methods => m_methods.ToArrayOf(); + + /// + /// Gets the number of unnecessary Close-metadata lookups. + /// + public int CloseDiscoveries { get; private set; } + + /// + /// Gets the cancellation token actually delivered to Close. + /// + public CancellationToken CloseToken { get; private set; } + + /// + /// Gets or sets whether optional Close metadata is unavailable. + /// + public bool RejectCloseDiscovery { get; init; } + + /// + /// Gets or sets the action between generating a handle and returning its response. + /// + public Func? OnGenerate { get; set; } + + /// + /// Gets or sets the action before a generation-readiness response completes. + /// + public Func? OnStateRead { get; set; } + + /// + /// Executes only the expected temporary-file protocol operations. + /// + public async ValueTask DispatchAsync(IServiceRequest request, CancellationToken ct) + { + if (request is TranslateBrowsePathsToNodeIdsRequest translate) + { + Assert.That(translate.BrowsePaths.Count, Is.EqualTo(1)); + string? name = translate.BrowsePaths[0].RelativePath.Elements[0].TargetName.Name; + if (name == BrowseNames.Close) + { + CloseDiscoveries++; + if (RejectCloseDiscovery) + { + throw new ServiceResultException(StatusCodes.BadTimeout, "Close metadata is unavailable."); + } + } + NodeId target = name switch + { + BrowseNames.GenerateFileForRead => s_generateMethod, + BrowseNames.Read => s_readMethod, + BrowseNames.Close => MethodIds.FileType_Close, + BrowseNames.CurrentState => s_stateId, + _ => throw new AssertionException("Unexpected path: " + name) + }; + return new TranslateBrowsePathsToNodeIdsResponse + { + ResponseHeader = new ResponseHeader(), + Results = + [ + new BrowsePathResult + { + Targets = + [ + new BrowsePathTarget { TargetId = target, RemainingPathIndex = uint.MaxValue } + ] + } + ] + }; + } + if (request is ReadRequest read) + { + Assert.That(read.NodesToRead.Count, Is.EqualTo(1)); + Assert.That(read.NodesToRead[0].NodeId, Is.EqualTo(s_stateId)); + if (OnStateRead != null) + { + await OnStateRead(ct).ConfigureAwait(false); + } + return new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new DataValue(Variant.From(ObjectIds.FileTransferStateMachineType_ReadTransfer))] + }; + } + Assert.That(request, Is.TypeOf()); + var call = (CallRequest)request; + Assert.That(call.MethodsToCall.Count, Is.EqualTo(1)); + CallMethodRequest method = call.MethodsToCall[0]; + m_methods.Add(method.MethodId); + if (method.MethodId == s_generateMethod) + { + Assert.That(method.ObjectId, Is.EqualTo(s_transfer)); + if (OnGenerate != null) + { + await OnGenerate(ct).ConfigureAwait(false); + } + return GenerationResponse; + } + Assert.That(method.ObjectId, Is.EqualTo(s_file)); + Assert.That(method.InputArguments[0].TryGetValue(out uint handle), Is.True); + Assert.That(handle, Is.EqualTo(kHandle)); + if (method.MethodId == s_readMethod) + { + Assert.That(method.InputArguments.Count, Is.EqualTo(2)); + ReadResponse.Results[0].OutputArguments = [Variant.From(Chunks.Dequeue())]; + return ReadResponse; + } + Assert.That(method.MethodId, Is.EqualTo(MethodIds.FileType_Close)); + Assert.That(method.InputArguments.Count, Is.EqualTo(1)); + CloseToken = ct; + return CloseResponse; + } + + /// + /// Creates one successful method response. + /// + private static CallResponse CreateResponse(ArrayOf outputs) + { + return new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new CallMethodResult { OutputArguments = outputs }] + }; + } + + private readonly List m_methods = []; + } + + private const uint kHandle = 7; + private static readonly NodeId s_transfer = new(1000u); + private static readonly NodeId s_file = new(1001u); + private static readonly NodeId s_generateMethod = new(1002u); + private static readonly NodeId s_readMethod = new(1003u); + private static readonly NodeId s_stateMachine = new(1004u); + private static readonly NodeId s_stateId = new(1005u); + } +} diff --git a/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemTests.cs index 9cdfeaca77..a1c5144c2d 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/MonitoredItemTests.cs @@ -31,9 +31,12 @@ // making CA2000 noisy without a real leak risk. Disabled file-level for the suite. #pragma warning disable CA2000 using System; +using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Moq; using NUnit.Framework; using Opc.Ua.Client.Subscriptions.Fakes; using Opc.Ua.Tests; @@ -95,6 +98,141 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems Assert.That(((IMonitoredItemApplyState)sut).HasPendingChanges, Is.False); } + /// + /// Loaded handles require a modify response for revised parameters, not another create or invented defaults. + /// + [Test] + public async Task LoadedItemRefreshesRevisedParametersOnceWithoutRecreationAsync() + { + m_options.Configure(options => options with + { + StartNodeId = new NodeId("loaded", 2), + MonitoringMode = MonitoringMode.Reporting, + SamplingInterval = TimeSpan.FromMilliseconds(20), + QueueSize = 17 + }); + var item = new TestMonitoredItem(m_context, m_options, m_telemetry.CreateLogger("MonitoredItem")); + await using (item.ConfigureAwait(false)) + { + item.ApplyLoadState(new MonitoredItemLoadState("loaded", m_options, 42, 7, [])); + Assert.That(item.TryGetPendingChange(out _), Is.False); + + item.SetTransferResult(42, 9); + + Assert.That(item.TryGetPendingChange(out MonitoredItem.Change? refresh), Is.True); + Assert.That(refresh!.ForceRecreate, Is.False); + Assert.That(refresh.MonitoringModeChange, Is.Null); + MonitoredItemModifyRequest request = refresh.BindModifyRequest()!; + Assert.That(request.MonitoredItemId, Is.EqualTo(9)); + Assert.That(request.RequestedParameters.ClientHandle, Is.EqualTo(42)); + Assert.That(request.RequestedParameters.SamplingInterval, Is.EqualTo(20)); + Assert.That(request.RequestedParameters.QueueSize, Is.EqualTo(17)); + refresh.SetModifyResult(request, new MonitoredItemModifyResult + { + StatusCode = StatusCodes.Good, + RevisedSamplingInterval = 1000, + RevisedQueueSize = 19 + }, 0, [], new ResponseHeader()); + Assert.That(item.CurrentSamplingInterval, Is.EqualTo(TimeSpan.FromSeconds(1))); + Assert.That(item.CurrentQueueSize, Is.EqualTo(19)); + Assert.That(item.CurrentMonitoringMode, Is.EqualTo(MonitoringMode.Reporting)); + Assert.That(item.ServerId, Is.EqualTo(9)); + Assert.That(item.TryGetPendingChange(out _), Is.False); + item.SetTransferResult(42, 9); + Assert.That(item.TryGetPendingChange(out _), Is.False); + } + } + + /// + /// An options callback between refresh capture and enqueue cannot restore stale monitoring parameters. + /// + [Test] + public async Task LoadedItemRefreshPreservesOptionsChangedBeforeEnqueueAsync() + { + var initialFilter = new Mock(); + initialFilter.SetupGet(filter => filter.TypeId).Returns(DataTypeIds.DataChangeFilter); + var updatedFilter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValueTimestamp }; + var captured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var resume = new ManualResetEventSlim(); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using TestMonitoredItem item = CreateLoadedItem(initialFilter.Object); + int captures = 0; + initialFilter.SetupGet(filter => filter.TypeId).Returns(() => + { + if (Interlocked.Increment(ref captures) == 1) + { + captured.TrySetResult(true); + // The synchronous filter getter runs after options capture but before enqueue. + resume.Wait(deadline.Token); + } + return DataTypeIds.DataChangeFilter; + }); + Task transfer = Task.Run(() => item.SetTransferResult(42, 9), deadline.Token); + ArrayOf requests; + try + { + await captured.Task.WaitAsync(deadline.Token).ConfigureAwait(false); + UpdateMonitoringParameters(updatedFilter); + Assert.That(item.TryGetPendingChange(out MonitoredItem.Change? optionsChange), Is.True); + Assert.That(optionsChange!.Options, Is.SameAs(m_options.CurrentValue)); + resume.Set(); + await transfer.WaitAsync(deadline.Token).ConfigureAwait(false); + requests = ApplyPendingModifications(item); + } + finally + { + resume.Set(); + await transfer.WaitAsync(deadline.Token).ConfigureAwait(false); + } + + Assert.That(captures, Is.EqualTo(1)); + Assert.That(requests.Count, Is.EqualTo(2)); + foreach (MonitoredItemModifyRequest request in requests) + { + AssertUpdatedMonitoringParameters(request, updatedFilter); + } + Assert.That(item.CurrentSamplingInterval, Is.EqualTo(TimeSpan.FromMilliseconds(250))); + Assert.That(item.CurrentQueueSize, Is.EqualTo(31)); + Assert.That(m_options.CurrentValue.SamplingInterval, Is.EqualTo(item.CurrentSamplingInterval)); + Assert.That(m_options.CurrentValue.QueueSize, Is.EqualTo(item.CurrentQueueSize)); + Assert.That(item.CurrentMonitoringMode, Is.EqualTo(MonitoringMode.Reporting)); + Assert.That(item.ServerId, Is.EqualTo(9)); + Assert.That(item.HasPendingChanges, Is.False); + item.SetTransferResult(42, 9); + Assert.That(item.HasPendingChanges, Is.False); + } + + /// + /// Sequential options changes on either side of transfer retain the latest requested settings. + /// + [TestCase(false)] + [TestCase(true)] + public async Task LoadedItemRefreshPreservesOptionsBeforeOrAfterTransferAsync(bool updateBeforeTransfer) + { + var initialFilter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValue }; + var updatedFilter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValueTimestamp }; + await using TestMonitoredItem item = CreateLoadedItem(initialFilter); + if (updateBeforeTransfer) + { + UpdateMonitoringParameters(updatedFilter); + } + item.SetTransferResult(42, 9); + if (!updateBeforeTransfer) + { + UpdateMonitoringParameters(updatedFilter); + } + + ArrayOf requests = ApplyPendingModifications(item); + + Assert.That(requests.Count, Is.EqualTo(2)); + AssertUpdatedMonitoringParameters(requests[^1], updatedFilter); + Assert.That(item.CurrentSamplingInterval, Is.EqualTo(TimeSpan.FromMilliseconds(250))); + Assert.That(item.CurrentQueueSize, Is.EqualTo(31)); + Assert.That(item.CurrentMonitoringMode, Is.EqualTo(MonitoringMode.Reporting)); + Assert.That(item.ServerId, Is.EqualTo(9)); + Assert.That(item.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 @@ -689,6 +827,78 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems Assert.That(result, Is.EqualTo($"Test#{monitoredItem.ClientHandle}|0 (TestItem)")); } + /// + /// Creates a healthy saved item that needs its revised parameters refreshed after transfer. + /// + private TestMonitoredItem CreateLoadedItem(MonitoringFilter filter) + { + m_options.Configure(options => options with + { + StartNodeId = new NodeId("loaded", 2), + MonitoringMode = MonitoringMode.Reporting, + SamplingInterval = TimeSpan.FromMilliseconds(20), + QueueSize = 17, + DiscardOldest = true, + Filter = filter + }); + var item = new TestMonitoredItem(m_context, m_options, m_telemetry.CreateLogger("MonitoredItem")); + item.ApplyLoadState(new MonitoredItemLoadState("loaded", m_options, 42, 7, [])); + Assert.That(item.HasPendingChanges, Is.False); + return item; + } + + /// + /// Applies a distinct set of desired monitoring parameters through the real options callback. + /// + private void UpdateMonitoringParameters(MonitoringFilter filter) + { + m_options.Configure(options => options with + { + SamplingInterval = TimeSpan.FromMilliseconds(250), + QueueSize = 31, + DiscardOldest = false, + Filter = filter + }); + } + + /// + /// Completes every FIFO change using its requested parameters as the server's revised result. + /// + private static ArrayOf ApplyPendingModifications(MonitoredItem item) + { + var requests = new List(); + while (item.TryGetPendingChange(out MonitoredItem.Change? change)) + { + Assert.That(change.ForceRecreate, Is.False); + Assert.That(change.MonitoringModeChange, Is.Null); + MonitoredItemModifyRequest request = change.BindModifyRequest()!; + Assert.That(request, Is.Not.Null); + requests.Add(request); + change.SetModifyResult(request, new MonitoredItemModifyResult + { + StatusCode = StatusCodes.Good, + RevisedSamplingInterval = request.RequestedParameters.SamplingInterval, + RevisedQueueSize = request.RequestedParameters.QueueSize + }, 0, [], new ResponseHeader()); + } + return requests.ToArrayOf(); + } + + /// + /// Checks the exact latest parameters and preserved transferred identifiers sent to the server. + /// + private static void AssertUpdatedMonitoringParameters( + MonitoredItemModifyRequest request, DataChangeFilter filter) + { + Assert.That(request.MonitoredItemId, Is.EqualTo(9)); + Assert.That(request.RequestedParameters.ClientHandle, Is.EqualTo(42)); + Assert.That(request.RequestedParameters.SamplingInterval, Is.EqualTo(250)); + Assert.That(request.RequestedParameters.QueueSize, Is.EqualTo(31)); + Assert.That(request.RequestedParameters.DiscardOldest, Is.False); + Assert.That(request.RequestedParameters.Filter.TryGetValue(out DataChangeFilter? actual), Is.True); + Assert.That(actual, Is.SameAs(filter)); + } + private sealed class TestMonitoredItem : MonitoredItem { public TestMonitoredItem(IMonitoredItemContext subscription, diff --git a/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.cs b/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.cs index 820b4343e5..a7a164fdb2 100644 --- a/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.cs +++ b/tests/Opc.Ua.Client.Tests/Subscription/SubscriptionTests.cs @@ -2115,6 +2115,55 @@ namespace Opc.Ua.Client.Subscriptions } } + /// + /// An initiated transfer has its own confirmation callback; a failed handle rebind must not claim success. + /// + [TestCase(false)] + [TestCase(true)] + public async Task InitiatedTransferReportsOnlySuccessfulHandleRebindAsync(bool succeeds) + { + m_mockSubscriptionServices.Setup(service => service.DeleteSubscriptionsAsync( + It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new DeleteSubscriptionsResponse { Results = [StatusCodes.Good] }); + m_mockMonitoredItemServices.Setup(service => service.CreateMonitoredItemsAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new CreateMonitoredItemsResponse + { + Results = + [ + new MonitoredItemCreateResult + { + StatusCode = StatusCodes.Good, + MonitoredItemId = 9, + RevisedSamplingInterval = 1000, + RevisedQueueSize = 17 + } + ] + }); + var subscription = new TestSubscription(m_session, m_mockNotificationDataHandler.Object, + m_completion, m_options, m_telemetry, 2); + await using (subscription.ConfigureAwait(false)) + { + Assert.That(subscription.MonitoredItems.TryAdd("known", + OptionsFactory.Create(), out IMonitoredItem? item), Is.True); + Assert.That(item!.Created, Is.True); + SetupGetMonitoredItemsResult(succeeds ? StatusCodes.BadMethodInvalid : StatusCodes.BadTimeout); + subscription.ResetPublishStateForTest(); + + bool transferred = await subscription.TryCompleteTransferAsync([], CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(transferred, Is.EqualTo(succeeds)); + Assert.That(subscription.LastPublishState & PublishState.Transferred, + Is.EqualTo(succeeds ? PublishState.Transferred : PublishState.None)); + m_mockNotificationDataHandler.Verify(handler => handler.OnSubscriptionStateChangedAsync( + subscription, It.IsAny(), + It.Is(state => (state & PublishState.Transferred) != 0)), + succeeds ? Times.Once() : Times.Never()); + } + } + /// /// An unusable GetMonitoredItems answer is treated like a server that /// does not provide the optional method: the server ids the client diff --git a/tests/Opc.Ua.Server.Tests/Hosting/HostedRolePresetTests.cs b/tests/Opc.Ua.Server.Tests/Hosting/HostedRolePresetTests.cs new file mode 100644 index 0000000000..6ad7da2c39 --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/Hosting/HostedRolePresetTests.cs @@ -0,0 +1,167 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Opc.Ua.Server.Hosting; + +namespace Opc.Ua.Server.Tests.Hosting +{ + /// + /// Verifies that hosted presets retain role configuration and explicit role-manager ownership. + /// + [TestFixture] + [Category("Hosting")] + public sealed class HostedRolePresetTests + { + /// + /// Applies either form of role configuration after selecting a hosted preset. + /// + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void PresetAppliesConfiguredRoleIdentityRules(bool secure, bool configurationSection) + { + var services = new ServiceCollection(); + services.AddLogging(); + IOpcUaServerBuilder server = AddPreset(services, secure); + if (configurationSection) + { + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["Roles:0:Name"] = BrowseNames.WellKnownRole_Operator, + ["Roles:0:Identities:0:CriteriaType"] = nameof(IdentityCriteriaType.UserName), + ["Roles:0:Identities:0:Criteria"] = "preset-operator" + }).Build(); + server.ConfigureRoles(configuration); + } + else + { + server.ConfigureRoles(AddOperatorRule); + } + using ServiceProvider provider = services.BuildServiceProvider(); + + RequireOperatorRule(provider.GetRequiredService()); + Assert.That(services.Count(descriptor => descriptor.ServiceType == typeof(IRoleManager)), Is.EqualTo(1)); + } + + /// + /// Resolves previously registered options lazily even when no subsequent ConfigureRoles call is made. + /// + [TestCase(false)] + [TestCase(true)] + public void PresetConsumesPreviouslyRegisteredRoleOptionsOnce(bool secure) + { + var services = new ServiceCollection(); + services.AddLogging(); + int configured = 0; + services.Configure(options => + { + configured++; + AddOperatorRule(options); + }); + AddPreset(services, secure); + Assert.That(configured, Is.Zero); + using ServiceProvider provider = services.BuildServiceProvider(); + + IRoleManager roles = provider.GetRequiredService(); + + RequireOperatorRule(roles); + Assert.That(provider.GetRequiredService(), Is.SameAs(roles)); + Assert.That(configured, Is.EqualTo(1)); + } + + /// + /// Keeps an explicit manager authoritative regardless of its position relative to the preset. + /// + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void PresetPreservesExplicitRoleManager(bool secure, bool registerBeforePreset) + { + var manager = new Mock(MockBehavior.Strict); + var services = new ServiceCollection(); + services.AddLogging(); + if (registerBeforePreset) + { + services.AddSingleton(manager.Object); + } + IOpcUaServerBuilder server = AddPreset(services, secure); + if (!registerBeforePreset) + { + server.AddRoleManager(manager.Object); + } + server.ConfigureRoles(AddOperatorRule); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.That(provider.GetRequiredService(), Is.SameAs(manager.Object)); + Assert.That(services.Count(descriptor => descriptor.ServiceType == typeof(IRoleManager)), Is.EqualTo(1)); + manager.VerifyNoOtherCalls(); + } + + private static IOpcUaServerBuilder AddPreset(IServiceCollection services, bool secure) + { + IOpcUaBuilder builder = services.AddOpcUa(); + return secure + ? builder.AddSecureServer(static _ => { }) + : builder.AddReferenceServer(); + } + + private static void AddOperatorRule(RoleConfigurationOptions options) + { + options.Roles.Add(new RoleDefinitionOptions + { + Name = BrowseNames.WellKnownRole_Operator, + Identities = + { + new RoleIdentityMappingOptions + { + CriteriaType = IdentityCriteriaType.UserName, + Criteria = "preset-operator" + } + } + }); + } + + private static void RequireOperatorRule(IRoleManager roles) + { + RoleEntry role = roles.GetRole(ObjectIds.WellKnownRole_Operator) ?? + throw new AssertionException("The well-known Operator role is missing."); + Assert.That(role.Identities, Has.Exactly(1).Matches(rule => + rule.CriteriaType == IdentityCriteriaType.UserName && rule.Criteria == "preset-operator")); + } + } +}