Files
UA-.NETStandard/tests/Opc.Ua.WotCon.Samples.Tests/WotSampleEnvironment.cs
Cristi Pogacean d2ec51d035 Normalize transport connection failures at the client channel boundary (#4549)
## Summary
Translate socket and I/O connection failures into
`ServiceResultException(BadNotConnected)` at
`UaSCUaBinaryClientChannel.ConnectAsync`, so public discovery creation
does not leak raw transport exceptions.

- Preserve the original exception as `InnerException` for diagnostics.
- Keep the existing operation fault, channel shutdown and operation
completion paths.
- Leave cancellation, existing `ServiceResultException`
instances/statuses, and programming errors unchanged.
- Do not change the lower-level byte transport contract or reconnect
behavior.

Fixes #4535

## Regression Coverage
- Deterministic injected HostNotFound, ConnectionRefused, socket timeout
and IOException failures assert BadNotConnected, exact inner cause and
closed transport/channel.
- Separate controls assert caller cancellation and unchanged
service/programming exceptions.
- Public `DiscoveryClient.CreateAsync` integration uses a bound,
non-listening loopback port. It asserts the translated failure and
verifies a previously connected discovery client can still retrieve
endpoints afterward. A separate public-API cancellation control remains
a cancellation.
- No live DNS lookup is required in the committed tests; DNS failure is
injected deterministically.

## Validation
Base `b4136d8e9`, Windows Release:

| Focused selection | net10.0 | net48 |
| --- | ---: | ---: |
| UaSCBinaryClientChannelDeterministicTests + TcpByteTransportTests | 51
passed | 51 passed |
| SessionlessInvocationTests | 22 passed | 22 passed |

All four selections exited 0, with zero failures/skips. Changed-file
whitespace verification, diagnostics and `git diff --check` passed.

Before the fix, the seven new deterministic cases produced 4
raw-transport failures / 3 passing controls. The two new public-API
cases produced 1 raw SocketException failure / 1 cancellation-control
pass with only the production fix reversed. The source fix was restored,
rebuilt, and the full focused selections above passed.

The complete framework/platform matrix is left to PR CI. No repository
guidance, feed configuration, security policy or unrelated consumer
workaround was changed.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-25 12:29:56 +02:00

530 lines
20 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 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;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.WotCon.Client;
using AggregationClient;
using AggregationServer;
using FlatTagServer;
namespace Opc.Ua.WotCon.Samples.Tests
{
internal sealed class WotSampleEnvironment : IAsyncDisposable
{
private WotSampleEnvironment(
string root,
IHost sourceAHost,
IHost sourceBHost,
IHost aggregationHost,
AggregationClientOptions clientOptions,
FlatTagValues sourceAValues,
FlatTagValues sourceBValues,
FlatTagValues sourceAPump2Values,
FlatTagValues sourceBPump2Values)
{
Root = root;
SourceAHost = sourceAHost;
SourceBHost = sourceBHost;
AggregationHost = aggregationHost;
ClientOptions = clientOptions;
SourceAValues = sourceAValues;
SourceBValues = sourceBValues;
SourceAPump2Values = sourceAPump2Values;
SourceBPump2Values = sourceBPump2Values;
}
public string Root { get; }
public IHost SourceAHost { get; }
public IHost SourceBHost { get; }
public IHost AggregationHost { get; }
public AggregationClientOptions ClientOptions { get; }
public FlatTagValues SourceAValues { get; }
public FlatTagValues SourceBValues { get; }
public FlatTagValues SourceAPump2Values { get; }
public FlatTagValues SourceBPump2Values { get; }
public string DocumentsDirectory => FindDocumentsDirectory();
public static async Task<WotSampleEnvironment> StartAsync(
CancellationToken cancellationToken)
{
string id = Guid.NewGuid().ToString("N");
string root = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
nameof(WotSampleEnvironment),
id);
Directory.CreateDirectory(root);
int[] ports = TestPorts.GetFreePorts(3);
int sourceAPort = ports[0];
int sourceBPort = ports[1];
int aggregationPort = ports[2];
string sourceAEndpoint = $"opc.tcp://127.0.0.1:{sourceAPort}/SourceA";
string sourceBEndpoint = $"opc.tcp://127.0.0.1:{sourceBPort}/SourceB";
string aggregationEndpoint =
$"opc.tcp://127.0.0.1:{aggregationPort}/AggregationServer";
var sourceAValues = new FlatTagValues
{
DifferentialPressure = 111.25,
FluidTemperature = 301.15,
MassFlow = 0.42,
Level = 4.25,
Cavitation = true,
BearingTemperature = 340.15,
PumpPowerInput = 21.0,
PumpEfficiency = 82.0,
NumberOfStarts = 99,
MotorOverheat = false
};
var sourceBValues = new FlatTagValues
{
DifferentialPressure = 222.5,
FluidTemperature = 310.15,
MassFlow = 0.84,
Level = 8.5,
Cavitation = false,
BearingTemperature = 333.15,
PumpPowerInput = 17.75,
PumpEfficiency = 91.5,
NumberOfStarts = 23,
MotorOverheat = true
};
var sourceAPump2Values = new FlatTagValues
{
SerialNumber = "SN-002",
ProductInstanceUri = "urn:simdevice:SimPump:PumpX-2000:SN-002",
DifferentialPressure = 211.25,
FluidTemperature = 304.15,
MassFlow = 0.52,
Level = 4.75,
Cavitation = false
};
var sourceBPump2Values = new FlatTagValues
{
SerialNumber = "SN-002",
ProductInstanceUri = "urn:simdevice:SimPump:PumpX-2000:SN-002",
BearingTemperature = 337.15,
PumpPowerInput = 19.75,
PumpEfficiency = 89.5,
NumberOfStarts = 31,
MotorOverheat = false
};
IHost sourceAHost = FlatTagServerHost.Build(new FlatTagServerOptions
{
EndpointUrl = sourceAEndpoint,
SourceNamespaceUri = FlatTagServerOptions.SourceANamespaceUri,
ApplicationName = $"FlatTagServerSourceA{id}",
InstanceName = "SourceA",
PkiRoot = Path.Combine(root, "SourceA", "pki"),
Values = sourceAValues,
Pump2Values = sourceAPump2Values
});
IHost sourceBHost = FlatTagServerHost.Build(new FlatTagServerOptions
{
EndpointUrl = sourceBEndpoint,
SourceNamespaceUri = FlatTagServerOptions.SourceBNamespaceUri,
ApplicationName = $"FlatTagServerSourceB{id}",
InstanceName = "SourceB",
PkiRoot = Path.Combine(root, "SourceB", "pki"),
Values = sourceBValues,
Pump2Values = sourceBPump2Values
});
IHost aggregationHost = AggregationServerHost.Build(
new AggregationServerOptions
{
EndpointUrl = aggregationEndpoint,
ApplicationName = $"AggregationServer{id}",
PkiRoot = Path.Combine(root, "Aggregation", "pki")
});
var clientOptions = new AggregationClientOptions
{
AggregationEndpoint = aggregationEndpoint,
SourceAEndpoint = sourceAEndpoint,
SourceBEndpoint = sourceBEndpoint,
ApplicationName = $"AggregationClient{id}",
PkiRoot = Path.Combine(root, "Client", "pki"),
DocumentsDirectory = FindDocumentsDirectory()
};
var environment = new WotSampleEnvironment(
root,
sourceAHost,
sourceBHost,
aggregationHost,
clientOptions,
sourceAValues,
sourceBValues,
sourceAPump2Values,
sourceBPump2Values);
try
{
await sourceAHost.StartAsync(cancellationToken).ConfigureAwait(false);
await sourceBHost.StartAsync(cancellationToken).ConfigureAwait(false);
await WaitForTcpAsync(sourceAPort, cancellationToken).ConfigureAwait(false);
await WaitForTcpAsync(sourceBPort, cancellationToken).ConfigureAwait(false);
await aggregationHost.StartAsync(cancellationToken).ConfigureAwait(false);
await environment.WaitForAggregationAsync(cancellationToken).ConfigureAwait(false);
return environment;
}
catch
{
await environment.DisposeAsync().ConfigureAwait(false);
throw;
}
}
public AggregationClientOptions CreateClientOptions(
string documentsDirectory,
string? sourceAEndpoint = null,
string? sourceBEndpoint = null)
{
return new AggregationClientOptions
{
AggregationEndpoint = ClientOptions.AggregationEndpoint,
SourceAEndpoint = sourceAEndpoint ?? ClientOptions.SourceAEndpoint,
SourceBEndpoint = sourceBEndpoint ?? ClientOptions.SourceBEndpoint,
ApplicationName = ClientOptions.ApplicationName + Guid.NewGuid().ToString("N"),
PkiRoot = Path.Combine(Root, "Clients", Guid.NewGuid().ToString("N"), "pki"),
DocumentsDirectory = documentsDirectory
};
}
public Task<WotClientConnection> ConnectAsync(
CancellationToken cancellationToken)
{
AggregationClientOptions options = CreateClientOptions(DocumentsDirectory);
return WotClientConnection.CreateAsync(options, cancellationToken);
}
public Task<OpcUaClientConnection> ConnectSourceAAsync(
CancellationToken cancellationToken)
{
return OpcUaClientConnection.CreateAsync(
Root,
ClientOptions.SourceAEndpoint,
ClientOptions.ApplicationName + ".SourceA" + Guid.NewGuid().ToString("N"),
cancellationToken);
}
public Task<OpcUaClientConnection> ConnectSourceBAsync(
CancellationToken cancellationToken)
{
return OpcUaClientConnection.CreateAsync(
Root,
ClientOptions.SourceBEndpoint,
ClientOptions.ApplicationName + ".SourceB" + Guid.NewGuid().ToString("N"),
cancellationToken);
}
public string CreateDocumentsCopy()
{
string target = Path.Combine(Root, "Documents", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(target);
foreach (string source in Directory.EnumerateFiles(DocumentsDirectory, "*", SearchOption.AllDirectories))
{
string destination = Path.Combine(target, Path.GetRelativePath(DocumentsDirectory, source));
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(source, destination);
}
return target;
}
public async ValueTask DisposeAsync()
{
await StopHostAsync(AggregationHost).ConfigureAwait(false);
await StopHostAsync(SourceBHost).ConfigureAwait(false);
await StopHostAsync(SourceAHost).ConfigureAwait(false);
AggregationHost.Dispose();
SourceBHost.Dispose();
SourceAHost.Dispose();
if (Directory.Exists(Root))
{
Directory.Delete(Root, recursive: true);
}
}
private static async Task StopHostAsync(IHost host)
{
try
{
await host.StopAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
TestContext.Out.WriteLine("Ignoring best-effort teardown failure: {0}", ex);
}
}
private async Task WaitForAggregationAsync(CancellationToken cancellationToken)
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
WotClientConnection connection = await ConnectAsync(cancellationToken)
.ConfigureAwait(false);
await using (connection.ConfigureAwait(false))
{
return;
}
}
catch (ServiceResultException ex)
when (ex.StatusCode == StatusCodes.BadServerHalted ||
ex.StatusCode == StatusCodes.BadNotConnected ||
ex.StatusCode == StatusCodes.BadServerNotConnected ||
ex.StatusCode == StatusCodes.BadConnectionRejected)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken)
.ConfigureAwait(false);
}
catch (SocketException)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken)
.ConfigureAwait(false);
}
}
}
private static async Task WaitForTcpAsync(
int port,
CancellationToken cancellationToken)
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
using var client = new TcpClient();
try
{
await client.ConnectAsync("127.0.0.1", port, cancellationToken)
.ConfigureAwait(false);
return;
}
catch (SocketException)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken)
.ConfigureAwait(false);
}
}
}
private static string FindDocumentsDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
string solution = Path.Combine(directory.FullName, "UA.slnx");
if (File.Exists(solution))
{
string documents = Path.Combine(
directory.FullName,
"samples",
"WotCon",
"AggregationClient",
"Documents");
if (Directory.Exists(documents))
{
return documents;
}
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException(
"The checked-in samples\\AggregationClient\\Documents directory was not found.");
}
}
internal sealed class OpcUaClientConnection : IAsyncDisposable
{
private OpcUaClientConnection(IHost host, ManagedSession session)
{
Host = host;
Session = session;
}
public IHost Host { get; }
public ManagedSession Session { get; }
public static async Task<OpcUaClientConnection> CreateAsync(
string root,
string endpointUrl,
string applicationName,
CancellationToken cancellationToken)
{
IHost host = BuildClientHost(root, endpointUrl, applicationName);
try
{
await host.StartAsync(cancellationToken).ConfigureAwait(false);
Func<CancellationToken, Task<ManagedSession>> connect =
host.Services.GetRequiredService<
Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession session = await connect(cancellationToken).ConfigureAwait(false);
await session.FetchNamespaceTablesAsync(cancellationToken).ConfigureAwait(false);
session.MessageContext.NamespaceUris.Update(session.NamespaceUris.ToArray());
return new OpcUaClientConnection(host, session);
}
catch
{
await host.StopAsync(CancellationToken.None).ConfigureAwait(false);
host.Dispose();
throw;
}
}
public async ValueTask DisposeAsync()
{
try
{
await Session.DisposeAsync().ConfigureAwait(false);
}
finally
{
await Host.StopAsync(CancellationToken.None).ConfigureAwait(false);
Host.Dispose();
}
}
private static IHost BuildClientHost(
string root,
string endpointUrl,
string applicationName)
{
HostApplicationBuilder builder = Microsoft.Extensions.Hosting.Host
.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Services
.AddOpcUa()
.AddOpcTcpTransport()
.AddClient(client =>
{
client.ApplicationName = applicationName;
client.ApplicationUri = "urn:localhost:OPCFoundation:" + applicationName;
client.ProductUri = "uri:opcfoundation.org:WotSampleTestClient";
client.PkiRoot = Path.Combine(
root,
"Clients",
Guid.NewGuid().ToString("N"),
"pki");
client.AutoAcceptUntrustedCertificates = true;
client.Session = new ManagedSessionOptions
{
SessionName = applicationName,
SessionTimeout = TimeSpan.FromSeconds(60)
};
})
.AddDiscoveryAndConnect(discovery =>
{
discovery.DiscoveryUrl = endpointUrl;
discovery.SecurityMode = MessageSecurityMode.None;
discovery.SecurityPolicyUri = SecurityPolicies.None;
});
return builder.Build();
}
}
internal sealed class WotClientConnection : IAsyncDisposable
{
private WotClientConnection(IHost host, ManagedSession session, WotRegistryClient registry)
{
Host = host;
Session = session;
Registry = registry;
}
public IHost Host { get; }
public ManagedSession Session { get; }
public WotRegistryClient Registry { get; }
public static async Task<WotClientConnection> CreateAsync(
AggregationClientOptions options,
CancellationToken cancellationToken)
{
IHost host = AggregationClientRunner.BuildHost(options);
try
{
await host.StartAsync(cancellationToken).ConfigureAwait(false);
Func<CancellationToken, Task<ManagedSession>> connect =
host.Services.GetRequiredService<
Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession session = await connect(cancellationToken).ConfigureAwait(false);
session.MessageContext.NamespaceUris.Update(session.NamespaceUris.ToArray());
Func<ManagedSession, CancellationToken, Task<WotRegistryClient>> createClient =
host.Services.GetRequiredService<
Func<ManagedSession, CancellationToken, Task<WotRegistryClient>>>();
WotRegistryClient registry = await createClient(session, cancellationToken)
.ConfigureAwait(false);
return new WotClientConnection(host, session, registry);
}
catch
{
await host.StopAsync(CancellationToken.None).ConfigureAwait(false);
host.Dispose();
throw;
}
}
public async ValueTask DisposeAsync()
{
try
{
await Session.DisposeAsync().ConfigureAwait(false);
}
finally
{
await Host.StopAsync(CancellationToken.None).ConfigureAwait(false);
Host.Dispose();
}
}
}
}