OPCFoundation / UA-.NETStandard

OPC Unified Architecture .NET Standard
Other
1.97k stars 950 forks source link

TransferId of a subscription is overwritten in CreateOrModifySubscription during reconnect to a session #2820

Open ganko-pi opened 3 weeks ago

ganko-pi commented 3 weeks ago

Type of issue

Current Behavior

During a reconnect to a session the method CreateOrModifySubscription in Subscription.cs is called. In line 2022 the m_transferId and hence the TransferId is overwritten even if previously the TransferId was set.

Expected Behavior

The TransferId does not change so the new subscription can be associated with the previous subscription.

Steps To Reproduce

  1. Operating system: Microsoft Windows 10
  2. Tested with commit 0b23e5f3 on branch release/1.5.374
  3. Clone the UA-.NETStandard repository from GitHub (https://github.com/OPCFoundation/UA-.NETStandard)
  4. Create a new C# console project with the name OpcUaExample and .NET 8
  5. Add the project Opc.Ua.Client.csproj from UA-.NETStandard/Libraries/Opc.Ua.Client_ to the solution
  6. Add a project reference to Opc.Ua.Client to OpcUaExample
  7. Replace the contents of Program.cs with the following:
    
    using Opc.Ua;
    using Opc.Ua.Client;
    using Opc.Ua.Configuration;
    using ISession = Opc.Ua.Client.ISession;

namespace OpcUaExample;

///

/// Class containing the entry point of the program. /// public class Program {

/// <summary>
/// Entry point of the program.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static async Task Main()
{
    using OpcUaSessionKeeper opcUaSessionKeeper = new("opc.tcp://localhost:62541/Quickstarts/ReferenceServer");

    await opcUaSessionKeeper.ConnectOpcUaSession();

    Console.WriteLine("Executing {0}", nameof(opcUaSessionKeeper.MonitorNodeValue));
    opcUaSessionKeeper.MonitorNodeValue();
    opcUaSessionKeeper.WriteNodeValue(100);
    await Task.Delay(TimeSpan.FromSeconds(2));
    Console.WriteLine();
    Console.WriteLine("Please restart the OPC UA server to see the reconnection handler in action. Press any key to continue after the restart.");
    Console.WriteLine();
    Console.ReadKey(intercept: true);
    opcUaSessionKeeper.WriteNodeValue(101);
    await Task.Delay(TimeSpan.FromSeconds(2));
    opcUaSessionKeeper.RemoveSubscriptions();
    Console.WriteLine();

    await opcUaSessionKeeper.DisconnectOpcUaSession();

    Console.WriteLine("Press any key to exit");
    Console.ReadKey(intercept: true);
}

}

///

/// Class to manage a OPC UA session. /// public class OpcUaSessionKeeper : IDisposable { private readonly string _opcUaUri; private ISession? _opcUaSession; private SessionReconnectHandler? _sessionReconnectHandler; private readonly ushort _opcUaCertificateLifetimeInMonths = 180; private readonly List _subscriptions = [];

/// <summary>
/// Constructor to instantiate an <see cref="OpcUaSessionKeeper"/>.
/// </summary>
public OpcUaSessionKeeper(string uri)
{
    _opcUaUri = uri;
}

/// <inheritdoc/>
public void Dispose()
{
    DisconnectOpcUaSession().Wait();
}

/// <summary>
/// Creates a new OPC UA <see cref="ISession"/> and connects to it.
/// </summary>
public async Task ConnectOpcUaSession()
{
    // define the OPC UA client application
    ApplicationInstance application = new()
    {
        ApplicationType = ApplicationType.Client,
    };

    // load the application configuration
    string applicationConfigurationFilePath = Path.Combine(AppContext.BaseDirectory, "OpcUaExample.Config.xml");
    ApplicationConfiguration config = await application.LoadApplicationConfiguration(applicationConfigurationFilePath, silent: false);

    try
    {
        // check the application certificate.
        await application.CheckApplicationInstanceCertificate(silent: false, minimumKeySize: 0, _opcUaCertificateLifetimeInMonths);
    }
    catch (Exception ex)
    {
        string baseLoggingMessage = "Exception occured during check of certificate.";
        Console.WriteLine("{0} Cause: {1}: {2}.", baseLoggingMessage, ex.GetType(), ex.Message);

        // try deleting the old certificate and creating a new one
        await application.DeleteApplicationInstanceCertificate();
        application.ApplicationConfiguration.SecurityConfiguration.ApplicationCertificate.Certificate = null;

        // create a new application certificate
        await application.CheckApplicationInstanceCertificate(silent: false, minimumKeySize: 0, _opcUaCertificateLifetimeInMonths);

        Console.WriteLine("Deleted the old application certificate and created a new one successfully.");
    }

    // SessionTimeOut >= KeepAliveTimeout
    uint sessionTimeOutMs = (uint)TimeSpan.FromSeconds(60).TotalMilliseconds;
    int keepAliveIntervalMs = (int)TimeSpan.FromSeconds(30).TotalMilliseconds;

    string serverUri = _opcUaUri;
    UserIdentity? userIdentity = null;

    Console.WriteLine("Connecting to OPC UA server {0}.", serverUri);

    // configure endpoint for OPC UA
    EndpointDescription endpointDescription = CoreClientUtils.SelectEndpoint(config, serverUri, useSecurity: true);
    EndpointConfiguration endpointConfiguration = EndpointConfiguration.Create(config);
    ConfiguredEndpoint endpoint = new(null, endpointDescription, endpointConfiguration);

    Session session = await Session.Create(
        config,
        endpoint,
        updateBeforeConnect: false,
        sessionName: config.ApplicationName,
        sessionTimeOutMs,
        userIdentity,
        preferredLocales: null
    );

    session.KeepAliveInterval = keepAliveIntervalMs;
    session.DeleteSubscriptionsOnClose = false;
    session.TransferSubscriptionsOnReconnect = true;

    Console.WriteLine("New session for OPC UA created with session name {0} for server {1}.", session.SessionName, serverUri);

    _sessionReconnectHandler = new SessionReconnectHandler(reconnectAbort: true, maxReconnectPeriod: (int)TimeSpan.FromMinutes(5).TotalMilliseconds);
    session.KeepAlive += RecoverSessionOnError;

    _opcUaSession = session;
}

private void RecoverSessionOnError(ISession session, KeepAliveEventArgs e)
{
    if (e.Status?.StatusCode.Code != StatusCodes.BadSecureChannelClosed)
    {
        return;
    }

    Console.WriteLine("Received bad status {0} for session with name {1} for server {2}. Recovering session with id {3}.",
        e.Status, session.SessionName, string.Join(", ", session.ServerUris.ToArray()), session.SessionId);

    _sessionReconnectHandler!.BeginReconnect(session, (int)TimeSpan.FromSeconds(1).TotalMilliseconds, ReconnectCompleted);

    // Cancel sending a new keep alive request because reconnect is triggered.
    e.CancelKeepAlive = true;
}

private void ReconnectCompleted(object? sender, EventArgs e)
{
    SessionReconnectHandler sessionReconnectHandler = (SessionReconnectHandler)sender!;

    if (sessionReconnectHandler.Session == null)
    {
        Console.WriteLine("Session which was tried to recover recovered itself.");
        return;
    }

    ISession originalSession = _opcUaSession!;
    // ensure only a new instance is disposed
    // after reactivate, the same session instance may be returned
    if (ReferenceEquals(originalSession, sessionReconnectHandler.Session))
    {
        Console.WriteLine("Session with name {0} for server {1} was reactivated.",
            sessionReconnectHandler.Session.SessionName, string.Join(", ", sessionReconnectHandler.Session.ServerUris.ToArray()));
        return;
    }

    _opcUaSession = sessionReconnectHandler.Session;
    originalSession.Dispose();

    Console.WriteLine("Reconnected to a new session with name {0} for server {1}.",
        sessionReconnectHandler.Session.SessionName, string.Join(", ", sessionReconnectHandler.Session.ServerUris.ToArray()));
}

/// <summary>
/// Closes the OPC UA <see cref="ISession"/> if a connection exists.
/// </summary>
public async Task DisconnectOpcUaSession()
{
    if (_opcUaSession == null)
    {
        return;
    }

    _opcUaSession.KeepAlive -= RecoverSessionOnError;
    await _opcUaSession.CloseAsync();
    _opcUaSession.Dispose();
}

/// <summary>
/// Updates the value of a node.
/// </summary>
/// <param name="value">The new value for a node.</param>
/// <exception cref="InvalidOperationException">
/// <see cref="InvalidOperationException"/> is thrown when this
/// function is called when the client is not connected.
/// </exception>
public void WriteNodeValue(int value)
{
    if (_opcUaSession == null)
    {
        throw new InvalidOperationException("Session is null");
    }

    NodeId nodeToWrite = new(value: 2044, namespaceIndex: 3);
    WriteValueCollection writeValueCollection = [];
    WriteValue writeValue = new()
    {
        NodeId = nodeToWrite,
        AttributeId = Attributes.Value,
        Value = new DataValue(value),
    };
    writeValueCollection.Add(writeValue);

    ResponseHeader responseHeader = _opcUaSession.Write(null, writeValueCollection,
        out StatusCodeCollection statusCodeCollection, out DiagnosticInfoCollection diagnosticInfoCollection);
}

/// <summary>
/// Creates a subscription to monitor value changes of a node.
/// </summary>
/// <exception cref="InvalidOperationException">
/// <see cref="InvalidOperationException"/> is thrown when this
/// function is called when the client is not connected.
/// </exception>
public void MonitorNodeValue()
{
    if (_opcUaSession == null)
    {
        throw new InvalidOperationException("Session is null");
    }

    NodeId nodeToMonitor = new(value: 2044, namespaceIndex: 3);

    int publishingIntervalMs = (int)TimeSpan.FromSeconds(1).TotalMilliseconds;
    int samplingIntervalMs = (int)TimeSpan.FromSeconds(0.5).TotalMilliseconds;
    uint queueSize = (uint)Math.Ceiling((double)publishingIntervalMs / samplingIntervalMs);

    Subscription subscription = new(_opcUaSession.DefaultSubscription)
    {
        DisplayName = $"Subscription of OpcUaExample",
        PublishingEnabled = true,
        PublishingInterval = publishingIntervalMs,
        MinLifetimeInterval = (uint)TimeSpan.FromMinutes(2).TotalMilliseconds,
    };

    _opcUaSession.AddSubscription(subscription);

    // Create the subscription on server side
    subscription.Create();

    // Create MonitoredItems for data changes
    MonitoredItem monitoredItem = new(subscription.DefaultItem)
    {
        StartNodeId = nodeToMonitor,
        AttributeId = Attributes.Value,
        QueueSize = queueSize,
        SamplingInterval = samplingIntervalMs,
        DiscardOldest = true,
    };
    monitoredItem.Notification += NotificationEventHandler;

    subscription.AddItem(monitoredItem);

    // Create the monitored items on server side
    subscription.ApplyChanges();

    Console.WriteLine("MonitoredItems created for SubscriptionId = {0}.", subscription.Id);

    _subscriptions.Add(subscription);
}

