From 0dfae76f41eb70326eb62293bfcbead296352af3 Mon Sep 17 00:00:00 2001 From: Marc Date: Tue, 27 Oct 2026 06:15:50 +0100 Subject: [PATCH] Add host-dispatched UAFX endpoint lifecycle --- docs/UAFX.md | 68 +- .../PubSubServerBuilderExtensions.cs | 7 +- .../IPubSubFxConnectionLifecycle.cs | 85 +- .../PubSubFxReservationMethods.cs | 187 +++- ...OpcUaServerBuilderPubSubExtensionsTests.cs | 72 ++ .../PubSubCommunicationIdMethodTests.cs | 797 ++++++++++++++++++ 6 files changed, 1143 insertions(+), 73 deletions(-) diff --git a/docs/UAFX.md b/docs/UAFX.md index bebabdd08a..28e57fc4b9 100644 --- a/docs/UAFX.md +++ b/docs/UAFX.md @@ -42,9 +42,10 @@ structures. `PubSubReserveCommunicationIds2DataType` and `Opc.Ua.PubSub.Server` provides the experimental `PubSubFxReservationMethods` binding for the `VerifyAssetCmd`, `VerifyFunctionalEntityCmd`, and -`ReserveCommunicationIdsCmd` commands of `EstablishConnections`. It can also -bind `CloseConnections` to a host-owned `IPubSubFxConnectionLifecycle` -transaction. The host implements that capability because it owns the endpoint +`ReserveCommunicationIdsCmd` commands of `EstablishConnections`. It can +delegate the remaining `EstablishConnections` commands and bind +`CloseConnections` to a host-owned `IPubSubFxConnectionLifecycle` +implementation. The host supplies that capability because it owns the endpoint nodes, ConnectionManager authorization, and shared-resource ownership. The binding delegates asset verification to each addressed `IAssetRevisionType.VerifyAsset` method, @@ -84,23 +85,32 @@ The server's PubSub node manager forwards Session closure to the reservation provider. A standalone host must perform that forwarding itself, as described in [PubSub communication-ID reservations](PubSubCommunicationIds.md). Registration does not enable configuration methods, expose the model, replace -an existing handler, or implement endpoint creation/configuration/enablement. +an existing handler, or provide a concrete host lifecycle implementation. Registering a lifecycle capability causes `Bind` to install the generated `CloseConnections` Method handler only when that Method has no existing handler. +When an `EstablishConnections` call selects endpoint lifecycle commands, the +binding invokes the same capability after all selected verification and +reservation commands succeed. The host processes the selected endpoint +commands in Part 81 order and returns one ordered endpoint result per request. +The capability receives the ordered verification and communication-ID +reservation results so the host can use reserved identifiers during endpoint +configuration. Calls require an actual Session with the `ConfigureAdmin` role and -`ExposeConfigurationMethods = true`. The accepted command masks are any -non-empty combination of `VerifyAssetCmd`, `VerifyFunctionalEntityCmd`, and -`ReserveCommunicationIdsCmd`; other command bits fail explicitly. Each set -command requires a non-empty input array, and each unset command requires an -empty array. `VerifyFunctionalEntityCmd` requires at least one non-empty -`ExpectedVerificationVariables` array. Its endpoint entries must select the -`ConnectionEndpointDefinitionDataType.Node` member; control groups, -configuration data, and communication links must remain unset because their -commands are not implemented by this binding. The other endpoint command -inputs and communication-configuration inputs must also remain empty. A call -is limited to 128 combined asset-verification, endpoint-verification, and -reservation requests. +`ExposeConfigurationMethods = true`. The binding accepts non-empty +combinations of the nine defined `EstablishConnections` command bits. A +command that creates or changes endpoints requires a registered +`IPubSubFxConnectionLifecycle`; without one, those command bits fail with +`BadInvalidState` before verification or reservation begins. Each selected +command requires its input, and each unselected command requires an empty +input. `VerifyFunctionalEntityCmd` requires at least one non-empty +`ExpectedVerificationVariables` array. Endpoint definitions select the Node +member unless `CreateConnectionEndpointCmd` supplies a Parameter member. +`ControlGroups`, `ConfigurationData`, and `CommunicationLinks` are accepted +only with their corresponding control, configuration-data, or communication +configuration commands. `SetCommunicationConfigurationCmd` requires one +communication-configuration input. A call is limited to 128 combined +asset-verification, endpoint, and reservation requests. Asset verification preserves input order, invokes each asset's `VerifyAsset` method with the supplied mode and expected-variable arrays, and @@ -231,25 +241,27 @@ Generated C# is produced during compilation and is not checked in. ## Implementation boundary These are model contracts, not a complete ConnectionManager or -AutomationComponent lifecycle implementation. The binding above provides only -the documented verification and reservation operations; applications remain -responsible for authorization, lifecycle orchestration, endpoint association, -enablement, status binding, persistence and cleanup according to Part 81 and -their selected Part 84 profiles. Loading a model or completing a Method call is -not evidence of valid PubSub DataSet reception or UAFX conformance. +AutomationComponent lifecycle implementation. The binding above implements +verification and reservation and delegates endpoint lifecycle commands to an +optional host capability. Applications remain responsible for supplying that +capability and for endpoint association, status binding, persistence, and +cleanup according to Part 81 and their selected Part 84 profiles. Loading a +model or completing a Method call is not evidence of valid PubSub DataSet +reception or UAFX conformance. `Opc.Ua.PubSub.Server` can load these declarations into its node manager when `PubSubServerOptions.ExposeFxModel` is enabled. This is opt-in and registers the generated encodeable types and the DI, FX Data, AutomationComponent, and ConnectionManager namespaces. Model exposure alone does not bind `EstablishConnections` or `CloseConnections`. The binding above implements -asset and functional-entity verification plus communication-ID reservation; -`CloseConnections` is delegated to the optional host transaction. Endpoint -creation, control, configuration, and enablement remain unimplemented. +asset and functional-entity verification plus communication-ID reservation. +`EstablishConnections` endpoint commands and `CloseConnections` are delegated +to the optional host capability. This package does not provide a concrete host +lifecycle implementation for endpoint creation, control, configuration, or +enablement. The experimental [Data Channel binding](DataChannels.md) provides the `ConfigureUafx` transport-configuration bridge. It does not install Part 81 configuration or grant a ConnectionManager authority over authenticated data -peers. The `EstablishConnections` binding above supports asset verification, -functional-entity verification, and communication-ID reservation only; it does -not implement endpoint creation, control, configuration, or enablement. +peers. A host lifecycle capability must still implement and authorize the +endpoint operations delegated by `EstablishConnections` and `CloseConnections`. diff --git a/src/Opc.Ua.PubSub.Server/DependencyInjection/PubSubServerBuilderExtensions.cs b/src/Opc.Ua.PubSub.Server/DependencyInjection/PubSubServerBuilderExtensions.cs index d9e3ceadbd..f213f6ad6f 100644 --- a/src/Opc.Ua.PubSub.Server/DependencyInjection/PubSubServerBuilderExtensions.cs +++ b/src/Opc.Ua.PubSub.Server/DependencyInjection/PubSubServerBuilderExtensions.cs @@ -51,8 +51,8 @@ namespace Microsoft.Extensions.DependencyInjection { /// /// Registers injectable Part 81 reservation methods for host-owned AutomationComponents. - /// A host lifecycle capability registered with is also used - /// to bind the component's CloseConnections Method. + /// A host lifecycle capability registered with handles + /// endpoint lifecycle commands and the component's CloseConnections Method. /// [global::System.Diagnostics.CodeAnalysis.Experimental(DataChannelFeature.ExperimentalDiagnosticId, UrlFormat = DataChannelFeature.ExperimentalUrlFormat)] @@ -70,7 +70,8 @@ namespace Microsoft.Extensions.DependencyInjection } /// - /// Registers the host-owned, list-level lifecycle transaction used by Part 81 CloseConnections. + /// Registers the host-owned lifecycle capability used by Part 81 endpoint commands and + /// CloseConnections. /// /// PubSub server builder. /// Host implementation that authorizes and transacts endpoint lifecycle. diff --git a/src/Opc.Ua.PubSub.Server/IPubSubFxConnectionLifecycle.cs b/src/Opc.Ua.PubSub.Server/IPubSubFxConnectionLifecycle.cs index fe0c8a3a4c..608d0a9623 100644 --- a/src/Opc.Ua.PubSub.Server/IPubSubFxConnectionLifecycle.cs +++ b/src/Opc.Ua.PubSub.Server/IPubSubFxConnectionLifecycle.cs @@ -30,6 +30,7 @@ using System.Threading; using System.Threading.Tasks; using Opc.Ua.Fx.Ac; +using Opc.Ua.Fx.Data; namespace Opc.Ua.PubSub.Server { @@ -37,13 +38,48 @@ namespace Opc.Ua.PubSub.Server /// Provides host-owned ConnectionEndpoint lifecycle transactions for an AutomationComponent. /// /// - /// Implementations authorize the actual ConnectionManager represented by the call context and process the complete - /// endpoint list as one transaction. They preserve unrelated and shared resources, prevent automatic readmission - /// when removing an endpoint, and return one ordered result per requested endpoint. This capability does not - /// transfer ownership of host nodes or PubSub runtime components to the PubSub server. + /// Implementations authorize the actual ConnectionManager represented by the call context. EstablishConnections + /// commands are processed in Part 81 order and return ordered endpoint results. CloseConnections processes the + /// complete endpoint list as one transaction, preserves unrelated and shared resources, and prevents automatic + /// readmission when removing an endpoint. This capability does not transfer ownership of host nodes or PubSub + /// runtime components to the PubSub server. /// public interface IPubSubFxConnectionLifecycle { + /// + /// Processes the host-owned endpoint commands in an EstablishConnections call. + /// + /// + /// The implementation processes selected commands in Part 81 order, starting with + /// CreateConnectionEndpointCmd and ending with EnableCommunicationCmd. It preserves the supplied + /// functional-entity verification results, returns ordered per-endpoint results, performs atomic operations + /// where required by Part 81, and does not take ownership of pre-existing or shared host resources. + /// + /// The actual Method call context, including its authenticated Session. + /// The host-owned AutomationComponent whose Method was called. + /// The complete EstablishConnections command mask. + /// The ordered endpoint configuration requests. + /// + /// Ordered results produced by earlier verification commands in the same Method call. + /// + /// + /// Ordered communication-ID reservations completed earlier in the same Method call. + /// + /// + /// Communication-model configuration supplied for SetCommunicationConfigurationCmd. + /// + /// Cancels processing where the host can safely honor cancellation. + /// Overall status and the ordered endpoint and communication-configuration results. + ValueTask EstablishConnectionsAsync( + ISystemContext context, + AutomationComponentState component, + uint commandMask, + ArrayOf connectionEndpointConfigurations, + ArrayOf initialEndpointResults, + ArrayOf reservationResults, + ArrayOf communicationConfigurations, + CancellationToken cancellationToken = default); + /// /// Closes or removes the specified host-owned ConnectionEndpoints in one lifecycle transaction. /// @@ -62,4 +98,45 @@ namespace Opc.Ua.PubSub.Server bool remove, CancellationToken cancellationToken = default); } + + /// + /// Contains the result of host-owned Part 81 EstablishConnections endpoint commands. + /// + public sealed class PubSubFxEstablishConnectionsResult + { + /// + /// Initializes a result containing the overall method status and ordered command result arrays. + /// + /// The overall EstablishConnections status. + /// + /// Per-endpoint results in the request order. + /// + /// + /// Communication-configuration result array; one item when the command is processed. + /// + public PubSubFxEstablishConnectionsResult( + ServiceResult serviceResult, + ArrayOf connectionEndpointConfigurationResults, + ArrayOf communicationConfigurationResults) + { + ServiceResult = serviceResult; + ConnectionEndpointConfigurationResults = connectionEndpointConfigurationResults; + CommunicationConfigurationResults = communicationConfigurationResults; + } + + /// + /// Gets the overall EstablishConnections status. + /// + public ServiceResult ServiceResult { get; } + + /// + /// Gets the per-endpoint results in request order. + /// + public ArrayOf ConnectionEndpointConfigurationResults { get; } + + /// + /// Gets the communication-configuration result array. + /// + public ArrayOf CommunicationConfigurationResults { get; } + } } diff --git a/src/Opc.Ua.PubSub.Server/PubSubFxReservationMethods.cs b/src/Opc.Ua.PubSub.Server/PubSubFxReservationMethods.cs index c3be9466d2..733fe0905e 100644 --- a/src/Opc.Ua.PubSub.Server/PubSubFxReservationMethods.cs +++ b/src/Opc.Ua.PubSub.Server/PubSubFxReservationMethods.cs @@ -41,10 +41,8 @@ using Opc.Ua.Server; namespace Opc.Ua.PubSub.Server { /// - /// Binds supported Part 81 verification and reservation commands to the application's Session-owned Part 14 - /// transaction, and optionally delegates CloseConnections to a host-owned lifecycle transaction. Other - /// EstablishConnections commands and ConnectionEndpoint creation/configuration are not implemented by this - /// binding. + /// Binds Part 81 verification and reservation commands to the application's Session-owned Part 14 transaction, + /// and delegates endpoint lifecycle commands to an optional host-owned capability. /// [global::System.Diagnostics.CodeAnalysis.Experimental(DataChannelFeature.ExperimentalDiagnosticId, UrlFormat = DataChannelFeature.ExperimentalUrlFormat)] @@ -64,7 +62,8 @@ namespace Opc.Ua.PubSub.Server /// The PubSub application supporting communication-ID reservations. /// The PubSub server options governing configuration method authorization. /// - /// Optional host capability that atomically closes or removes ConnectionEndpoints. + /// Optional host capability that processes endpoint lifecycle commands and closes or removes + /// ConnectionEndpoints. /// public PubSubFxReservationMethods( IPubSubApplication application, @@ -127,7 +126,8 @@ namespace Opc.Ua.PubSub.Server method.OnCallAsync = (context, calledMethod, objectId, commandMask, assets, endpoints, reservations, configurations, cancellationToken) => EstablishConnectionsAsync(context, objectId, component, componentId, commandMask, - assets, endpoints, reservations, configurations, listeningEndpoint, cancellationToken); + assets, endpoints, reservations, configurations, listeningEndpoint, m_connectionLifecycle, + cancellationToken); if (m_connectionLifecycle is { } connectionLifecycle) { CloseConnectionsMethodState closeMethod = component.CloseConnections!; @@ -224,6 +224,7 @@ namespace Opc.Ua.PubSub.Server ArrayOf reservations, ArrayOf configurations, Func>? listeningEndpoint, + IPubSubFxConnectionLifecycle? connectionLifecycle, CancellationToken cancellationToken) { var result = new EstablishConnectionsMethodStateResult @@ -252,8 +253,18 @@ namespace Opc.Ua.PubSub.Server uint verifyAssetCommand = (uint)FxCommandMask.VerifyAssetCmd; uint verifyFunctionalEntityCommand = (uint)FxCommandMask.VerifyFunctionalEntityCmd; uint reserveCommunicationIdsCommand = (uint)FxCommandMask.ReserveCommunicationIdsCmd; - if ((commandMask & ~(verifyAssetCommand | - verifyFunctionalEntityCommand | reserveCommunicationIdsCommand)) != 0) + uint createConnectionEndpointCommand = (uint)FxCommandMask.CreateConnectionEndpointCmd; + uint establishControlCommand = (uint)FxCommandMask.EstablishControlCmd; + uint setConfigurationDataCommand = (uint)FxCommandMask.SetConfigurationDataCmd; + uint reassignControlCommand = (uint)FxCommandMask.ReassignControlCmd; + uint setCommunicationConfigurationCommand = + (uint)FxCommandMask.SetCommunicationConfigurationCmd; + uint enableCommunicationCommand = (uint)FxCommandMask.EnableCommunicationCmd; + uint supportedCommands = verifyAssetCommand | verifyFunctionalEntityCommand | + reserveCommunicationIdsCommand | createConnectionEndpointCommand | establishControlCommand | + setConfigurationDataCommand | reassignControlCommand | + setCommunicationConfigurationCommand | enableCommunicationCommand; + if ((commandMask & ~supportedCommands) != 0) { result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidState); return result; @@ -261,11 +272,92 @@ namespace Opc.Ua.PubSub.Server bool verifyAssets = (commandMask & verifyAssetCommand) != 0; bool verifyFunctionalEntities = (commandMask & verifyFunctionalEntityCommand) != 0; bool reserveCommunicationIds = (commandMask & reserveCommunicationIdsCommand) != 0; + bool createConnectionEndpoints = (commandMask & createConnectionEndpointCommand) != 0; + bool establishControl = (commandMask & establishControlCommand) != 0; + bool setConfigurationData = (commandMask & setConfigurationDataCommand) != 0; + bool reassignControl = (commandMask & reassignControlCommand) != 0; + bool setCommunicationConfiguration = (commandMask & setCommunicationConfigurationCommand) != 0; + bool enableCommunication = (commandMask & enableCommunicationCommand) != 0; + bool hasEndpointCommands = verifyFunctionalEntities || createConnectionEndpoints || establishControl || + setConfigurationData || reassignControl || setCommunicationConfiguration || enableCommunication; + bool hasLifecycleCommands = createConnectionEndpoints || establishControl || setConfigurationData || + reassignControl || setCommunicationConfiguration || enableCommunication; + if (hasLifecycleCommands && connectionLifecycle is null) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidState); + return result; + } if ((verifyAssets && assets.IsEmpty) || (!verifyAssets && !assets.IsEmpty) || - (verifyFunctionalEntities && endpoints.IsEmpty) || - (!verifyFunctionalEntities && !endpoints.IsEmpty) || !configurations.IsEmpty || + (hasEndpointCommands && endpoints.IsEmpty) || (!hasEndpointCommands && !endpoints.IsEmpty) || (reserveCommunicationIds && reservations.IsEmpty) || - (!reserveCommunicationIds && !reservations.IsEmpty)) + (!reserveCommunicationIds && !reservations.IsEmpty) || + (setCommunicationConfiguration && configurations.Count != 1) || + (!setCommunicationConfiguration && !configurations.IsEmpty)) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); + return result; + } + bool hasExpectedVariables = false; + bool hasEndpointParameter = false; + bool hasControlGroup = false; + bool hasConfigurationData = false; + bool hasCommunicationLinks = false; + for (int i = 0; i < endpoints.Count; i++) + { + ConnectionEndpointConfigurationDataType endpoint = endpoints[i]; + hasExpectedVariables |= !endpoint.ExpectedVerificationVariables.IsEmpty; + hasControlGroup |= !endpoint.ControlGroups.IsEmpty; + hasConfigurationData |= !endpoint.ConfigurationData.IsEmpty; + bool endpointHasCommunicationLinks = endpoint.CommunicationLinks is not null && + endpoint.CommunicationLinks.GetType() != typeof(CommunicationLinkConfigurationDataType); + hasCommunicationLinks |= endpointHasCommunicationLinks; + switch (endpoint.ConnectionEndpoint.SwitchField) + { + case ConnectionEndpointDefinitionDataTypeFields.Node: + if (endpoint.ConnectionEndpoint.Node.IsNull) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); + return result; + } + break; + case ConnectionEndpointDefinitionDataTypeFields.Parameter: + hasEndpointParameter = true; + if (!createConnectionEndpoints) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); + return result; + } + break; + default: + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); + return result; + } + if (!verifyFunctionalEntities && !endpoint.ExpectedVerificationVariables.IsEmpty) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); + return result; + } + if (!establishControl && !reassignControl && !endpoint.ControlGroups.IsEmpty) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); + return result; + } + if (!setConfigurationData && !endpoint.ConfigurationData.IsEmpty) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); + return result; + } + if (!setCommunicationConfiguration && endpointHasCommunicationLinks) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); + return result; + } + } + if ((verifyFunctionalEntities && !hasExpectedVariables) || + (createConnectionEndpoints && !hasEndpointParameter) || + ((establishControl || reassignControl) && !hasControlGroup) || + (setConfigurationData && !hasConfigurationData) || + (setCommunicationConfiguration && !hasCommunicationLinks)) { result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); return result; @@ -287,31 +379,6 @@ namespace Opc.Ua.PubSub.Server return result; } } - if (verifyFunctionalEntities) - { - bool hasExpectedVariables = false; - for (int i = 0; i < endpoints.Count; i++) - { - ConnectionEndpointConfigurationDataType endpoint = endpoints[i]; - hasExpectedVariables |= !endpoint.ExpectedVerificationVariables.IsEmpty; - if (endpoint.ConnectionEndpoint.SwitchField != - ConnectionEndpointDefinitionDataTypeFields.Node || - endpoint.ConnectionEndpoint.Node.IsNull || - !endpoint.ControlGroups.IsEmpty || - !endpoint.ConfigurationData.IsEmpty || - (endpoint.CommunicationLinks is not null && - endpoint.CommunicationLinks.GetType() != typeof(CommunicationLinkConfigurationDataType))) - { - result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); - return result; - } - } - if (!hasExpectedVariables) - { - result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument); - return result; - } - } if (assets.Count > kMaxRequestsPerCall || endpoints.Count > kMaxRequestsPerCall - assets.Count || reservations.Count > kMaxRequestsPerCall - assets.Count - endpoints.Count) @@ -425,6 +492,40 @@ namespace Opc.Ua.PubSub.Server result.ServiceResult = new ServiceResult(StatusCodes.Uncertain); } } + + if (!aborted && hasLifecycleCommands && connectionLifecycle is { } lifecycle) + { + PubSubFxEstablishConnectionsResult lifecycleResult = + await lifecycle.EstablishConnectionsAsync( + context, + component, + commandMask, + endpoints, + result.ConnectionEndpointConfigurationResults, + result.ReserveCommunicationIdsResults, + configurations, + cancellationToken).ConfigureAwait(false); + if (lifecycleResult is null) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadUnexpectedError); + return result; + } + if (lifecycleResult.ConnectionEndpointConfigurationResults.IsNull || + lifecycleResult.ConnectionEndpointConfigurationResults.Count != endpoints.Count || + (setCommunicationConfiguration + ? lifecycleResult.CommunicationConfigurationResults.IsNull || + lifecycleResult.CommunicationConfigurationResults.Count != 1 + : !lifecycleResult.CommunicationConfigurationResults.IsEmpty)) + { + result.ServiceResult = new ServiceResult(StatusCodes.BadUnexpectedError); + return result; + } + result.ConnectionEndpointConfigurationResults = + lifecycleResult.ConnectionEndpointConfigurationResults; + result.CommunicationConfigurationResults = + lifecycleResult.CommunicationConfigurationResults; + result.ServiceResult = lifecycleResult.ServiceResult; + } } catch (ServiceResultException exception) { @@ -515,7 +616,7 @@ namespace Opc.Ua.PubSub.Server ArrayOf expectedVariables = endpoint.ExpectedVerificationVariables; if (expectedVariables.IsEmpty) { - result.ConnectionEndpointId = endpoint.ConnectionEndpoint.Node; + result.ConnectionEndpointId = GetConnectionEndpointNodeId(endpoint); result.VerificationStatus = StatusCodes.GoodNoData; return result; } @@ -562,11 +663,21 @@ namespace Opc.Ua.PubSub.Server (verificationResult != FunctionalEntityVerificationResultEnum.NotSet || verificationErrors.IsEmpty)) { - result.ConnectionEndpointId = endpoint.ConnectionEndpoint.Node; + result.ConnectionEndpointId = GetConnectionEndpointNodeId(endpoint); } return result; } + /// + /// Returns the existing endpoint NodeId when the union selects its Node member. + /// + private static NodeId GetConnectionEndpointNodeId(ConnectionEndpointConfigurationDataType endpoint) + { + return endpoint.ConnectionEndpoint.SwitchField == ConnectionEndpointDefinitionDataTypeFields.Node + ? endpoint.ConnectionEndpoint.Node + : NodeId.Null; + } + /// /// Confirms the FunctionalEntity is hosted by an Asset in the bound AutomationComponent. /// diff --git a/tests/Opc.Ua.PubSub.Server.Tests/OpcUaServerBuilderPubSubExtensionsTests.cs b/tests/Opc.Ua.PubSub.Server.Tests/OpcUaServerBuilderPubSubExtensionsTests.cs index 3cf14d4f91..e78654302f 100644 --- a/tests/Opc.Ua.PubSub.Server.Tests/OpcUaServerBuilderPubSubExtensionsTests.cs +++ b/tests/Opc.Ua.PubSub.Server.Tests/OpcUaServerBuilderPubSubExtensionsTests.cs @@ -30,12 +30,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; +using Opc.Ua.Fx.Ac; using Opc.Ua.PubSub.Application; using Opc.Ua.PubSub.Security.Sks; using Opc.Ua.PubSub.Server.Hosting; @@ -453,6 +455,76 @@ namespace Opc.Ua.PubSub.Server.Tests Assert.That(factory.NamespacesUris.ToArray(), Contains.Item(PubSubNodeManager.NamespaceUri)); } + [TestCase(true)] + [TestCase(false)] + public async Task FxDiResolvedBinderDispatchesRegisteredLifecycleAsync(bool registerLifecycleFirst) + { + ServiceCollection services = BuildServicesWithRuntime(); + IPubSubServerBuilder builder = services.AddOpcUa().AddServer(_ => { }) + .AddPubSub(options => options.ExposeConfigurationMethods = true); + var lifecycle = new Mock(); + if (registerLifecycleFirst) + { + builder.WithFxConnectionLifecycle(lifecycle.Object); + builder.WithFxCommunicationIdReservations(); + } + else + { + builder.WithFxCommunicationIdReservations(); + builder.WithFxConnectionLifecycle(lifecycle.Object); + } + + await using ServiceProvider provider = services.BuildServiceProvider(); + PubSubFxReservationMethods binder = provider.GetRequiredService(); + PubSubServerOptions registeredOptions = + provider.GetRequiredService>().Value; + Assert.Multiple(() => + { + Assert.That(provider.GetRequiredService(), Is.SameAs(lifecycle.Object)); + Assert.That(registeredOptions.ExposeConfigurationMethods, Is.True, + "The authorized call requires the explicitly configured method option."); + Assert.That(registeredOptions.ExposeFxModel, Is.False, + "Registering the FX binder and lifecycle must not enable the FX model."); + }); + + await using var harness = new PubSubCommunicationIdMethodTests.Harness( + options: new PubSubServerOptions { ExposeFxModel = true, ExposeConfigurationMethods = true }); + await harness.InitializeAsync().ConfigureAwait(false); + AutomationComponentState component = harness.CreateFxComponentWithCloseConnections(); + CloseConnectionsMethodState method = component.CloseConnections!; + ArrayOf endpoints = + [new NodeId("endpoint:z", 1), new NodeId("endpoint:a", 1), new NodeId("endpoint:z", 1)]; + ArrayOf expected = + [StatusCodes.BadInvalidState, StatusCodes.Good, StatusCodes.BadNodeIdUnknown]; + const bool remove = true; + binder.Bind(component); + + lifecycle.Setup(value => value.CloseConnectionsAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny())) + .Returns((ISystemContext context, AutomationComponentState actualComponent, + ArrayOf actualEndpoints, bool actualRemove, CancellationToken cancellationToken) => + { + Assert.That(context, Is.SameAs(harness.Context)); + Assert.That(actualComponent, Is.SameAs(component)); + Assert.That(actualEndpoints, Is.EqualTo(endpoints)); + Assert.That(actualRemove, Is.EqualTo(remove)); + Assert.That(cancellationToken, Is.EqualTo(CancellationToken.None)); + return new ValueTask>(expected); + }); + + (ServiceResult result, List outputs) = await harness.CallAsync( + method, harness.Context, [Variant.From(endpoints), Variant.From(remove)]).ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(outputs, Has.Count.EqualTo(1)); + Assert.That(outputs[0].TryGetValue(out ArrayOf actualResults), Is.True); + Assert.That(actualResults, Is.EqualTo(expected)); + lifecycle.Verify(value => value.CloseConnectionsAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny()), Times.Once); + } + internal static ServiceCollection BuildServicesWithRuntime() { var services = new ServiceCollection(); diff --git a/tests/Opc.Ua.PubSub.Server.Tests/PubSubCommunicationIdMethodTests.cs b/tests/Opc.Ua.PubSub.Server.Tests/PubSubCommunicationIdMethodTests.cs index 5a81586691..793b32005b 100644 --- a/tests/Opc.Ua.PubSub.Server.Tests/PubSubCommunicationIdMethodTests.cs +++ b/tests/Opc.Ua.PubSub.Server.Tests/PubSubCommunicationIdMethodTests.cs @@ -2717,6 +2717,622 @@ namespace Opc.Ua.PubSub.Server.Tests It.IsAny(), It.IsAny()), Times.Never); } + [TestCase(4u)] + [TestCase(8u)] + [TestCase(16u)] + [TestCase(32u)] + [TestCase(128u)] + [TestCase(71u)] + [TestCase(75u)] + [TestCase(83u)] + [TestCase(99u)] + [TestCase(195u)] + public async Task FxUnsupportedLifecycleCommandsFailBeforeVerificationAndReservationAsync(uint commandMask) + { + var policy = new Mock(); + policy.Setup(value => value.ResolveAsync(It.IsAny(), It.IsAny())) + .Returns((string _, CancellationToken _) => new ValueTask(Variant.From(7UL))); + await using var harness = new Harness(policy.Object, + new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + int resolverCalls = 0; + EstablishConnectionsMethodState method = harness.BindFx(_ => + { + resolverCalls++; + return new ValueTask("opc.tcp://localhost:4840/uafx"); + }); + ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc); + NodeId assetId = new("unsupported-command-asset", namespaceIndex); + _ = harness.AddVerifiableAsset(assetId, AssetVerificationResultEnum.Match); + NodeId functionalEntityId = new("unsupported-command-fe", namespaceIndex); + _ = harness.AddVerifiableFunctionalEntity( + functionalEntityId, FunctionalEntityVerificationResultEnum.Match); + var asset = new AssetVerificationDataType + { + AssetToVerify = assetId, + VerificationMode = AssetVerificationModeEnum.AssetCompatibility, + ExpectedVerificationResult = AssetVerificationResultEnum.Match + }; + ConnectionEndpointConfigurationDataType endpoint = FxEndpoint( + functionalEntityId, [ExpectedVerificationVariable("expected", namespaceIndex)]); + PubSubReserveCommunicationIds2DataType request = FxRequest(); + request.TransportProfileUri = DataChannelProfiles.PubSubUasc; + request.RequestTransportSpecificInfo = true; + + (ServiceResult result, List outputs) = await harness.CallAsync( + method, harness.Context, + FxArguments(commandMask, [asset], [endpoint], request)).ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That(outputs, Is.Empty); + Assert.That(harness.VerifyAssetCalls, Is.Zero); + Assert.That(harness.VerifyFunctionalEntityCalls, Is.Zero); + Assert.That(resolverCalls, Is.Zero); + policy.Verify(value => value.ResolveAsync(It.IsAny(), It.IsAny()), + Times.Never); + + (ServiceResult next, List nextOutputs) = await harness.CallAsync( + method, harness.Context, FxReserveArguments(FxRequest())).ConfigureAwait(false); + Assert.That(next.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(FxResults(harness, nextOutputs)[0].WriterGroupIds, Is.EqualTo(new ushort[] { 0x8000 })); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public async Task FxLifecycleBindingRejectsInvalidCloseMethodWithoutPartialInstallationAsync( + int invalidMethodShape) + { + await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + AutomationComponentState component = harness.CreateFxComponentWithCloseConnections(); + EstablishConnectionsMethodState establish = component.EstablishConnections!; + CloseConnectionsMethodState? close = invalidMethodShape switch + { + 0 => null, + 1 => new CloseConnectionsMethodState(component), + 2 => CreateMisparentedCloseMethod(harness), + _ => throw new ArgumentOutOfRangeException(nameof(invalidMethodShape)) + }; + component.CloseConnections = close; + NodeId originalComponentId = component.NodeId; + var lifecycle = new Mock(); + var binding = new PubSubFxReservationMethods( + harness.Application, new PubSubServerOptions { ExposeFxModel = true }, lifecycle.Object); + + Assert.That(() => binding.Bind(component), Throws.ArgumentException); + + Assert.That(component.NodeId, Is.EqualTo(originalComponentId)); + Assert.That(component.EstablishConnections, Is.SameAs(establish)); + Assert.That(component.CloseConnections, Is.SameAs(close)); + AssertNoMethodHandlers(establish); + if (close is not null) + { + AssertNoMethodHandlers(close); + } + lifecycle.Verify(value => value.CloseConnectionsAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task FxVerificationRejectsNullSelectedEndpointNodeBeforeCallingHostAsync() + { + var policy = new Mock(); + policy.Setup(value => value.ResolveAsync(It.IsAny(), It.IsAny())) + .Returns((string _, CancellationToken _) => new ValueTask(Variant.From(7UL))); + await using var harness = new Harness(policy.Object, + new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + MethodState method = harness.BindFx(); + ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc); + NodeId functionalEntityId = new("null-endpoint-fe", namespaceIndex); + _ = harness.AddVerifiableFunctionalEntity( + functionalEntityId, FunctionalEntityVerificationResultEnum.Match); + var endpoint = FxEndpoint( + functionalEntityId, [ExpectedVerificationVariable("expected", namespaceIndex)]); + endpoint.ConnectionEndpoint = new ConnectionEndpointDefinitionDataType + { + SwitchField = ConnectionEndpointDefinitionDataTypeFields.Node, + Node = NodeId.Null + }; + + (ServiceResult result, List outputs) = await harness.CallAsync( + method, harness.Context, FxArguments( + (uint)FxCommandMask.VerifyFunctionalEntityCmd | + (uint)FxCommandMask.ReserveCommunicationIdsCmd, + ArrayOf.Empty, [endpoint], FxRequest())) + .ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(outputs, Is.Empty); + Assert.That(harness.VerifyFunctionalEntityCalls, Is.Zero); + policy.Verify(value => value.ResolveAsync(It.IsAny(), It.IsAny()), + Times.Never); + (ServiceResult next, List nextOutputs) = await harness.CallAsync( + method, harness.Context, FxReserveArguments(FxRequest())).ConfigureAwait(false); + Assert.That(next.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(FxResults(harness, nextOutputs)[0].WriterGroupIds, Is.EqualTo(new ushort[] { 0x8000 })); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public async Task FxCloseDispatchRejectsArityAndArgumentTypesBeforeLifecycleAsync(int invalidCall) + { + await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + AutomationComponentState component = harness.CreateFxComponentWithCloseConnections(); + CloseConnectionsMethodState method = component.CloseConnections!; + var lifecycle = new Mock(); + new PubSubFxReservationMethods( + harness.Application, new PubSubServerOptions { ExposeFxModel = true }, lifecycle.Object) + .Bind(component); + ArrayOf endpoints = [new NodeId("dispatch-endpoint", 4)]; + ArrayOf arguments = invalidCall switch + { + 0 => [Variant.From(endpoints)], + 1 => [Variant.From(endpoints), Variant.From(false), Variant.From(1)], + 2 => [Variant.From("not-an-endpoint-array"), Variant.From(false)], + 3 => [Variant.From(endpoints), Variant.From("not-a-boolean")], + _ => throw new ArgumentOutOfRangeException(nameof(invalidCall)) + }; + var argumentErrors = new List(); + var outputs = new List(); + + ServiceResult result = await method.CallAsync( + harness.Context, component.NodeId, arguments, argumentErrors, outputs).ConfigureAwait(false); + + Assert.That(outputs, Is.Empty); + if (invalidCall == 0) + { + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadArgumentsMissing)); + Assert.That(argumentErrors, Is.Empty); + } + else if (invalidCall == 1) + { + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTooManyArguments)); + Assert.That(argumentErrors, Is.Empty); + } + else + { + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(argumentErrors, Has.Count.EqualTo(2)); + Assert.That(argumentErrors[invalidCall == 2 ? 0 : 1].StatusCode, + Is.EqualTo(StatusCodes.BadTypeMismatch)); + Assert.That(argumentErrors[invalidCall == 2 ? 1 : 0].StatusCode, + Is.EqualTo(StatusCodes.Good)); + } + lifecycle.Verify(value => value.CloseConnectionsAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task FxAllSupportedCommandsExecuteInStageOrderAsync() + { + var events = new List(); + var assetEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var functionalEntityEntered = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var reservationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var assetGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var functionalEntityGate = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var policy = new Mock(); + policy.Setup(value => value.ResolveAsync( + Profiles.PubSubUdpUadpTransport, It.IsAny())) + .Returns((string _, CancellationToken _) => + { + events.Add("reservation"); + reservationEntered.TrySetResult(true); + return new ValueTask(Variant.From(7UL)); + }); + await using var harness = new Harness(policy.Object, + new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + EstablishConnectionsMethodState method = harness.BindFx(); + ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc); + NodeId assetId = new("ordered-asset", namespaceIndex); + MethodState verifyAsset = harness.AddVerifiableAsset(assetId, AssetVerificationResultEnum.Match) + .FindChild(harness.Context, new QualifiedName("VerifyAsset", + namespaceIndex)) as MethodState ?? throw new InvalidOperationException(); + verifyAsset.OnCallMethod2Async = async (_, _, _, _, outputs, _) => + { + events.Add("asset"); + assetEntered.TrySetResult(true); + await assetGate.Task.ConfigureAwait(false); + outputs[0] = Variant.From((int)AssetVerificationResultEnum.Match); + outputs[1] = Variant.From(ArrayOf.Empty); + outputs[2] = Variant.From(ArrayOf.Empty); + return ServiceResult.Good; + }; + NodeId functionalEntityId = new("ordered-fe", namespaceIndex); + MethodState verifyFunctionalEntity = harness.AddVerifiableFunctionalEntity( + functionalEntityId, FunctionalEntityVerificationResultEnum.Match); + verifyFunctionalEntity.OnCallMethod2Async = async (_, _, _, _, outputs, _) => + { + events.Add("functional-entity"); + functionalEntityEntered.TrySetResult(true); + await functionalEntityGate.Task.ConfigureAwait(false); + outputs[0] = Variant.From((int)FunctionalEntityVerificationResultEnum.Match); + outputs[1] = Variant.From(ArrayOf.Empty); + return ServiceResult.Good; + }; + var asset = new AssetVerificationDataType + { + AssetToVerify = assetId, + VerificationMode = AssetVerificationModeEnum.AssetCompatibility, + ExpectedVerificationResult = AssetVerificationResultEnum.Match, + ExpectedVerificationVariables = [] + }; + ConnectionEndpointConfigurationDataType endpoint = FxEndpoint( + functionalEntityId, [ExpectedVerificationVariable("ordered-variable", namespaceIndex)]); + Task<(ServiceResult Result, List Outputs)> pending = harness.CallAsync( + method, harness.Context, FxArguments( + (uint)FxCommandMask.VerifyAssetCmd | + (uint)FxCommandMask.VerifyFunctionalEntityCmd | + (uint)FxCommandMask.ReserveCommunicationIdsCmd, + [asset], [endpoint], FxRequest())); + + try + { + await assetEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(string.Join(",", events), Is.EqualTo("asset")); + Assert.That(harness.VerifyFunctionalEntityCalls, Is.Zero); + Assert.That(reservationEntered.Task.IsCompleted, Is.False); + Assert.That(pending.IsCompleted, Is.False); + assetGate.SetResult(true); + await functionalEntityEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(string.Join(",", events), Is.EqualTo("asset,functional-entity")); + Assert.That(reservationEntered.Task.IsCompleted, Is.False); + Assert.That(pending.IsCompleted, Is.False); + functionalEntityGate.SetResult(true); + await reservationEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + (ServiceResult result, List outputs) = + await pending.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + + Assert.That(string.Join(",", events), Is.EqualTo("asset,functional-entity,reservation")); + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(outputs, Has.Count.EqualTo(4)); + AssetVerificationResultDataType[] assetResults = FxAssetResults(harness, outputs); + Assert.That(assetResults, Has.Length.EqualTo(1)); + Assert.That(assetResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good)); + Assert.That(assetResults[0].VerificationResult, Is.EqualTo(AssetVerificationResultEnum.Match)); + ConnectionEndpointConfigurationResultDataType[] endpointResults = + FxEndpointResults(harness, outputs); + Assert.That(endpointResults, Has.Length.EqualTo(1)); + Assert.That(endpointResults[0].FunctionalEntityNodeResult, Is.EqualTo(StatusCodes.Good)); + Assert.That(endpointResults[0].ConnectionEndpointId, Is.EqualTo(endpoint.ConnectionEndpoint.Node)); + Assert.That(endpointResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good)); + Assert.That(endpointResults[0].VerificationResult, + Is.EqualTo(FunctionalEntityVerificationResultEnum.Match)); + PubSubReserveCommunicationIdsResult2DataType[] reservationResults = FxResults(harness, outputs); + Assert.That(reservationResults, Has.Length.EqualTo(1)); + Assert.That(reservationResults[0].Result, Is.EqualTo(StatusCodes.Good)); + Assert.That(reservationResults[0].DefaultPublisherId.TryGetValue(out ulong publisher), Is.True); + Assert.That(publisher, Is.EqualTo(7UL)); + Assert.That(reservationResults[0].WriterGroupIds, Is.EqualTo(new ushort[] { 0x8000 })); + Assert.That(reservationResults[0].DataSetWriterIds, Is.EqualTo(new ushort[] { 0x8000 })); + Assert.That(outputs[3].TryGetValue( + out ArrayOf configurations, + harness.Context.AsMessageContext()), Is.True); + Assert.That(configurations.IsEmpty, Is.True); + } + finally + { + assetGate.TrySetResult(true); + functionalEntityGate.TrySetResult(true); + await pending.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + } + } + + [TestCase(125)] + [TestCase(126)] + [TestCase(127)] + [TestCase(128)] + public async Task FxCombinedRequestBudgetIsCheckedBeforeAnyStageAsync(int functionalEntityCount) + { + var policy = new Mock(); + policy.Setup(value => value.ResolveAsync( + Profiles.PubSubUdpUadpTransport, It.IsAny())) + .Returns(new ValueTask(Variant.From(7UL))); + await using var harness = new Harness(policy.Object, + new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + EstablishConnectionsMethodState method = harness.BindFx(); + ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc); + NodeId assetId = new("budget-asset", namespaceIndex); + _ = harness.AddVerifiableAsset(assetId, AssetVerificationResultEnum.Match); + var asset = new AssetVerificationDataType + { + AssetToVerify = assetId, + VerificationMode = AssetVerificationModeEnum.AssetCompatibility, + ExpectedVerificationResult = AssetVerificationResultEnum.Match + }; + ConnectionEndpointConfigurationDataType[] endpoints = + Enumerable.Range(0, functionalEntityCount).Select(index => + { + NodeId functionalEntityId = new($"budget-fe-{index}", namespaceIndex); + _ = harness.AddVerifiableFunctionalEntity( + functionalEntityId, FunctionalEntityVerificationResultEnum.Match); + return FxEndpoint(functionalEntityId, + [ExpectedVerificationVariable($"budget-variable-{index}", namespaceIndex)]); + }).ToArray(); + + (ServiceResult result, List outputs) = await harness.CallAsync( + method, harness.Context, FxArguments( + (uint)FxCommandMask.VerifyAssetCmd | + (uint)FxCommandMask.VerifyFunctionalEntityCmd | + (uint)FxCommandMask.ReserveCommunicationIdsCmd, + [asset], endpoints, FxRequest())).ConfigureAwait(false); + + if (functionalEntityCount <= 126) + { + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(outputs, Has.Count.EqualTo(4)); + Assert.That(harness.VerifyAssetCalls, Is.EqualTo(1)); + Assert.That(harness.VerifyFunctionalEntityCalls, Is.EqualTo(functionalEntityCount)); + AssetVerificationResultDataType[] assetResults = FxAssetResults(harness, outputs); + Assert.That(assetResults, Has.Length.EqualTo(1)); + Assert.That(assetResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good)); + Assert.That(assetResults[0].VerificationResult, Is.EqualTo(AssetVerificationResultEnum.Match)); + ConnectionEndpointConfigurationResultDataType[] endpointResults = + FxEndpointResults(harness, outputs); + Assert.That(endpointResults, Has.Length.EqualTo(functionalEntityCount)); + Assert.That(endpointResults.Select(value => value.ConnectionEndpointId), + Is.EqualTo(endpoints.Select(value => value.ConnectionEndpoint.Node))); + Assert.That(endpointResults.Select(value => value.FunctionalEntityNodeResult), + Is.All.EqualTo(StatusCodes.Good)); + Assert.That(endpointResults.Select(value => value.VerificationStatus), + Is.All.EqualTo(StatusCodes.Good)); + Assert.That(endpointResults.Select(value => value.VerificationResult), + Is.All.EqualTo(FunctionalEntityVerificationResultEnum.Match)); + Assert.That(FxResults(harness, outputs), Has.Length.EqualTo(1)); + Assert.That(FxResults(harness, outputs)[0].Result, Is.EqualTo(StatusCodes.Good)); + Assert.That(FxResults(harness, outputs)[0].WriterGroupIds, + Is.EqualTo(new ushort[] { 0x8000 })); + Assert.That(FxResults(harness, outputs)[0].DataSetWriterIds, + Is.EqualTo(new ushort[] { 0x8000 })); + Assert.That(FxResults(harness, outputs)[0].DefaultPublisherId.TryGetValue(out ulong publisher), + Is.True); + Assert.That(publisher, Is.EqualTo(7UL)); + } + else + { + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + Assert.That(outputs, Is.Empty); + Assert.That(harness.VerifyAssetCalls, Is.Zero); + Assert.That(harness.VerifyFunctionalEntityCalls, Is.Zero); + policy.Verify(value => value.ResolveAsync( + It.IsAny(), It.IsAny()), Times.Never); + (ServiceResult next, List nextOutputs) = await harness.CallAsync( + method, harness.Context, FxReserveArguments(FxRequest())).ConfigureAwait(false); + Assert.That(next.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(FxResults(harness, nextOutputs), Has.Length.EqualTo(1)); + Assert.That(FxResults(harness, nextOutputs)[0].Result, Is.EqualTo(StatusCodes.Good)); + Assert.That(FxResults(harness, nextOutputs)[0].WriterGroupIds, + Is.EqualTo(new ushort[] { 0x8000 })); + Assert.That(FxResults(harness, nextOutputs)[0].DataSetWriterIds, + Is.EqualTo(new ushort[] { 0x8000 })); + Assert.That(FxResults(harness, nextOutputs)[0].DefaultPublisherId.TryGetValue(out ulong publisher), + Is.True); + Assert.That(publisher, Is.EqualTo(7UL)); + } + policy.Verify(value => value.ResolveAsync( + Profiles.PubSubUdpUadpTransport, It.IsAny()), Times.Once); + } + + [Test] + public async Task FxLifecycleBindingsRemainIsolatedAcrossComponentsAndAwaitHostCompletionAsync() + { + await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + AutomationComponentState firstComponent = harness.CreateFxComponentWithCloseConnections(); + AutomationComponentState secondComponent = harness.CreateFxComponentWithCloseConnections(); + firstComponent.NodeId = new NodeId("component:first", 1); + firstComponent.EstablishConnections!.NodeId = new NodeId("component:first:establish", 1); + firstComponent.CloseConnections!.NodeId = new NodeId("component:first:close", 1); + secondComponent.NodeId = new NodeId("component:second", 1); + secondComponent.EstablishConnections!.NodeId = new NodeId("component:second:establish", 1); + secondComponent.CloseConnections!.NodeId = new NodeId("component:second:close", 1); + ServerSystemContext secondContext = new(harness.Context.Server) + { + SessionId = new NodeId(456u), + UserIdentity = harness.Context.UserIdentity + }; + ArrayOf firstEndpoints = + [new NodeId("first:endpoint:a", 1), new NodeId("first:endpoint:b", 1)]; + ArrayOf secondEndpoints = + [new NodeId("second:endpoint:b", 1), new NodeId("second:endpoint:a", 1)]; + ArrayOf firstResults = [StatusCodes.Good, StatusCodes.BadNodeIdUnknown]; + ArrayOf secondResults = [StatusCodes.BadInvalidState, StatusCodes.Good]; + var firstCall = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var secondCall = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var firstGate = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + var secondGate = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + var lifecycle = new Mock(); + lifecycle.Setup(value => value.CloseConnectionsAsync( + It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny())) + .Returns((ISystemContext context, AutomationComponentState component, ArrayOf endpoints, + bool remove, CancellationToken token) => + { + var call = new CloseLifecycleCall(context, component, endpoints, remove, token); + if (ReferenceEquals(component, firstComponent)) + { + firstCall.TrySetResult(call); + return AwaitLifecycleResultsAsync(firstGate.Task); + } + if (ReferenceEquals(component, secondComponent)) + { + secondCall.TrySetResult(call); + return AwaitLifecycleResultsAsync(secondGate.Task); + } + throw new InvalidOperationException("The binder dispatched an unbound component."); + }); + var binder = new PubSubFxReservationMethods( + harness.Application, new PubSubServerOptions { ExposeFxModel = true }, lifecycle.Object); + binder.Bind(firstComponent); + binder.Bind(secondComponent); + using var firstCancellation = new CancellationTokenSource(); + using var secondCancellation = new CancellationTokenSource(); + Task<(ServiceResult Result, List Outputs)> firstPending = harness.CallAsync( + firstComponent.CloseConnections!, harness.Context, + [Variant.From(firstEndpoints), Variant.From(false)], firstCancellation.Token); + Task<(ServiceResult Result, List Outputs)> secondPending = harness.CallAsync( + secondComponent.CloseConnections!, secondContext, + [Variant.From(secondEndpoints), Variant.From(true)], secondCancellation.Token); + + try + { + CloseLifecycleCall[] calls = await Task.WhenAll( + firstCall.Task.WaitAsync(TimeSpan.FromSeconds(10)), + secondCall.Task.WaitAsync(TimeSpan.FromSeconds(10))).ConfigureAwait(false); + Assert.That(calls[0].Context, Is.SameAs(harness.Context)); + Assert.That(calls[0].Component, Is.SameAs(firstComponent)); + Assert.That(calls[0].Endpoints, Is.EqualTo(firstEndpoints)); + Assert.That(calls[0].Remove, Is.False); + Assert.That(calls[0].Token, Is.EqualTo(firstCancellation.Token)); + Assert.That(calls[1].Context, Is.SameAs(secondContext)); + Assert.That(calls[1].Component, Is.SameAs(secondComponent)); + Assert.That(calls[1].Endpoints, Is.EqualTo(secondEndpoints)); + Assert.That(calls[1].Remove, Is.True); + Assert.That(calls[1].Token, Is.EqualTo(secondCancellation.Token)); + Assert.That(firstPending.IsCompleted, Is.False); + Assert.That(secondPending.IsCompleted, Is.False); + + secondGate.SetResult(secondResults); + (ServiceResult secondResult, List secondOutputs) = + await secondPending.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(secondResult.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(secondOutputs, Has.Count.EqualTo(1)); + Assert.That(secondOutputs[0].TryGetValue(out ArrayOf actualSecondResults), Is.True); + Assert.That(actualSecondResults, Is.EqualTo(secondResults)); + Assert.That(firstPending.IsCompleted, Is.False); + + firstGate.SetResult(firstResults); + (ServiceResult firstResult, List firstOutputs) = + await firstPending.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(firstResult.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(firstOutputs, Has.Count.EqualTo(1)); + Assert.That(firstOutputs[0].TryGetValue(out ArrayOf actualFirstResults), Is.True); + Assert.That(actualFirstResults, Is.EqualTo(firstResults)); + lifecycle.Verify(value => value.CloseConnectionsAsync( + It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + finally + { + firstGate.TrySetResult(firstResults); + secondGate.TrySetResult(secondResults); + await Task.WhenAll(firstPending, secondPending) + .WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); + } + } + + private static CloseConnectionsMethodState CreateMisparentedCloseMethod(Harness harness) + { + var otherComponent = new AutomationComponentState(null) + { + NodeId = new NodeId("other-component", 1) + }; + var method = new CloseConnectionsMethodState(otherComponent); + method.Create(harness.Context, new NodeId("misparented-close", 1), + new QualifiedName("CloseConnections"), new LocalizedText("CloseConnections"), true); + return method; + } + + [Test] + public async Task FxFunctionalEntityNotSetWithoutErrorsDoesNotAbortReservationAsync() + { + var policy = new Mock(); + policy.Setup(value => value.ResolveAsync( + Profiles.PubSubUdpUadpTransport, It.IsAny())) + .Returns(new ValueTask(Variant.From(7UL))); + await using var harness = new Harness(policy.Object, + new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + EstablishConnectionsMethodState method = harness.BindFx(); + ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc); + NodeId functionalEntityId = new("not-set-without-errors-fe", namespaceIndex); + _ = harness.AddVerifiableFunctionalEntity( + functionalEntityId, FunctionalEntityVerificationResultEnum.NotSet, + verificationErrors: ArrayOf.Empty); + NodeIdValuePair expectedVariable = ExpectedVerificationVariable("expected", namespaceIndex); + ConnectionEndpointConfigurationDataType endpoint = FxEndpoint( + functionalEntityId, [expectedVariable]); + + (ServiceResult result, List outputs) = await harness.CallAsync( + method, harness.Context, FxArguments( + (uint)FxCommandMask.VerifyFunctionalEntityCmd | + (uint)FxCommandMask.ReserveCommunicationIdsCmd, + ArrayOf.Empty, [endpoint], FxRequest())).ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(outputs, Has.Count.EqualTo(4)); + Assert.That(harness.VerifyFunctionalEntityCalls, Is.EqualTo(1)); + Assert.That(harness.LastVerifyFunctionalEntityInputs.Count, Is.EqualTo(1)); + Assert.That(harness.LastVerifyFunctionalEntityInputs[0].TryGetValue( + out ArrayOf passedVariables, harness.Context.AsMessageContext()), Is.True); + Assert.That(passedVariables, Is.EqualTo(new[] { expectedVariable })); + Assert.That(FxAssetResults(harness, outputs), Is.Empty); + ConnectionEndpointConfigurationResultDataType[] endpointResults = FxEndpointResults(harness, outputs); + Assert.That(endpointResults, Has.Length.EqualTo(1)); + Assert.That(endpointResults[0].FunctionalEntityNodeResult, Is.EqualTo(StatusCodes.Good)); + Assert.That(endpointResults[0].ConnectionEndpointId, Is.EqualTo(endpoint.ConnectionEndpoint.Node)); + Assert.That(endpointResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good)); + Assert.That(endpointResults[0].VerificationResult, + Is.EqualTo(FunctionalEntityVerificationResultEnum.NotSet)); + Assert.That(endpointResults[0].VerificationVariablesErrors.IsEmpty, Is.True); + PubSubReserveCommunicationIdsResult2DataType[] reservations = FxResults(harness, outputs); + Assert.That(reservations, Has.Length.EqualTo(1)); + Assert.That(reservations[0].Result, Is.EqualTo(StatusCodes.Good)); + Assert.That(reservations[0].WriterGroupIds, Is.EqualTo(new ushort[] { 0x8000 })); + Assert.That(reservations[0].DataSetWriterIds, Is.EqualTo(new ushort[] { 0x8000 })); + Assert.That(reservations[0].DefaultPublisherId.TryGetValue(out ulong publisher), Is.True); + Assert.That(publisher, Is.EqualTo(7UL)); + policy.Verify(value => value.ResolveAsync( + Profiles.PubSubUdpUadpTransport, It.IsAny()), Times.Once); + } + + private static void AssertNoMethodHandlers(MethodState method) + { + Assert.That(method.OnCallMethod, Is.Null); + Assert.That(method.OnCallMethod2, Is.Null); + Assert.That(method.OnCallMethodWithResultAsync, Is.Null); + Assert.That(method.OnCallMethod2Async, Is.Null); + if (method is EstablishConnectionsMethodState establish) + { + Assert.That(establish.OnCall, Is.Null); + Assert.That(establish.OnCallAsync, Is.Null); + } + if (method is CloseConnectionsMethodState close) + { + Assert.That(close.OnCall, Is.Null); + Assert.That(close.OnCallAsync, Is.Null); + } + } + + private static async ValueTask> AwaitLifecycleResultsAsync( + Task> results) + { + return await results.ConfigureAwait(false); + } + + private sealed record CloseLifecycleCall( + ISystemContext Context, + AutomationComponentState Component, + ArrayOf Endpoints, + bool Remove, + CancellationToken Token); + private static async ValueTask> WaitForCancellationAsync( TaskCompletionSource entered, CancellationToken cancellationToken) @@ -2726,6 +3342,187 @@ namespace Opc.Ua.PubSub.Server.Tests return []; } + [Test] + public async Task FxEndpointLifecycleRunsAfterReservationsAndReturnsOrderedResults() + { + var events = new List(); + var policy = new Mock(); + policy.Setup(value => value.ResolveAsync( + Profiles.PubSubUdpUadpTransport, It.IsAny())) + .Returns((string _, CancellationToken _) => + { + events.Add("reserve"); + return new ValueTask(Variant.From(7UL)); + }); + await using var harness = new Harness(policy.Object, + new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + AutomationComponentState component = harness.CreateFxComponentWithCloseConnections(); + EstablishConnectionsMethodState method = component.EstablishConnections!; + var lifecycle = new Mock(); + ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc); + ConnectionEndpointConfigurationDataType endpoint = FxEndpoint( + new NodeId("lifecycle-functional-entity", namespaceIndex), + ArrayOf.Empty); + endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)]; + ArrayOf endpoints = [endpoint]; + uint commandMask = (uint)FxCommandMask.ReserveCommunicationIdsCmd | + (uint)FxCommandMask.SetConfigurationDataCmd; + var endpointResult = new ConnectionEndpointConfigurationResultDataType + { + ConnectionEndpointId = endpoint.ConnectionEndpoint.Node, + FunctionalEntityNodeResult = StatusCodes.Good, + ConnectionEndpointResult = StatusCodes.Good, + VerificationResult = FunctionalEntityVerificationResultEnum.NotSet, + VerificationStatus = StatusCodes.Good, + VerificationVariablesErrors = [], + EstablishControlResult = [], + ConfigurationDataResult = [StatusCodes.Good], + ReassignControlResult = [], + CommunicationLinksResult = StatusCodes.Good, + EnableCommunicationResult = StatusCodes.Good + }; + lifecycle.Setup(value => value.EstablishConnectionsAsync( + It.IsAny(), + It.IsAny(), + commandMask, + It.IsAny>(), + It.IsAny>(), + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns((ISystemContext context, AutomationComponentState actualComponent, uint actualCommandMask, + ArrayOf actualEndpoints, + ArrayOf initialResults, + ArrayOf reservationResults, + ArrayOf actualConfigurations, + CancellationToken cancellationToken) => + { + events.Add("lifecycle"); + Assert.That(context, Is.SameAs(harness.Context)); + Assert.That(actualComponent, Is.SameAs(component)); + Assert.That(actualCommandMask, Is.EqualTo(commandMask)); + Assert.That(actualEndpoints, Is.EqualTo(endpoints)); + Assert.That(initialResults.Count, Is.EqualTo(1)); + Assert.That(initialResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good)); + Assert.That(reservationResults.Count, Is.EqualTo(1)); + Assert.That(reservationResults[0], + Is.InstanceOf()); + var reservation = + (PubSubReserveCommunicationIdsResult2DataType)reservationResults[0]; + Assert.That(reservation.Result, Is.EqualTo(StatusCodes.Good)); + Assert.That(reservation.WriterGroupIds.Count, Is.EqualTo(1)); + Assert.That(reservation.WriterGroupIds[0], Is.EqualTo((ushort)0x8000)); + Assert.That(actualConfigurations, Is.Empty); + Assert.That(cancellationToken, Is.EqualTo(CancellationToken.None)); + return new ValueTask( + new PubSubFxEstablishConnectionsResult( + ServiceResult.Good, + [endpointResult], + ArrayOf.Empty)); + }); + new PubSubFxReservationMethods( + harness.Application, + new PubSubServerOptions { ExposeFxModel = true }, + lifecycle.Object).Bind(component); + + (ServiceResult result, List outputs) = await harness.CallAsync( + method, harness.Context, FxArguments( + commandMask, + ArrayOf.Empty, + endpoints, + FxRequest())).ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(string.Join(",", events), Is.EqualTo("reserve,lifecycle")); + Assert.That(outputs, Has.Count.EqualTo(4)); + ConnectionEndpointConfigurationResultDataType[] endpointResults = + FxEndpointResults(harness, outputs); + Assert.That(endpointResults, Has.Length.EqualTo(1)); + Assert.That(endpointResults[0].ConnectionEndpointId, Is.EqualTo(endpointResult.ConnectionEndpointId)); + Assert.That(endpointResults[0].ConfigurationDataResult, Is.EqualTo(new[] { StatusCodes.Good })); + Assert.That(FxResults(harness, outputs)[0].Result, Is.EqualTo(StatusCodes.Good)); + Assert.That(outputs[3].TryGetValue( + out ArrayOf configurations, + harness.Context.AsMessageContext()), Is.True); + Assert.That(configurations, Is.Empty); + lifecycle.Verify(value => value.EstablishConnectionsAsync( + It.IsAny(), + It.IsAny(), + commandMask, + It.IsAny>(), + It.IsAny>(), + It.IsAny>(), + It.IsAny>(), + It.IsAny()), Times.Once); + } + + [Test] + public async Task FxEndpointLifecycleWithoutHostCapabilityFailsClosed() + { + await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + EstablishConnectionsMethodState method = harness.BindFx(); + ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc); + ConnectionEndpointConfigurationDataType endpoint = FxEndpoint( + new NodeId("lifecycle-functional-entity", namespaceIndex), + ArrayOf.Empty); + endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)]; + uint commandMask = (uint)FxCommandMask.SetConfigurationDataCmd; + + (ServiceResult result, List outputs) = await harness.CallAsync( + method, harness.Context, FxArguments( + commandMask, + ArrayOf.Empty, + [endpoint])).ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That(outputs, Is.Empty); + } + + [Test] + public async Task FxEndpointLifecycleRejectsMalformedHostResultShape() + { + await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true }); + await harness.InitializeAsync().ConfigureAwait(false); + AutomationComponentState component = harness.CreateFxComponentWithCloseConnections(); + EstablishConnectionsMethodState method = component.EstablishConnections!; + var lifecycle = new Mock(); + uint commandMask = (uint)FxCommandMask.SetConfigurationDataCmd; + ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc); + ConnectionEndpointConfigurationDataType endpoint = FxEndpoint( + new NodeId("lifecycle-functional-entity", namespaceIndex), + ArrayOf.Empty); + endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)]; + lifecycle.Setup(value => value.EstablishConnectionsAsync( + It.IsAny(), + It.IsAny(), + commandMask, + It.IsAny>(), + It.IsAny>(), + It.IsAny>(), + It.IsAny>(), + It.IsAny())) + .Returns(new ValueTask( + new PubSubFxEstablishConnectionsResult( + ServiceResult.Good, + ArrayOf.Empty, + ArrayOf.Empty))); + new PubSubFxReservationMethods( + harness.Application, + new PubSubServerOptions { ExposeFxModel = true }, + lifecycle.Object).Bind(component); + + (ServiceResult result, List outputs) = await harness.CallAsync( + method, harness.Context, FxArguments( + commandMask, + ArrayOf.Empty, + [endpoint])).ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError)); + Assert.That(outputs, Is.Empty); + } + private static PubSubReserveCommunicationIds2DataType FxRequest() { return new PubSubReserveCommunicationIds2DataType