Skip to content

fix: make PooledSocket.DisposeSocket thread-safe#272

Merged
cnblogs-dudu merged 1 commit into
cnblogs:mainfrom
XiLiuRoy:roy/poolsocket-thread-safety-fix
May 15, 2026
Merged

fix: make PooledSocket.DisposeSocket thread-safe#272
cnblogs-dudu merged 1 commit into
cnblogs:mainfrom
XiLiuRoy:roy/poolsocket-thread-safety-fix

Conversation

@XiLiuRoy

@XiLiuRoy XiLiuRoy commented May 12, 2026

Copy link
Copy Markdown

Problem

PooledSocket.Connect() registers a CancellationTokenSource callback (Cancel()) to enforce the connection timeout. When the timeout fires, Cancel() calls DisposeSocket() on a thread-pool thread. The same code path calls DisposeSocket() again on the caller thread when the post-connect check finds the socket is not connected — with no protection against both running concurrently.

This creates a race with two failure modes:

  1. Double-dispose — both threads see _socket != null, both call _socket.Dispose(), the second call throws ObjectDisposedException.
  2. NullReferenceExceptionCancel() sets _socket = null between the null-check and _socket.Dispose() on the caller thread.

These races are low-probability under normal conditions but reproducible under connection-timeout pressure (e.g. connecting to a slow or firewalled host) and more likely on ARM / weakly-ordered architectures.

Fix

Rewrite DisposeSocket() to use Interlocked.Exchange(ref _socket, null):

private protected void DisposeSocket()
{
    _isSocketDisposed = true;
    var socket = Interlocked.Exchange(ref _socket, null);
    socket?.Dispose();
}

Interlocked.Exchange atomically captures the socket reference and nulls the field in one operation. Exactly one concurrent caller gets the non-null reference and disposes it; all others receive null and no-op. This makes the method safe to call concurrently from any number of threads.

The visibility is changed from private to private protected so the method can be exercised directly in tests (via a thin subclass in the test project) without reflection. InternalsVisibleTo is scoped to the test assembly only, using the full public key to respect the existing strong-name signing.

Testing

Added PooledSocketRaceReproTests which calls DisposeSocket() from 64 concurrent tasks and asserts no exceptions are raised.

Made with Cursor :)

PooledSocket.Connect() registers a CTS cancellation callback that calls
DisposeSocket() on a thread-pool thread when the connection timeout fires.
The same method also calls DisposeSocket() from the caller thread when the
post-connect check sees the socket is not connected. Without synchronisation,
both paths race to call _socket.Dispose() and null _socket concurrently,
producing ObjectDisposedException or NullReferenceException under load.

Fix by using Interlocked.Exchange(ref _socket, null) in DisposeSocket() so
exactly one concurrent caller gets the non-null reference and disposes it;
all others receive null and no-op.

Change DisposeSocket visibility to private protected and add InternalsVisibleTo
so the concurrent-disposal race can be tested directly without reflection.

Adds PooledSocketRaceReproTests covering the concurrent-disposal scenario.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cnblogs-dudu cnblogs-dudu self-requested a review May 15, 2026 23:47
@cnblogs-dudu cnblogs-dudu merged commit 12f3809 into cnblogs:main May 15, 2026
1 check passed
@cnblogs-dudu

Copy link
Copy Markdown

Thanks for your contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants