Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
1 of 5 tasks
ganko-pi opened this issue Oct 28, 2024 · 3 comments
Assignees

Comments

@ganko-pi
Copy link

ganko-pi commented Oct 28, 2024

Type of issue

  • Bug
  • Enhancement
  • Compliance
  • Question
  • Help wanted

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 0b23e5f on branch release/1.5.374
  3. Clone the UA-.NETStandard repository from GitHub (https://github.com/OPCFoundation/UA-.NETStandard)\
    3a (added later) switch to commit 0b23e5f
  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;

/// <summary>
/// Class containing the entry point of the program.
/// </summary>
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);
    }
}

/// <summary>
/// Class to manage a OPC UA session.
/// </summary>
public class OpcUaSessionKeeper : IDisposable
{
    private readonly string _opcUaUri;
    private ISession? _opcUaSession;
    private SessionReconnectHandler? _sessionReconnectHandler;
    private readonly ushort _opcUaCertificateLifetimeInMonths = 180;
    private readonly List<Subscription> _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();
    }
}
  1. Create a file OpcUaExample.Config.xml and paste the following:
<?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>
  1. Open the solution UA-.NETStandard/UA Reference.sln
  2. Start the project ConsoleReferenceServer
  3. Start the project OpcUaExample
  4. OpcUaExample fails with ServiceResultException due to an untrusted certificate
  5. Move the rejected certificate from ~\AppData\Local\OPC Foundation\pki\rejected\certs to ~\AppData\Local\OPC Foundation\pki\trusted\certs
  6. Start OpcUaExample again
  7. 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
  8. Set a breakpoint in Opc.Ua.Client/Subscription/Subscription.cs in line 2022 (in solution with OpcUaExample)
  9. Restart the ConsoleReferenceServer
  10. When the breakpoint is hit check the value of `m_transferId_ which should have the value of the previous subscription id (compare with value printed in console)
  11. 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

@mregen mregen assigned mregen and romanett and unassigned mregen Dec 3, 2024
@romanett
Copy link
Contributor

romanett commented Jan 5, 2025

@ganko-pi If I execute the test in the way you are describing and restart the server, the server has no knowledge of the existing subscription, therefore the transfer fails and the client recreates the subscription with the same Monitored Items. I see no real bug in the implementation.

@ganko-pi
Copy link
Author

I am sorry the links I provided in Current behavior and step 3 point to the master branch and hence the target of the link was altered since the creation of the original post. Now the line of the current state of master (commit b38406c) for setting the breakpoint is line 2054. I updated the original links to point to commit 0b23e5f I tested with and added an step 3a.

Nonetheless the bug still exists. I carefully followed the description again with a new clone of the repository and a new project to ensure the correctness of it. When hitting the breakpoint m_transferId is the subscription id of the previous subscription before the restart of the server. But the line where the breakpoint is set overrides m_transferId. To my understanding m_transferId is a client side variable storing the id of a subscription before it was altered due to a reconnection to the server. If this is not the case and m_transferId should be set by the server then I agree with you that the server can not have knowledge about a subscription id before a restart of the server and this issue can be closed.

@romanett
Copy link
Contributor

romanett commented Jan 10, 2025

@ganko-pi the client uses the m_transferId to Transfer the subscription after a reconnect to a new Session. However with your Testcase the Transfer of the Session fails and therefore the client creates a new subscription overwriting the existing Transfer id that provides No value any more as the subscription Transfer failed.

You can se the cause of this in my opinnion correct behaviour in the Session Claas.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants