Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/ClientBuilder/ManagedSessionResourceOwnershipTests.cs
romanett 0b9af4db34 Fix flaky BuilderDisposesOwnedHttpProviderAndChannelManagerAsync on net48 (#4597)
## Problem


`ManagedSessionResourceOwnershipTests.BuilderDisposesOwnedHttpProviderAndChannelManagerAsync`
failed on net48 in [run
36598770373](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/36598770373)
(job `test-windows-net48 (5/30)`, on PR #4595, which only touches
`version.json`). It failed after 12.1 s with a `TaskCanceledException`.

This was not a disposal problem. The TRX shows that `ConnectAsync` never
finished:

- The shared channel entered `TransportConnecting` and never reached
`Ready`.
- After 10 s the test's connect deadline cancelled
`ConnectionStateMachine.WaitForConnectedAsync`, with
`ClientChannelManager.GetCoreAsync` still waiting on the open.
- The open was still running after the shutdown token was cancelled, so
it was stuck on something that ignores cancellation.
- On passing runs this test takes about 0.4 s on CI.

## Root cause

While opening, `WebApiTransportChannel` sends a sessionless
`GetEndpoints` to fill in the endpoint description. That request goes to
the test's scripted `HttpMessageHandler`. On .NET Framework the handler
read the request body with the tokenless `ReadAsByteArrayAsync()`. For a
`ByteArrayContent` this copies the bytes through `Stream.BeginWrite`,
which runs on a thread-pool thread. On a starved runner the read can
therefore stall indefinitely. Because it cannot be cancelled, it
outlasts both the channel's own 6 s `GetEndpoints` timeout and the
test's 10 s deadline. On net8+ the handler already passes the token.

## Proof

- **Non-cancellable 12 s delay** injected at that point in the handler:
reproduces the CI failure exactly. The channel sits in
`TransportConnecting` for 10 s, then `TaskCanceledException` is thrown
from `WaitForConnectedAsync`.
- **Cancellable 12 s delay** at the same point: the channel's 6 s
`GetEndpoints` timeout cuts it off, the channel opens and the test
passes.
- **Probe on net48:** `ReadAsByteArrayAsync()` on the request content
never completes synchronously, while `ReadAsStreamAsync()` on a
`ByteArrayContent` always does.

The product's own response reading (`HttpResponseBodyReader`) already
uses `ReadAsStreamAsync` and passes the token, so only the test handler
is affected.

## Fix

On .NET Framework the handler now reads the content stream, which
`ByteArrayContent` creates synchronously, and copies it in memory. There
is no thread-pool hop left in the handler.

## Testing

`ManagedSessionResourceOwnershipTests` passed 15 of 15 runs on net48 and
15 of 15 on net10.0. Before the fix, the test also passed 150 repeats
locally, including under full CPU load.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
2026-09-30 08:05:09 +02:00

164 lines
8.6 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.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Bindings;
using Opc.Ua.Client.Tests.Stack.Client.Fakes;
namespace Opc.Ua.Client.Tests.ClientBuilder
{
[TestFixture]
public sealed class ManagedSessionResourceOwnershipTests
{
[Test]
public async Task BuilderDisposesOwnedHttpProviderAndChannelManagerAsync()
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint("https://ownership.test/");
endpoint.Description.TransportProfileUri = Profiles.HttpsOpenApiTransport;
ServiceMessageContext context = harness.Configuration.CreateMessageContext();
context.NamespaceUris.GetIndexOrAppend("urn:test:ownership");
using var handler = new ScriptedHttpHandler(
harness.CreateOpenedStandaloneChannel(endpoint), endpoint, context);
ManagedSessionBuilder builder = new ManagedSessionBuilder(harness.Configuration, harness.Telemetry)
.UseEndpoint(endpoint)
.WithHttpsResilience(_ => { })
.WithWebApiAuthentication(options => options.HttpMessageHandler = handler);
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await using Client.ManagedSession session = await builder.ConnectAsync(deadline.Token)
.ConfigureAwait(false);
var manager = (ClientChannelManager)((IManagedTransportChannel)session.TransportChannel).Manager;
var bindings = (ITransportChannelBindings)typeof(ClientChannelManager)
.GetProperty("ChannelBindings", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(manager)!;
using ITransportChannel probe = bindings.Create(Utils.UriSchemeHttps, harness.Telemetry)!;
var factory = (IOpcUaHttpClientFactory)typeof(HttpsTransportChannel)
.GetField("m_httpClientFactory", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(probe)!;
var provider = (IServiceProvider)factory.GetType()
.GetField("m_serviceProvider", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(factory)!;
Assert.That(provider.GetService(typeof(IOpcUaHttpClientFactory)), Is.Not.Null);
await session.DisposeAsync().ConfigureAwait(false);
Assert.Throws<ObjectDisposedException>(() => provider.GetService(typeof(IOpcUaHttpClientFactory)));
var participant = new Mock<IReconnectParticipant>();
participant.SetupGet(value => value.Id).Returns("ownership-probe");
participant.SetupGet(value => value.Endpoint).Returns(endpoint);
Assert.ThrowsAsync<ObjectDisposedException>(async () =>
{
using IManagedTransportChannel lease = await manager.GetAsync(
participant.Object, deadline.Token).ConfigureAwait(false);
});
Assert.That(handler.Disposed, Is.False, "An injected HTTP handler remains owned by its caller.");
}
[Test]
public async Task BuilderDoesNotDisposeInjectedChannelManagerAsync()
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
ManagedSessionBuilder builder = new ManagedSessionBuilder(harness.Configuration, harness.Telemetry)
.UseEndpoint(endpoint)
.WithChannelManager(harness.Manager)
.WithHttpsResilience(_ => { });
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await using Client.ManagedSession session = await builder.ConnectAsync(deadline.Token)
.ConfigureAwait(false);
await session.DisposeAsync().ConfigureAwait(false);
await using Session next = await harness.CreateSessionAsync(endpoint).ConfigureAwait(false);
Assert.That(next.Connected, Is.True);
}
private sealed class ScriptedHttpHandler(
ScriptedChannel channel,
ConfiguredEndpoint endpoint,
IServiceMessageContext context) : HttpMessageHandler
{
public bool Disposed { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage message,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
#if NET8_0_OR_GREATER
byte[] body = await message.Content!.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
#else
// .NET Framework's ReadAsByteArrayAsync has no token and buffers
// the content through Stream.BeginWrite on a thread-pool thread,
// so a starved runner stalls the open past every timeout. The
// stream of a ByteArrayContent is created synchronously.
using System.IO.Stream stream = await message.Content!.ReadAsStreamAsync().ConfigureAwait(false);
using var buffer = new System.IO.MemoryStream();
stream.CopyTo(buffer);
byte[] body = buffer.ToArray();
#endif
var options = new JsonDecoderOptions { UpdateNamespaceTable = true };
IServiceRequest request = message.RequestUri!.AbsolutePath switch
{
"/getendpoints" => WebApiBodyCodec.DecodeBody<GetEndpointsRequest>(body, context, options),
"/createsession" => WebApiBodyCodec.DecodeBody<CreateSessionRequest>(body, context, options),
"/activatesession" => WebApiBodyCodec.DecodeBody<ActivateSessionRequest>(body, context, options),
"/read" => WebApiBodyCodec.DecodeBody<ReadRequest>(body, context, options),
"/closesession" => WebApiBodyCodec.DecodeBody<CloseSessionRequest>(body, context, options),
_ => throw new ServiceResultException(StatusCodes.BadServiceUnsupported)
};
IServiceResponse response = request is GetEndpointsRequest
? new GetEndpointsResponse
{
ResponseHeader = channel.CreateGoodHeader(),
Endpoints = [endpoint.Description]
}
: channel.CreateResponse(request);
response.ResponseHeader.RequestHandle = request.RequestHeader.RequestHandle;
var content = new ByteArrayContent(
WebApiBodyCodec.EncodeBody(response, context, JsonEncoderOptions.Compact));
content.Headers.ContentType = MediaTypeHeaderValue.Parse(
WebApiMediaType.FormatContentType(WebApiEncoding.Compact));
return new HttpResponseMessage(HttpStatusCode.OK) { Content = content };
}
protected override void Dispose(bool disposing)
{
Disposed = true;
base.Dispose(disposing);
}
}
}
}