Invalidate SmtpClient cached connection when connection-affecting properties change - #132770
Conversation
SmtpClient caches a live SmtpConnection in its transport and reuses it across Send calls, but the Host and Port setters only cleared the legacy _servicePoint and never dropped the cached connection. As a result, changing Host or Port between sends kept delivering mail to the original server. Release the cached connection when Host or Port actually changes so the next send establishes a fresh connection to the new target. Add regression tests across the sync Send, SendAsync, and SendMailAsync paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @karelz, @dotnet/ncl |
There was a problem hiding this comment.
Pull request overview
This PR updates System.Net.Mail.SmtpClient so that changing Host or Port invalidates any cached SMTP transport connection, ensuring subsequent sends connect to the newly configured endpoint rather than reusing a connection established for the previous host/port.
Changes:
- Release the cached transport connection when
SmtpClient.Hostchanges. - Release the cached transport connection when
SmtpClient.Portchanges. - Add functional regression tests to verify host/port changes result in new connections and correct delivery.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpClient.cs | Releases the cached SMTP transport connection when Host/Port changes. |
| src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs | Adds regression tests ensuring host/port changes establish a new connection and deliver to the intended server. |
Extend the SmtpClient connection-reuse fix beyond Host/Port to cover the other properties that change how a connection is established: Credentials, UseDefaultCredentials, EnableSsl, and TargetName. Changing any of these now invalidates the cached connection so the next send establishes a fresh one. Move the potentially blocking connection shutdown off the property setters: setters only mark the transport stale (InvalidateCachedConnection), and the graceful close of the old connection happens lazily on the send path inside GetConnectionAsync. IsConnected reports false while stale so EnsureConnection falls through to reconnect. Generalize the host regression test into a theory covering Host, Credentials, and TargetName, and add a TLS test verifying that enabling EnableSsl after an initial plaintext send establishes a new encrypted connection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
Addresses review feedback: the default TargetName is derived once from the
host ("SMTPSVC/<host>"). When Host changed, the cached connection was
invalidated but TargetName kept targeting the previous host, causing a
Negotiate/NTLM SPN mismatch on the new connection.
Now, when Host changes and TargetName still holds the host-derived default,
TargetName is updated to match the new host. A TargetName explicitly set by
the caller is left untouched. Add test coverage for both the default-follows-
host and explicit-preservation cases.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
When GetConnectionAsync releases a previously cached SmtpConnection, only gracefully QUIT one that was actually established; abort (force-close) a connection whose connect attempt failed, since it may have no initialized stream and the graceful path would dereference it and mask the original failure. Also switch the connection test credential literal to the "foo"/"bar" convention used throughout the mail tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs:23
_staleis written from property setters (no lock) and read fromIsConnectedon the send path; without synchronization this can be lost/seen late across threads, causing an old connection to be reused even after an invalidating configuration change. Marking the fieldvolatilemakes the invalidation reliably observable without widening locking.
private readonly SmtpClient _client;
private ICredentialsByHost? _credentials;
private bool _shouldAbort;
private bool _stale;
Now that TargetName is connection-affecting and invalidates the cached connection, guard its setter with the same _inCall check used by Host, Port, Credentials, and Timeout so it throws SmtpInvalidOperationDuringSend rather than mutating connection settings mid-send. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
The stale flag is written from property setters without holding the transport lock and read from IsConnected on the send path. Marking it volatile makes an invalidating configuration change reliably observable across threads without widening locking, so a stale connection is not reused after invalidation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs:105
GetConnectionAsyncperforms potentially blocking shutdown work (ReleaseConnection()/Abort()) while holding theSmtpTransportlock. This increases lock hold times across network I/O (QUIT) and can delaySmtpTransport.Abort()(used by the send timeout path). Consider clearing_connectionunder the lock, releasing/aborting the previous connection outside the lock, then reacquiring the lock to create the new connection (soAbort()during shutdown can still flow through_shouldAbort).
{
lock (this)
{
// Release any previously cached connection (for example one that became stale
// after a configuration change, or one whose connect attempt failed) before
EnableSsl is connection-affecting and invalidates the cached connection, so guard its setter with the same _inCall check used by Host, Port, Credentials, Timeout, and TargetName to prevent toggling SSL mid-send. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
GetConnectionAsync gracefully releasing a previously cached connection performs a blocking QUIT over the network. Holding the transport lock across that I/O delayed a concurrent Abort() (for example from the send-timeout path). Detach the previous connection under the lock, shut it down outside the lock, then reacquire the lock to create the new connection so an Abort() during shutdown still flows through _shouldAbort. Sends are serialized by SmtpClient._inCall, so no other GetConnectionAsync runs concurrently, and ShutdownConnection is idempotent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs:16
ConnectionAffectingPropertyis only used as test data within this file; making itpublicunnecessarily expands the surface area of the test assembly. Consider making itinternal(or nesting it in the test type) since nothing outside needs it.
public enum ConnectionAffectingProperty
{
Host,
Credentials,
TargetName,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpClient.cs:370
- This comment says
TargetNameparticipates in "TLS", but the TLS handshake uses thehostparameter as TargetHost;TargetNameis used for authentication/SPN. Update the comment to avoid implying TLS behavior.
// The target name participates in connection establishment (authentication
// and TLS), so invalidate any cached connection to force a new one on the
// next send.
src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs:12
- This enum is only used within this test file; declaring it as
publicunnecessarily expands the test assembly's public surface area. Make it non-public (default/internal) to keep scope narrower.
public enum ConnectionAffectingProperty
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs:129
GetConnectionAsync<TIOAdapter>may synchronously perform a graceful QUIT (ReleaseConnection) on the previously cached connection before establishing the new one. Since this runs on the SendMailAsync path before the first await, it can block the caller thread (e.g., UI thread) and also can’t be interrupted byAbort()while it’s in the QUIT/close path (because_connectionis already cleared). For stale/failed connection invalidation, aborting the old connection is sufficient and avoids synchronous network I/O in an async code path.
if (previousConnection.IsConnected)
{
previousConnection.ReleaseConnection();
}
src/libraries/System.Net.Mail/src/System/Net/Mail/SmtpTransport.cs:195
- The PR title/description focus on invalidating the cached connection when
Host/Portchange, but the implementation also invalidates onCredentials,EnableSsl, andTargetNamechanges (as described in this comment). Please update the PR description/title to reflect the broader behavior change so reviewers and backporters don’t miss it.
// Marks any cached connection as stale without performing blocking work. The connection
// is gracefully released and replaced the next time one is established (see
// GetConnectionAsync). This is called when a property that affects how the connection is
// established (host, port, credentials, SSL settings, target name) changes.
internal void InvalidateCachedConnection()
A graceful ReleaseConnection() performs a synchronous blocking QUIT over the network on the async send path before the first await, which can block the caller thread. A connection being discarded due to a configuration change does not need a polite QUIT, so abort it instead. Because Abort() is non-blocking, the previous detach-outside-lock structure is no longer needed and the logic collapses back into a single lock. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d288b6ab-b489-4bb8-afb1-5552f3bae55d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/libraries/System.Net.Mail/tests/Functional/SmtpClientConnectionTest.cs:12
- The
ConnectionAffectingPropertyenum is only used within this test file and doesn’t need to be public. Keeping it public unnecessarily expands the test assembly’s surface area (and makes it easier to accidentally depend on from other tests).
public enum ConnectionAffectingProperty
|
/ba-g Test failures are unrelated, no SMTP related failures |
Why
SmtpClientcaches a liveSmtpConnectionin its transport and reuses it acrossSendcalls. However, the connection-affecting property setters never dropped the cached connection. As a result, changing configuration between sends kept delivering mail over the original connection instead of re-establishing one for the new configuration. The most visible case wasHost/Port(mail kept going to the original server), but the same stale-reuse problem applied to every property that determines how the connection is established.What
When a connection-affecting property actually changes, the cached transport connection is invalidated so the next send re-establishes a fresh connection for the new configuration. This now covers:
HostandPortCredentials/UseDefaultCredentials(SMTP AUTH is per-connection)EnableSslTargetName(the SPN used for authentication)Changing
Hostalso updates the defaultTargetName(SMTPSVC/<host>) when the target name was not set explicitly, so authentication targets the new host.Rather than doing potentially blocking work in a property setter, the setters simply mark the transport as stale (a non-blocking flag). The stale connection is aborted and replaced lazily on the next send inside
GetConnectionAsync. Aborting (rather than a graceful QUIT) avoids synchronous network I/O on the async send path. Normal connection reuse is preserved when a value is unchanged, and the setters throwInvalidOperationException(SmtpInvalidOperationDuringSend) if changed while a send is in progress, so invalidation never races an active send.Tests
Added regression tests in
SmtpClientConnectionTestandSmtpClientTlsTest, running across all three send paths (Send,SendAsync,SendMailAsync):ChangingConnectionProperty_EstablishesNewConnection- theory overHost,Credentials, andTargetName; asserts a new connection is established and, for theHostcase, that the defaultTargetNamefollows the new host.ChangingHost_PreservesExplicitlySetTargetName- an explicitly setTargetNameis not overwritten whenHostchanges.ChangingPort_DoesNotReuseConnectionToPreviousServer- points the client at a second loopback server and asserts the next message reaches the new server, not the original.EnableSsl_ChangedAfterConnect_EstablishesNewEncryptedConnection- togglingEnableSslafter a plaintext connection forces a new encrypted connection.The targeted
SmtpClientConnectionTestandSmtpClientTlsTestclasses (60 tests across the three send modes) pass; the connection-property tests were confirmed to fail without the fix and pass with it.Note
This PR was authored by GitHub Copilot.