/// <summary>
/// A function to print details of updates of a monitored node.
/// </summary>
/// <param name="opcUaMonitoredItem">The monitored node which was updated.</param>
/// <param name="e">Additional information for the monitored node.</param>
public void NotificationEventHandler(MonitoredItem opcUaMonitoredItem, MonitoredItemNotificationEventArgs e)
{
    MonitoredItemNotification notification = (MonitoredItemNotification)e.NotificationValue;
    Console.WriteLine("Subscription id: {0}, sequence number: {1}, node id: {2}, sampling time: {3}, value: {4}",
        opcUaMonitoredItem.Subscription.Id,
        opcUaMonitoredItem.Subscription.SequenceNumber,
        opcUaMonitoredItem.StartNodeId,
        notification.Value.SourceTimestamp,
        notification.Value.Value);
}

/// <summary>
/// Removes all subscriptions.
/// </summary>
/// <exception cref="InvalidOperationException">
/// <see cref="InvalidOperationException"/> is thrown when this
/// function is called when the client is not connected.
/// </exception>
public void RemoveSubscriptions()
{
    if (_opcUaSession == null)
    {
        return;
    }

    Console.WriteLine("Removing subscriptions with ids [{0}].", string.Join(", ", _subscriptions.Select(subscription => subscription.Id)));
    _opcUaSession.RemoveSubscriptions(_subscriptions);
    _subscriptions.Clear();
}

}

