Add host-dispatched UAFX endpoint lifecycle

This commit is contained in:
Marc
2026-10-27 06:15:50 +01:00
parent 1d6db20b1d
commit 0dfae76f41
6 changed files with 1143 additions and 73 deletions
+40 -28
View File
@@ -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`.
@@ -51,8 +51,8 @@ namespace Microsoft.Extensions.DependencyInjection
{
/// <summary>
/// Registers injectable Part 81 reservation methods for host-owned AutomationComponents.
/// A host lifecycle capability registered with <see cref="WithFxConnectionLifecycle"/> is also used
/// to bind the component's <c>CloseConnections</c> Method.
/// A host lifecycle capability registered with <see cref="WithFxConnectionLifecycle"/> handles
/// endpoint lifecycle commands and the component's <c>CloseConnections</c> Method.
/// </summary>
[global::System.Diagnostics.CodeAnalysis.Experimental(DataChannelFeature.ExperimentalDiagnosticId,
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
@@ -70,7 +70,8 @@ namespace Microsoft.Extensions.DependencyInjection
}
/// <summary>
/// Registers the host-owned, list-level lifecycle transaction used by Part 81 <c>CloseConnections</c>.
/// Registers the host-owned lifecycle capability used by Part 81 endpoint commands and
/// <c>CloseConnections</c>.
/// </summary>
/// <param name="builder">PubSub server builder.</param>
/// <param name="lifecycle">Host implementation that authorizes and transacts endpoint lifecycle.</param>
@@ -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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IPubSubFxConnectionLifecycle
{
/// <summary>
/// Processes the host-owned endpoint commands in an EstablishConnections call.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="context">The actual Method call context, including its authenticated Session.</param>
/// <param name="component">The host-owned AutomationComponent whose Method was called.</param>
/// <param name="commandMask">The complete EstablishConnections command mask.</param>
/// <param name="connectionEndpointConfigurations">The ordered endpoint configuration requests.</param>
/// <param name="initialEndpointResults">
/// Ordered results produced by earlier verification commands in the same Method call.
/// </param>
/// <param name="reservationResults">
/// Ordered communication-ID reservations completed earlier in the same Method call.
/// </param>
/// <param name="communicationConfigurations">
/// Communication-model configuration supplied for SetCommunicationConfigurationCmd.
/// </param>
/// <param name="cancellationToken">Cancels processing where the host can safely honor cancellation.</param>
/// <returns>Overall status and the ordered endpoint and communication-configuration results.</returns>
ValueTask<PubSubFxEstablishConnectionsResult> EstablishConnectionsAsync(
ISystemContext context,
AutomationComponentState component,
uint commandMask,
ArrayOf<ConnectionEndpointConfigurationDataType> connectionEndpointConfigurations,
ArrayOf<ConnectionEndpointConfigurationResultDataType> initialEndpointResults,
ArrayOf<ReserveCommunicationIdsResultDataType> reservationResults,
ArrayOf<CommunicationConfigurationDataType> communicationConfigurations,
CancellationToken cancellationToken = default);
/// <summary>
/// Closes or removes the specified host-owned ConnectionEndpoints in one lifecycle transaction.
/// </summary>
@@ -62,4 +98,45 @@ namespace Opc.Ua.PubSub.Server
bool remove,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Contains the result of host-owned Part 81 EstablishConnections endpoint commands.
/// </summary>
public sealed class PubSubFxEstablishConnectionsResult
{
/// <summary>
/// Initializes a result containing the overall method status and ordered command result arrays.
/// </summary>
/// <param name="serviceResult">The overall EstablishConnections status.</param>
/// <param name="connectionEndpointConfigurationResults">
/// Per-endpoint results in the request order.
/// </param>
/// <param name="communicationConfigurationResults">
/// Communication-configuration result array; one item when the command is processed.
/// </param>
public PubSubFxEstablishConnectionsResult(
ServiceResult serviceResult,
ArrayOf<ConnectionEndpointConfigurationResultDataType> connectionEndpointConfigurationResults,
ArrayOf<CommunicationConfigurationResultDataType> communicationConfigurationResults)
{
ServiceResult = serviceResult;
ConnectionEndpointConfigurationResults = connectionEndpointConfigurationResults;
CommunicationConfigurationResults = communicationConfigurationResults;
}
/// <summary>
/// Gets the overall EstablishConnections status.
/// </summary>
public ServiceResult ServiceResult { get; }
/// <summary>
/// Gets the per-endpoint results in request order.
/// </summary>
public ArrayOf<ConnectionEndpointConfigurationResultDataType> ConnectionEndpointConfigurationResults { get; }
/// <summary>
/// Gets the communication-configuration result array.
/// </summary>
public ArrayOf<CommunicationConfigurationResultDataType> CommunicationConfigurationResults { get; }
}
}
@@ -41,10 +41,8 @@ using Opc.Ua.Server;
namespace Opc.Ua.PubSub.Server
{
/// <summary>
/// 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.
/// </summary>
[global::System.Diagnostics.CodeAnalysis.Experimental(DataChannelFeature.ExperimentalDiagnosticId,
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
@@ -64,7 +62,8 @@ namespace Opc.Ua.PubSub.Server
/// <param name="application">The PubSub application supporting communication-ID reservations.</param>
/// <param name="options">The PubSub server options governing configuration method authorization.</param>
/// <param name="connectionLifecycle">
/// Optional host capability that atomically closes or removes ConnectionEndpoints.
/// Optional host capability that processes endpoint lifecycle commands and closes or removes
/// ConnectionEndpoints.
/// </param>
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<ReserveCommunicationIdsDataType> reservations,
ArrayOf<CommunicationConfigurationDataType> configurations,
Func<CancellationToken, ValueTask<string?>>? 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<NodeIdValuePair> 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;
}
/// <summary>
/// Returns the existing endpoint NodeId when the union selects its Node member.
/// </summary>
private static NodeId GetConnectionEndpointNodeId(ConnectionEndpointConfigurationDataType endpoint)
{
return endpoint.ConnectionEndpoint.SwitchField == ConnectionEndpointDefinitionDataTypeFields.Node
? endpoint.ConnectionEndpoint.Node
: NodeId.Null;
}
/// <summary>
/// Confirms the FunctionalEntity is hosted by an Asset in the bound AutomationComponent.
/// </summary>
@@ -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<IPubSubFxConnectionLifecycle>();
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<PubSubFxReservationMethods>();
PubSubServerOptions registeredOptions =
provider.GetRequiredService<IOptions<PubSubServerOptions>>().Value;
Assert.Multiple(() =>
{
Assert.That(provider.GetRequiredService<IPubSubFxConnectionLifecycle>(), 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<NodeId> endpoints =
[new NodeId("endpoint:z", 1), new NodeId("endpoint:a", 1), new NodeId("endpoint:z", 1)];
ArrayOf<StatusCode> expected =
[StatusCodes.BadInvalidState, StatusCodes.Good, StatusCodes.BadNodeIdUnknown];
const bool remove = true;
binder.Bind(component);
lifecycle.Setup(value => value.CloseConnectionsAsync(
It.IsAny<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Returns((ISystemContext context, AutomationComponentState actualComponent,
ArrayOf<NodeId> 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<ArrayOf<StatusCode>>(expected);
});
(ServiceResult result, List<Variant> 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<StatusCode> actualResults), Is.True);
Assert.That(actualResults, Is.EqualTo(expected));
lifecycle.Verify(value => value.CloseConnectionsAsync(
It.IsAny<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Once);
}
internal static ServiceCollection BuildServicesWithRuntime()
{
var services = new ServiceCollection();
@@ -2717,6 +2717,622 @@ namespace Opc.Ua.PubSub.Server.Tests
It.IsAny<bool>(), It.IsAny<CancellationToken>()), 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<IPubSubDefaultPublisherIdPolicy>();
policy.Setup(value => value.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns((string _, CancellationToken _) => new ValueTask<Variant>(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<string?>("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<Variant> 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<string>(), It.IsAny<CancellationToken>()),
Times.Never);
(ServiceResult next, List<Variant> 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<IPubSubFxConnectionLifecycle>();
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<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public async Task FxVerificationRejectsNullSelectedEndpointNodeBeforeCallingHostAsync()
{
var policy = new Mock<IPubSubDefaultPublisherIdPolicy>();
policy.Setup(value => value.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns((string _, CancellationToken _) => new ValueTask<Variant>(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<Variant> outputs) = await harness.CallAsync(
method, harness.Context, FxArguments(
(uint)FxCommandMask.VerifyFunctionalEntityCmd |
(uint)FxCommandMask.ReserveCommunicationIdsCmd,
ArrayOf<AssetVerificationDataType>.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<string>(), It.IsAny<CancellationToken>()),
Times.Never);
(ServiceResult next, List<Variant> 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<IPubSubFxConnectionLifecycle>();
new PubSubFxReservationMethods(
harness.Application, new PubSubServerOptions { ExposeFxModel = true }, lifecycle.Object)
.Bind(component);
ArrayOf<NodeId> endpoints = [new NodeId("dispatch-endpoint", 4)];
ArrayOf<Variant> 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<ServiceResult>();
var outputs = new List<Variant>();
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<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public async Task FxAllSupportedCommandsExecuteInStageOrderAsync()
{
var events = new List<string>();
var assetEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var functionalEntityEntered =
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var reservationEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var assetGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var functionalEntityGate = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var policy = new Mock<IPubSubDefaultPublisherIdPolicy>();
policy.Setup(value => value.ResolveAsync(
Profiles.PubSubUdpUadpTransport, It.IsAny<CancellationToken>()))
.Returns((string _, CancellationToken _) =>
{
events.Add("reservation");
reservationEntered.TrySetResult(true);
return new ValueTask<Variant>(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<StatusCode>.Empty);
outputs[2] = Variant.From(ArrayOf<StatusCode>.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<StatusCode>.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<Variant> 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<Variant> 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<CommunicationConfigurationResultDataType> 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<IPubSubDefaultPublisherIdPolicy>();
policy.Setup(value => value.ResolveAsync(
Profiles.PubSubUdpUadpTransport, It.IsAny<CancellationToken>()))
.Returns(new ValueTask<Variant>(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<Variant> 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<string>(), It.IsAny<CancellationToken>()), Times.Never);
(ServiceResult next, List<Variant> 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<CancellationToken>()), 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<NodeId> firstEndpoints =
[new NodeId("first:endpoint:a", 1), new NodeId("first:endpoint:b", 1)];
ArrayOf<NodeId> secondEndpoints =
[new NodeId("second:endpoint:b", 1), new NodeId("second:endpoint:a", 1)];
ArrayOf<StatusCode> firstResults = [StatusCodes.Good, StatusCodes.BadNodeIdUnknown];
ArrayOf<StatusCode> secondResults = [StatusCodes.BadInvalidState, StatusCodes.Good];
var firstCall = new TaskCompletionSource<CloseLifecycleCall>(
TaskCreationOptions.RunContinuationsAsynchronously);
var secondCall = new TaskCompletionSource<CloseLifecycleCall>(
TaskCreationOptions.RunContinuationsAsynchronously);
var firstGate = new TaskCompletionSource<ArrayOf<StatusCode>>(
TaskCreationOptions.RunContinuationsAsynchronously);
var secondGate = new TaskCompletionSource<ArrayOf<StatusCode>>(
TaskCreationOptions.RunContinuationsAsynchronously);
var lifecycle = new Mock<IPubSubFxConnectionLifecycle>();
lifecycle.Setup(value => value.CloseConnectionsAsync(
It.IsAny<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.Returns((ISystemContext context, AutomationComponentState component, ArrayOf<NodeId> 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<Variant> Outputs)> firstPending = harness.CallAsync(
firstComponent.CloseConnections!, harness.Context,
[Variant.From(firstEndpoints), Variant.From(false)], firstCancellation.Token);
Task<(ServiceResult Result, List<Variant> 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<Variant> 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<StatusCode> actualSecondResults), Is.True);
Assert.That(actualSecondResults, Is.EqualTo(secondResults));
Assert.That(firstPending.IsCompleted, Is.False);
firstGate.SetResult(firstResults);
(ServiceResult firstResult, List<Variant> 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<StatusCode> actualFirstResults), Is.True);
Assert.That(actualFirstResults, Is.EqualTo(firstResults));
lifecycle.Verify(value => value.CloseConnectionsAsync(
It.IsAny<ISystemContext>(), It.IsAny<AutomationComponentState>(),
It.IsAny<ArrayOf<NodeId>>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
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<IPubSubDefaultPublisherIdPolicy>();
policy.Setup(value => value.ResolveAsync(
Profiles.PubSubUdpUadpTransport, It.IsAny<CancellationToken>()))
.Returns(new ValueTask<Variant>(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<StatusCode>.Empty);
NodeIdValuePair expectedVariable = ExpectedVerificationVariable("expected", namespaceIndex);
ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
functionalEntityId, [expectedVariable]);
(ServiceResult result, List<Variant> outputs) = await harness.CallAsync(
method, harness.Context, FxArguments(
(uint)FxCommandMask.VerifyFunctionalEntityCmd |
(uint)FxCommandMask.ReserveCommunicationIdsCmd,
ArrayOf<AssetVerificationDataType>.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<NodeIdValuePair> 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<CancellationToken>()), 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<ArrayOf<StatusCode>> AwaitLifecycleResultsAsync(
Task<ArrayOf<StatusCode>> results)
{
return await results.ConfigureAwait(false);
}
private sealed record CloseLifecycleCall(
ISystemContext Context,
AutomationComponentState Component,
ArrayOf<NodeId> Endpoints,
bool Remove,
CancellationToken Token);
private static async ValueTask<ArrayOf<StatusCode>> WaitForCancellationAsync(
TaskCompletionSource<bool> entered,
CancellationToken cancellationToken)
@@ -2726,6 +3342,187 @@ namespace Opc.Ua.PubSub.Server.Tests
return [];
}
[Test]
public async Task FxEndpointLifecycleRunsAfterReservationsAndReturnsOrderedResults()
{
var events = new List<string>();
var policy = new Mock<IPubSubDefaultPublisherIdPolicy>();
policy.Setup(value => value.ResolveAsync(
Profiles.PubSubUdpUadpTransport, It.IsAny<CancellationToken>()))
.Returns((string _, CancellationToken _) =>
{
events.Add("reserve");
return new ValueTask<Variant>(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<IPubSubFxConnectionLifecycle>();
ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
new NodeId("lifecycle-functional-entity", namespaceIndex),
ArrayOf<NodeIdValuePair>.Empty);
endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
ArrayOf<ConnectionEndpointConfigurationDataType> 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<ISystemContext>(),
It.IsAny<AutomationComponentState>(),
commandMask,
It.IsAny<ArrayOf<ConnectionEndpointConfigurationDataType>>(),
It.IsAny<ArrayOf<ConnectionEndpointConfigurationResultDataType>>(),
It.IsAny<ArrayOf<ReserveCommunicationIdsResultDataType>>(),
It.IsAny<ArrayOf<CommunicationConfigurationDataType>>(),
It.IsAny<CancellationToken>()))
.Returns((ISystemContext context, AutomationComponentState actualComponent, uint actualCommandMask,
ArrayOf<ConnectionEndpointConfigurationDataType> actualEndpoints,
ArrayOf<ConnectionEndpointConfigurationResultDataType> initialResults,
ArrayOf<ReserveCommunicationIdsResultDataType> reservationResults,
ArrayOf<CommunicationConfigurationDataType> 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<PubSubReserveCommunicationIdsResult2DataType>());
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<PubSubFxEstablishConnectionsResult>(
new PubSubFxEstablishConnectionsResult(
ServiceResult.Good,
[endpointResult],
ArrayOf<CommunicationConfigurationResultDataType>.Empty));
});
new PubSubFxReservationMethods(
harness.Application,
new PubSubServerOptions { ExposeFxModel = true },
lifecycle.Object).Bind(component);
(ServiceResult result, List<Variant> outputs) = await harness.CallAsync(
method, harness.Context, FxArguments(
commandMask,
ArrayOf<AssetVerificationDataType>.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<CommunicationConfigurationResultDataType> configurations,
harness.Context.AsMessageContext()), Is.True);
Assert.That(configurations, Is.Empty);
lifecycle.Verify(value => value.EstablishConnectionsAsync(
It.IsAny<ISystemContext>(),
It.IsAny<AutomationComponentState>(),
commandMask,
It.IsAny<ArrayOf<ConnectionEndpointConfigurationDataType>>(),
It.IsAny<ArrayOf<ConnectionEndpointConfigurationResultDataType>>(),
It.IsAny<ArrayOf<ReserveCommunicationIdsResultDataType>>(),
It.IsAny<ArrayOf<CommunicationConfigurationDataType>>(),
It.IsAny<CancellationToken>()), 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<NodeIdValuePair>.Empty);
endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
uint commandMask = (uint)FxCommandMask.SetConfigurationDataCmd;
(ServiceResult result, List<Variant> outputs) = await harness.CallAsync(
method, harness.Context, FxArguments(
commandMask,
ArrayOf<AssetVerificationDataType>.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<IPubSubFxConnectionLifecycle>();
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<NodeIdValuePair>.Empty);
endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
lifecycle.Setup(value => value.EstablishConnectionsAsync(
It.IsAny<ISystemContext>(),
It.IsAny<AutomationComponentState>(),
commandMask,
It.IsAny<ArrayOf<ConnectionEndpointConfigurationDataType>>(),
It.IsAny<ArrayOf<ConnectionEndpointConfigurationResultDataType>>(),
It.IsAny<ArrayOf<ReserveCommunicationIdsResultDataType>>(),
It.IsAny<ArrayOf<CommunicationConfigurationDataType>>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask<PubSubFxEstablishConnectionsResult>(
new PubSubFxEstablishConnectionsResult(
ServiceResult.Good,
ArrayOf<ConnectionEndpointConfigurationResultDataType>.Empty,
ArrayOf<CommunicationConfigurationResultDataType>.Empty)));
new PubSubFxReservationMethods(
harness.Application,
new PubSubServerOptions { ExposeFxModel = true },
lifecycle.Object).Bind(component);
(ServiceResult result, List<Variant> outputs) = await harness.CallAsync(
method, harness.Context, FxArguments(
commandMask,
ArrayOf<AssetVerificationDataType>.Empty,
[endpoint])).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
Assert.That(outputs, Is.Empty);
}
private static PubSubReserveCommunicationIds2DataType FxRequest()
{
return new PubSubReserveCommunicationIds2DataType