Files
Marc Schier b1c0cd20fd Fix classic browse-path subscription restoration (#4814)
# Description

Persisted classic subscriptions retain their server/client handles but
previously lost relative-path resolution metadata. Transfer could
succeed, yet a later `ApplyChangesAsync` attempted to resolve an
already-created item and failed. The SDK now owns this restoration
lifecycle without exposing a public resolution-state setter.

- Preserve the originally monitored target in internal snapshot state
and restore both `ResolvedNodeId` and `Status.NodeId`.
Transfer/reactivation use handles without re-browsing; fresh creation
resolves paths again.
- Version classic subscription/configuration files and read existing
unversioned 2.0 files using frozen legacy wire layouts. Legacy missing
targets remain unknown rather than being guessed.
- Exclude existing items from creation-time resolution, reserve creation
before asynchronous translation, and reject stale translation results.
Failed deletion retains identity and pending retry state.
- Support forward-only snapshot input using position tracking and
bounded, per-ExtensionObject buffering in the binary decoder, including
short reads. No whole-file buffering or reflection-based serialization
is introduced.
- Add unit/integration regressions and document the supported lifecycle
and migration implications.

**Compatibility:** Newly written files require the updated SDK. Existing
2.0 files remain readable. Assigning a different `StartNodeId` or
`RelativePath` to a created/in-flight item now immediately throws
`BadInvalidState`; unchanged assignments remain allowed. No V2
subscription-engine changes or 1.5 XML import are included.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-10-16 20:06:51 +02:00

221 lines
9.7 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Tests;
namespace Opc.Ua.Subscriptions.Classic.Tests
{
/// <summary>
/// Exercises persisted relative-path items against the server without recreating them.
/// </summary>
[TestFixture]
[Category("Client")]
[Category("TransferSubscription")]
public sealed class RelativePathTransferTests : ClientTestFramework
{
/// <summary>
/// Uses the reference server's UA TCP endpoint.
/// </summary>
public RelativePathTransferTests()
: base(Utils.UriSchemeOpcTcp)
{
}
/// <inheritdoc/>
[OneTimeSetUp]
public override Task OneTimeSetUpAsync()
{
SingleSession = true;
ClientFixtureSubscriptionEngineFactory = ClassicSubscriptionEngineFactory.Instance;
return OneTimeSetUpCoreAsync(securityNone: true);
}
/// <inheritdoc/>
[OneTimeTearDown]
public override Task OneTimeTearDownAsync()
{
return base.OneTimeTearDownAsync();
}
/// <inheritdoc/>
[SetUp]
public override Task SetUpAsync()
{
return base.SetUpAsync();
}
/// <inheritdoc/>
[TearDown]
public override Task TearDownAsync()
{
return base.TearDownAsync();
}
/// <summary>
/// Transfer and saved-session reactivation retain item identity through maintenance.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task RestoredRelativePathRetainsIdentityAndSupportsMaintenanceAsync(bool reactivate)
{
using ISession origin = await ClientFixture.ConnectAsync(
ServerUrl, SecurityPolicies.Basic256Sha256).ConfigureAwait(false);
origin.DeleteSubscriptionsOnClose = false;
using var subscription = new Subscription(origin.DefaultSubscription)
{
PublishingInterval = 100,
KeepAliveCount = 10,
LifetimeCount = 1000,
PublishingEnabled = true
};
var item = new MonitoredItem(Telemetry)
{
StartNodeId = ObjectIds.Server,
RelativePath = "/ServerStatus/CurrentTime",
SamplingInterval = 100
};
subscription.AddItem(item);
Assert.That(origin.AddSubscription(subscription), Is.True);
await subscription.CreateAsync().ConfigureAwait(false);
Assert.That(item.Status.Error, Is.Null);
Assert.That(item.ResolvedNodeId, Is.EqualTo(VariableIds.Server_ServerStatus_CurrentTime));
uint subscriptionId = subscription.Id;
uint serverId = item.ServerId;
uint clientHandle = item.ClientHandle;
using var snapshot = new MemoryStream();
origin.Save(snapshot, [subscription]);
snapshot.Position = 0;
ISession target;
if (reactivate)
{
using var configurationStream = new MemoryStream();
origin.SaveSessionConfiguration(configurationStream);
configurationStream.Position = 0;
SessionConfiguration configuration = SessionConfiguration.Create(configurationStream, Telemetry)!;
origin.TransportChannel.Dispose();
ITransportChannel channel = await ClientFixture.CreateChannelAsync(
configuration.ConfiguredEndpoint!, false).ConfigureAwait(false);
target = ClientFixture.CreateSession(channel, configuration.ConfiguredEndpoint!);
try
{
Assert.That(target.ApplySessionConfiguration(configuration), Is.True);
await target.ReconnectAsync(channel).ConfigureAwait(false);
}
catch
{
target.Dispose();
throw;
}
}
else
{
await origin.CloseAsync().ConfigureAwait(false);
target = await ClientFixture.ConnectAsync(
ServerUrl, SecurityPolicies.Basic256Sha256).ConfigureAwait(false);
}
using (target)
{
Subscription restored = target.Load(snapshot, true).Single();
MonitoredItem restoredItem = restored.MonitoredItems.Single();
int notifications = 0;
restoredItem.Notification += (_, _) => Interlocked.Increment(ref notifications);
Assert.That(restoredItem.ResolvedNodeId, Is.EqualTo(VariableIds.Server_ServerStatus_CurrentTime));
Assert.That(restoredItem.Status.NodeId, Is.EqualTo(VariableIds.Server_ServerStatus_CurrentTime));
bool adopted = reactivate
? await target.ReactivateSubscriptionsAsync([restored], true).ConfigureAwait(false)
: await target.TransferSubscriptionsAsync([restored], true).ConfigureAwait(false);
Assert.That(adopted, Is.True);
Assert.That(restored.Id, Is.EqualTo(subscriptionId));
Assert.That(restoredItem.ServerId, Is.EqualTo(serverId));
Assert.That(restoredItem.ClientHandle, Is.EqualTo(clientHandle));
await TestPolling.WaitUntilAsync(
() => Volatile.Read(ref notifications) > 0,
timeoutMessage: "The restored relative-path item did not receive notifications.")
.ConfigureAwait(false);
await restored.ApplyChangesAsync().ConfigureAwait(false);
var added = new MonitoredItem(Telemetry)
{
StartNodeId = ObjectIds.Server,
RelativePath = "/ServerStatus/State"
};
restored.AddItem(added);
await restored.ApplyChangesAsync().ConfigureAwait(false);
Assert.That(added.Created, Is.True);
Assert.That(added.ResolvedNodeId, Is.EqualTo(VariableIds.Server_ServerStatus_State));
Assert.That(restoredItem.ServerId, Is.EqualTo(serverId));
ArrayOf<uint> handles = await ReadHandlesAsync(target, restored.Id).ConfigureAwait(false);
Assert.That(handles.ToArray(), Is.EquivalentTo(new[] { serverId, added.ServerId }));
restoredItem.SamplingInterval = 200;
await restored.ApplyChangesAsync().ConfigureAwait(false);
await restored.SetMonitoringModeAsync(MonitoringMode.Sampling, [restoredItem])
.ConfigureAwait(false);
Assert.That(restoredItem.ServerId, Is.EqualTo(serverId));
Assert.That(restoredItem.Status.NodeId, Is.EqualTo(VariableIds.Server_ServerStatus_CurrentTime));
restored.RemoveItem(restoredItem);
await restored.ApplyChangesAsync().ConfigureAwait(false);
Assert.That(restoredItem.Created, Is.False);
handles = await ReadHandlesAsync(target, restored.Id).ConfigureAwait(false);
Assert.That(handles.ToArray(), Is.EqualTo(new[] { added.ServerId }));
await target.CloseAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Reads authoritative server handles to detect unintended recreation or duplication.
/// </summary>
private static async Task<ArrayOf<uint>> ReadHandlesAsync(ISession session, uint subscriptionId)
{
CallResponse response = await session.CallAsync(null,
[
new CallMethodRequest
{
ObjectId = ObjectIds.Server,
MethodId = MethodIds.Server_GetMonitoredItems,
InputArguments = [Variant.From(subscriptionId)]
}
], CancellationToken.None).ConfigureAwait(false);
Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(response.Results[0].OutputArguments[0].TryGetValue(out ArrayOf<uint> handles), Is.True);
return handles;
}
}
}