Client channel: no request admitted after Dispose; operation keeps a private Lock

Review feedback on #4849:
- Dispose stopped the timeout timer and failed the requests it found, but
  a request registered concurrently could be added after that snapshot
  and, with the timer gone, wait forever. Registration now happens under
  the timeout lock and is rejected with BadSecureChannelClosed once the
  channel is disposed, so every admitted request is in the drain. Tests
  for a request after Dispose and for requests racing Dispose (both hang
  without the fix).
- ChannelAsyncOperation is public and inheritable, so lock (this) exposed
  its lock (docs/DeveloperGuide.md: never expose locks, use
  System.Threading.Lock). The private Lock is back; this costs ~49 B per
  request (read --nodes 1: 1423 -> 1472 B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roman Ettlinger
2026-10-19 16:54:21 +02:00
co-authored by Claude Opus 5.5
parent 41859b64a3
commit b3d09802d0
3 changed files with 145 additions and 53 deletions
@@ -34,10 +34,6 @@ using System.Threading.Tasks;
using System.Threading.Tasks.Sources; using System.Threading.Tasks.Sources;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
// An operation locks itself: a lock object of its own would be one more allocation
// per request, and nothing else locks an operation.
#pragma warning disable CA2002 // Do not lock on objects with weak identity
namespace Opc.Ua.Bindings namespace Opc.Ua.Bindings
{ {
/// <summary> /// <summary>
@@ -144,7 +140,7 @@ namespace Opc.Ua.Bindings
{ {
if (disposing) if (disposing)
{ {
lock (this) lock (m_lock)
{ {
m_timer?.Dispose(); m_timer?.Dispose();
m_timer = null; m_timer = null;
@@ -265,7 +261,7 @@ namespace Opc.Ua.Bindings
{ {
// check if the request has already completed. // check if the request has already completed.
bool mustWait; bool mustWait;
lock (this) lock (m_lock)
{ {
mustWait = !m_completed; mustWait = !m_completed;
@@ -287,7 +283,7 @@ namespace Opc.Ua.Bindings
} }
finally finally
{ {
lock (this) lock (m_lock)
{ {
// Dispose the event // Dispose the event
m_event?.Dispose(); m_event?.Dispose();
@@ -297,7 +293,7 @@ namespace Opc.Ua.Bindings
} }
// return the response. // return the response.
lock (this) lock (m_lock)
{ {
if (m_error != null && throwOnError) if (m_error != null && throwOnError)
{ {
@@ -319,7 +315,7 @@ namespace Opc.Ua.Bindings
{ {
// check if the request has already completed. // check if the request has already completed.
bool mustWait; bool mustWait;
lock (this) lock (m_lock)
{ {
mustWait = !m_completed; mustWait = !m_completed;
@@ -369,7 +365,7 @@ namespace Opc.Ua.Bindings
finally finally
{ {
cancellation.Dispose(); cancellation.Dispose();
lock (this) lock (m_lock)
{ {
m_tcs = null; m_tcs = null;
} }
@@ -382,7 +378,7 @@ namespace Opc.Ua.Bindings
} }
// return the response. // return the response.
lock (this) lock (m_lock)
{ {
if (m_error != null && throwOnError) if (m_error != null && throwOnError)
{ {
@@ -410,7 +406,7 @@ namespace Opc.Ua.Bindings
/// </exception> /// </exception>
internal ValueTask WaitForCompletionAsync(CancellationToken ct) internal ValueTask WaitForCompletionAsync(CancellationToken ct)
{ {
lock (this) lock (m_lock)
{ {
if (m_completed) if (m_completed)
{ {
@@ -456,7 +452,7 @@ namespace Opc.Ua.Bindings
finally finally
{ {
m_waitRegistration.Dispose(); m_waitRegistration.Dispose();
lock (this) lock (m_lock)
{ {
m_waiting = false; m_waiting = false;
} }
@@ -467,7 +463,7 @@ namespace Opc.Ua.Bindings
throw new ServiceResultException(StatusCodes.BadRequestInterrupted); throw new ServiceResultException(StatusCodes.BadRequestInterrupted);
} }
lock (this) lock (m_lock)
{ {
if (m_error != null) if (m_error != null)
{ {
@@ -499,7 +495,7 @@ namespace Opc.Ua.Bindings
{ {
get get
{ {
lock (this) lock (m_lock)
{ {
m_properties ??= []; m_properties ??= [];
@@ -518,7 +514,7 @@ namespace Opc.Ua.Bindings
{ {
get get
{ {
lock (this) lock (m_lock)
{ {
return m_asyncState; return m_asyncState;
} }
@@ -530,7 +526,7 @@ namespace Opc.Ua.Bindings
{ {
get get
{ {
lock (this) lock (m_lock)
{ {
m_event ??= new ManualResetEvent(m_completed); m_event ??= new ManualResetEvent(m_completed);
@@ -544,7 +540,7 @@ namespace Opc.Ua.Bindings
{ {
get get
{ {
lock (this) lock (m_lock)
{ {
return m_synchronous; return m_synchronous;
} }
@@ -556,7 +552,7 @@ namespace Opc.Ua.Bindings
{ {
get get
{ {
lock (this) lock (m_lock)
{ {
return m_completed; return m_completed;
} }
@@ -579,7 +575,7 @@ namespace Opc.Ua.Bindings
/// </summary> /// </summary>
protected virtual bool InternalComplete(bool doNotBlock, object? result) protected virtual bool InternalComplete(bool doNotBlock, object? result)
{ {
lock (this) lock (m_lock)
{ {
// ignore multiple calls (i.e. a timeout after a response or vise versa). // ignore multiple calls (i.e. a timeout after a response or vise versa).
if (m_completed) if (m_completed)
@@ -667,6 +663,7 @@ namespace Opc.Ua.Bindings
private static readonly TimerCallback s_onTimeout = private static readonly TimerCallback s_onTimeout =
static state => ((ChannelAsyncOperation<T>)state!).OnTimeout(null); static state => ((ChannelAsyncOperation<T>)state!).OnTimeout(null);
private static readonly object? s_boxedDefault = default(T); private static readonly object? s_boxedDefault = default(T);
private readonly Lock m_lock = new();
private readonly AsyncCallback? m_callback; private readonly AsyncCallback? m_callback;
private readonly object? m_asyncState; private readonly object? m_asyncState;
private readonly bool m_synchronous; private readonly bool m_synchronous;
@@ -1700,17 +1700,28 @@ namespace Opc.Ua.Bindings
{ {
RequestId = requestId RequestId = requestId
}; };
if (!m_requests.TryAdd(operation.RequestId, operation)) lock (m_timeoutLock)
{ {
throw ServiceResultException.Unexpected( // Dispose fails the operations it finds after it stopped the timer;
"Could not add request {0} to list of pending operations.", // an operation added later would neither be failed nor time out.
operation.RequestId); if (m_nextTimeoutScan == long.MinValue)
} {
// added first, so that a scan that starts after the deadline is throw ServiceResultException.Create(
// scheduled finds the operation. StatusCodes.BadSecureChannelClosed,
if (deadline != long.MaxValue) "The channel is disposed.");
{ }
ScheduleTimeoutScan(deadline); if (!m_requests.TryAdd(operation.RequestId, operation))
{
throw ServiceResultException.Unexpected(
"Could not add request {0} to list of pending operations.",
operation.RequestId);
}
// added first, so that a scan that starts after the deadline is
// scheduled finds the operation.
if (deadline != long.MaxValue)
{
ScheduleTimeoutScanLocked(deadline);
}
} }
return operation; return operation;
} }
@@ -1726,30 +1737,38 @@ namespace Opc.Ua.Bindings
{ {
lock (m_timeoutLock) lock (m_timeoutLock)
{ {
if (deadline >= m_nextTimeoutScan) ScheduleTimeoutScanLocked(deadline);
{ }
return; }
}
m_nextTimeoutScan = deadline;
// rounded up: a timer that fires early finds nothing expired. /// <summary>
TimeSpan dueTime = TimeProvider.GetElapsedTime(TimeProvider.GetTimestamp(), deadline); /// <see cref="ScheduleTimeoutScan"/> with <see cref="m_timeoutLock"/> held.
dueTime = dueTime > TimeSpan.Zero /// </summary>
? TimeSpan.FromMilliseconds(Math.Ceiling(dueTime.TotalMilliseconds)) private void ScheduleTimeoutScanLocked(long deadline)
: TimeSpan.Zero; {
if (deadline >= m_nextTimeoutScan)
{
return;
}
m_nextTimeoutScan = deadline;
if (m_timeoutTimer == null) // rounded up: a timer that fires early finds nothing expired.
{ TimeSpan dueTime = TimeProvider.GetElapsedTime(TimeProvider.GetTimestamp(), deadline);
m_timeoutTimer = TimeProvider.CreateTimer( dueTime = dueTime > TimeSpan.Zero
static state => ((UaSCUaBinaryClientChannel)state!).OnTimeoutScan(), ? TimeSpan.FromMilliseconds(Math.Ceiling(dueTime.TotalMilliseconds))
this, : TimeSpan.Zero;
dueTime,
Timeout.InfiniteTimeSpan); if (m_timeoutTimer == null)
} {
else m_timeoutTimer = TimeProvider.CreateTimer(
{ static state => ((UaSCUaBinaryClientChannel)state!).OnTimeoutScan(),
m_timeoutTimer.Change(dueTime, Timeout.InfiniteTimeSpan); this,
} dueTime,
Timeout.InfiniteTimeSpan);
}
else
{
m_timeoutTimer.Change(dueTime, Timeout.InfiniteTimeSpan);
} }
} }
@@ -1788,7 +1807,8 @@ namespace Opc.Ua.Bindings
} }
/// <summary> /// <summary>
/// Stops the timeout timer for good. /// Stops the timeout timer for good. Operations are no longer added from
/// here on, so the ones pending afterwards are all the channel holds.
/// </summary> /// </summary>
private void DisposeTimeoutTimer() private void DisposeTimeoutTimer()
{ {
@@ -875,6 +875,81 @@ namespace Opc.Ua.Core.Tests.Stack.Transport
Assert.That(connectError.StatusCode, Is.EqualTo((uint)StatusCodes.BadRequestTimeout)); Assert.That(connectError.StatusCode, Is.EqualTo((uint)StatusCodes.BadRequestTimeout));
} }
/// <summary>
/// A disposed channel has no timer to time requests out, so it must not
/// admit one: a request that reaches the channel after Dispose fails at
/// once instead of waiting for a response that never comes.
/// </summary>
[Test]
[CancelAfter(30000)]
public async Task RequestAfterDisposeFailsInsteadOfWaitingAsync()
{
var channel = new TestClientChannel(
m_buffers,
new RecordingByteTransportFactory(),
m_quotas,
null,
BuildEndpoint(MessageSecurityMode.None, SecurityPolicies.None),
m_telemetry,
new FakeTimeProvider());
channel.Dispose();
// a connecting channel queues the request instead of sending it.
channel.CurrentState = TcpChannelState.Connecting;
Task request = channel
.SendRequestAsync(new GetEndpointsRequest(), 60000, CancellationToken.None)
.AsTask();
Assert.That(
await CompletesWithinAsync(request, 10).ConfigureAwait(false),
Is.True,
"the request was admitted by the disposed channel");
Assert.That(request.IsFaulted, Is.True);
}
/// <summary>
/// Every request that races Dispose completes: it is either rejected or
/// failed by Dispose, never left without a timer.
/// </summary>
[Test]
[CancelAfter(60000)]
public async Task RequestsRacingDisposeAllCompleteAsync()
{
for (int ii = 0; ii < 200; ii++)
{
var channel = new TestClientChannel(
m_buffers,
new RecordingByteTransportFactory(),
m_quotas,
null,
BuildEndpoint(MessageSecurityMode.None, SecurityPolicies.None),
m_telemetry,
new FakeTimeProvider());
channel.CurrentState = TcpChannelState.Connecting;
using var start = new Barrier(2);
// Task.Run unwraps the request task.
Task request = Task.Run(() =>
{
start.SignalAndWait();
return channel
.SendRequestAsync(new GetEndpointsRequest(), 60000, CancellationToken.None)
.AsTask();
});
Task dispose = Task.Run(() =>
{
start.SignalAndWait();
channel.Dispose();
});
await dispose.ConfigureAwait(false);
Assert.That(
await CompletesWithinAsync(request, 10).ConfigureAwait(false),
Is.True,
$"request {ii} still waits after Dispose");
}
}
private async Task<ServiceResultException> RunHandshakeToFaultAsync( private async Task<ServiceResultException> RunHandshakeToFaultAsync(
Func<TestClientChannel, ValueTask<bool>> feed) Func<TestClientChannel, ValueTask<bool>> feed)
{ {