8. Create a file _OpcUaExample.Config.xml_ and paste the following:
```xml
<?xml version="1.0" encoding="utf-8"?>
<ApplicationConfiguration
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:ua="http://opcfoundation.org/UA/2008/02/Types.xsd"
  xmlns="http://opcfoundation.org/UA/SDK/Configuration.xsd"
>
    <ApplicationName>OpcUaExample</ApplicationName>
    <ApplicationUri>urn:localhost:OpcUaExample</ApplicationUri>
    <ApplicationType>Client_1</ApplicationType>

    <SecurityConfiguration>

        <!-- Where the application instance certificate is stored (MachineDefault) -->
        <ApplicationCertificate>
            <StoreType>Directory</StoreType>
            <StorePath>%LocalFolder%/Certificates/own</StorePath>
            <SubjectName>CN=OpcUaExample,DC=localhost</SubjectName>
        </ApplicationCertificate>

        <!-- Where the issuer certificate are stored (certificate authorities) -->
        <TrustedIssuerCertificates>
            <StoreType>Directory</StoreType>
            <StorePath>%LocalFolder%/Certificates/issuer</StorePath>
        </TrustedIssuerCertificates>

        <!-- Where the trust list is stored -->
        <TrustedPeerCertificates>
            <StoreType>Directory</StoreType>
            <StorePath>%LocalFolder%/Certificates/trusted</StorePath>
        </TrustedPeerCertificates>

        <!-- The directory used to store invalid certficates for later review by the administrator. -->
        <RejectedCertificateStore>
            <StoreType>Directory</StoreType>
            <StorePath>%LocalFolder%/Certificates/rejected</StorePath>
        </RejectedCertificateStore>

        <!-- WARNING: The following setting (to automatically accept untrusted certificates) should be used
    for easy debugging purposes ONLY and turned off for production deployments! -->
        <AutoAcceptUntrustedCertificates>true</AutoAcceptUntrustedCertificates>

        <!-- WARNING: SHA1 signed certficates are by default rejected and should be phased out. 
       only nano and embedded profiles are allowed to use sha1 signed certificates. -->
        <RejectSHA1SignedCertificates>true</RejectSHA1SignedCertificates>
        <RejectUnknownRevocationStatus>true</RejectUnknownRevocationStatus>
        <MinimumCertificateKeySize>2048</MinimumCertificateKeySize>
        <AddAppCertToTrustedStore>false</AddAppCertToTrustedStore>
        <SendCertificateChain>true</SendCertificateChain>

        <!-- Where the User trust list is stored-->
        <TrustedUserCertificates>
            <StoreType>Directory</StoreType>
            <StorePath>%LocalFolder%/Certificates/trustedUser</StorePath>
        </TrustedUserCertificates>

    </SecurityConfiguration>

    <TransportConfigurations></TransportConfigurations>

    <TransportQuotas>
        <OperationTimeout>120000</OperationTimeout>
        <MaxStringLength>4194304</MaxStringLength>
        <MaxByteStringLength>4194304</MaxByteStringLength>
        <MaxArrayLength>65535</MaxArrayLength>
        <MaxMessageSize>4194304</MaxMessageSize>
        <MaxBufferSize>65535</MaxBufferSize>
        <ChannelLifetime>300000</ChannelLifetime>
        <SecurityTokenLifetime>3600000</SecurityTokenLifetime>
    </TransportQuotas>

    <ClientConfiguration>
        <DefaultSessionTimeout>60000</DefaultSessionTimeout>
        <WellKnownDiscoveryUrls>
            <ua:String>opc.tcp://{0}:4840</ua:String>
            <ua:String>http://{0}:52601/UADiscovery</ua:String>
            <ua:String>http://{0}/UADiscovery/Default.svc</ua:String>
        </WellKnownDiscoveryUrls>
        <DiscoveryServers></DiscoveryServers>
        <MinSubscriptionLifetime>10000</MinSubscriptionLifetime>

        <OperationLimits>
            <MaxNodesPerRead>2500</MaxNodesPerRead>
            <MaxNodesPerHistoryReadData>1000</MaxNodesPerHistoryReadData>
            <MaxNodesPerHistoryReadEvents>1000</MaxNodesPerHistoryReadEvents>
            <MaxNodesPerWrite>2500</MaxNodesPerWrite>
            <MaxNodesPerHistoryUpdateData>1000</MaxNodesPerHistoryUpdateData>
            <MaxNodesPerHistoryUpdateEvents>1000</MaxNodesPerHistoryUpdateEvents>
            <MaxNodesPerMethodCall>2500</MaxNodesPerMethodCall>
            <MaxNodesPerBrowse>2500</MaxNodesPerBrowse>
            <MaxNodesPerRegisterNodes>2500</MaxNodesPerRegisterNodes>
            <MaxNodesPerTranslateBrowsePathsToNodeIds>2500</MaxNodesPerTranslateBrowsePathsToNodeIds>
            <MaxNodesPerNodeManagement>2500</MaxNodesPerNodeManagement>
            <MaxMonitoredItemsPerCall>2500</MaxMonitoredItemsPerCall>
        </OperationLimits>

    </ClientConfiguration>

</ApplicationConfiguration>
  1. Make sure that the configuration file is copied to output directory, e.g. with adding the following to OpcUaExample.csproj:
    <ItemGroup>
    <None Update="OpcUaExample.Config.xml">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
    </ItemGroup>
  2. Open the solution UA-.NETStandard/UA Reference.sln
  3. Start the project ConsoleReferenceServer
  4. Start the project OpcUaExample
  5. OpcUaExample fails with ServiceResultException due to an untrusted certificate
  6. Move the rejected certificate from ~\AppData\Local\OPC Foundation\pki\rejected\certs to ~\AppData\Local\OPC Foundation\pki\trusted\certs
  7. Start OpcUaExample again
  8. Wait until "Please restart the OPC UA server to see the reconnection handler in action. Press any key to continue after the restart." is printed to the console
  9. Set a breakpoint in Opc.Ua.Client/Subscription/Subscription.cs in line 2022 (in solution with OpcUaExample)
  10. Restart the ConsoleReferenceServer
  11. When the breakpoint is hit check the value of `mtransferId which should have the value of the previous subscription id (compare with value printed in console)
  12. Step over the line and check the value of m_transferId which is now overwritten

Environment

- OS: Microsoft Windows 10
- Environment: Visual Studio 2022 17.11.5
- Runtime: .NET 8.0
- Component: Opc.Ua.Client
- Server: Reference Server
- Client: self-made

Anything else?

No response