ManagedTransportChannelLease: non-blocking sync Dispose (deadlock under sync context)

Sync Dispose previously called
  DisposeAsyncCore().AsTask().GetAwaiter().GetResult()
which ultimately performs network I/O (TCP FIN handshake) via
ChannelEntry.TearDownAsync → underlying.CloseAsync. On any host that
captures a SynchronizationContext (legacy ASP.NET, WPF, WinForms)
synchronous Dispose on the UI/request thread deadlocks. Even without
a sync context, a thread-pool thread is pinned on network I/O for the
close duration.

New Dispose() body:
  - Atomically marks the lease released via
    Interlocked.Exchange(ref m_active, 0) (idempotent — second call
    short-circuits, so lease accounting cannot double-release)
  - Pushes the actual Entry.ReleaseLeaseAsync(this) onto the thread
    pool via Task.Run and returns IMMEDIATELY
  - Swallows the async failure inside the fire-and-forget (sync Dispose
    cannot surface async exceptions)

CloseAsync / DisposeAsyncCore (the async path) are unchanged and still
await the full teardown chain. Documented on Dispose() that callers
needing teardown observability must use CloseAsync().
This commit is contained in:
Marc Schier
2026-06-11 09:56:01 +02:00
parent 39eb086443
commit 2a4f4f6014
@@ -275,10 +275,42 @@ namespace Opc.Ua
return DisposeAsyncCore();
}
/// <inheritdoc/>
/// <summary>
/// Synchronous dispose. Begins lease teardown asynchronously on
/// a thread-pool thread and returns immediately. Callers that
/// need to observe teardown completion or surface failures MUST
/// use <see cref="CloseAsync(CancellationToken)"/>. The
/// synchronous path exists only for compatibility with the
/// <see cref="IDisposable"/> contract and the legacy
/// <c>using</c> statement, and never blocks on network I/O —
/// blocking would deadlock callers running under a synchronization
/// context (legacy ASP.NET, WPF, WinForms).
/// </summary>
public void Dispose()
{
DisposeAsyncCore().AsTask().GetAwaiter().GetResult();
// Sync Dispose is best-effort. Mark the lease released
// immediately (preventing any further SendRequestAsync /
// ReconnectAsync calls), then push the actual network I/O
// onto the thread pool. This decouples the synchronous
// caller from the TCP FIN handshake — see
// ChannelEntry.TearDownAsync — and avoids deadlocking
// callers running under a synchronization context.
if (Interlocked.Exchange(ref m_active, 0) == 0)
{
return;
}
ChannelEntry entry = Entry;
_ = Task.Run(async () =>
{
try
{
await entry.ReleaseLeaseAsync(this).ConfigureAwait(false);
}
catch
{
// best effort — sync Dispose cannot surface async failures
}
});
}
private async ValueTask DisposeAsyncCore()