Skip to content

Tests: SNIClose close-during-async-IO regression tests AB#43847 #4420

Open
paulmedynski wants to merge 10 commits into
mainfrom
dev/paul/sni-close
Open

Tests: SNIClose close-during-async-IO regression tests AB#43847 #4420
paulmedynski wants to merge 10 commits into
mainfrom
dev/paul/sni-close

Conversation

@paulmedynski

@paulmedynski paulmedynski commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Investigation and regression tests for AB#43847 — "SNIClose can deadlock with in-flight async I/O during connection close" (ICM 775308542).

The original incident was reported against the retired System.Data.SqlClient. This work (a) determines the root cause from the incident dump, (b) verifies whether the same deadlock exists in current Microsoft.Data.SqlClient (MDS), and (c) adds regression tests plus a dedicated legacy-driver repro harness.

Conclusion: the deadlock is not present in Microsoft.Data.SqlClient. MDS defers the connection close off the I/O callback thread (asyncClose), so SNIClose never runs re-entrantly and the self-deadlock cannot occur. The bug is specific to the in-box System.Data.SqlClient code path.

Root cause (from the ICM dump)

The hang is a re-entrant SNIClose self-deadlock in legacy System.Data.SqlClient:

  1. ReadAsyncCallbackOnTimeoutCoreSendAttentionSNIWritePacket fails (the socket is half-broken: writes fail while the read stays pending).
  2. ThrowExceptionAndWarning calls SqlConnection.Close() synchronously on the callback thread.
  3. CloseSNICloseSNI_Conn::WaitForActiveCallbacks() spins (Sleep(100)) waiting for REF_ActiveCallbacks to drain — but the currently-executing ReadAsyncCallback is that outstanding reference. It can never drain from within itself → permanent self-deadlock.
  4. The stuck thread holds the parser lock; a second thread (EndExecuteReaderAsyncCloseSessionResetCancelAndProcessAttention) blocks on it, cascading into hung IOCP threads. New connections' pre-login TLS reads then time out ("SSL Provider - wait operation timed out").

Why MDS is not affected

In MDS, OnTimeoutCore uses an asyncClose flag: a failed SendAttention is caught (the task is cancelled and the break deferred), and ThrowExceptionAndWarning (TdsParser.cs) with asyncClose: true wraps the close in Task.Factory.StartNew. SNIClose therefore runs off the callback thread, the active-callback reference drains normally, and there is no re-entrancy.

Tests added

UnitTests — in-process TDS server

  • SNICloseDeadlockTest.cs — post-login query-read, pre-login stall, and TLS-over-TDS handshake close scenarios (Close/Dispose), each asserting the async read is genuinely pending at close.
  • SNICloseRaceDeadlockTest.cs — race-timing variant of the close-during-async-read path.
  • SNICloseHandshakeCancellationTest.cs (new) — CancelOpenAsyncDuringTlsHandshake_DoesNotDeadlock: a raw TcpListener advertises ENCRYPT_ON and withholds the ServerHello; the client's OpenAsync(token) is cancelled mid-handshake and must settle promptly. Promoted here from the legacy harness so it runs against real MDS.

ManualTests — live SQL Server

  • MarsCloseDeadlockTest.cs / MarsCloseStressTest.cs — MARS smux logical-session close/dispose with a pending async read (WAITFOR DELAY), which requires a real server because the in-process test server does not support MARS.

Each scenario closes on a worker thread under a bounded wait; a deadlock would manifest as the wait timing out.

Legacy repro harness — tools/SniCloseLegacyRepro/

A dedicated xUnit harness that runs the same driver test files against the retired System.Data.SqlClient to confirm the incident lineage and probe for the re-entrant close:

  • Multi-targets net462;net47;net472;net481;net10.0; selectable legacy package version via SqlClientLegacyVersion (default 4.9.1), plus the in-box System.Data.dll on netfx.
  • Compatibility shims (Compat.cs, GlobalUsings.cs) adapt the driver tests (which target the MDS API) to the legacy System.Data.SqlClient surface.
  • ReproAttempts.cs holds six exploratory Tier-2 strategies (thread-pool starvation, sync-over-async contexts, attention-breaking / RST proxies, callback re-entrancy) hunting the "write fails while read stays pending" window.
  • ReentrantSNICloseSoak — a heavy probabilistic soak, gated opt-in behind SNICLOSE_SOAK_ENABLE. It fired once on the legacy in-box netfx driver (net462/47x/48x, with a net462 cascade) but not on the Core NuGet lineage (net10.0) — a clean driver-lineage split consistent with the real bug. It did not recur, matching the SQL EE assessment that the race is "very difficult to capture." The definitive evidence remains the dump plus the asyncClose code review.
  • Runs on the host and in Windows containers (run-in-container.ps1) against ltsc images with a live SQL Server bridged over the container NAT gateway.

The harness lives under tools/ and is not part of the shipping product or default CI.

Validation

Suite Targets Result
Driver SNIClose UnitTests (incl. promoted handshake test) net462, net8.0, net9.0, net10.0 pass (9/9 each)
Driver MARS ManualTests all TFMs compile + pass (live server)
Legacy harness net462, net47, net472, net481, net10.0 19 pass, 1 skip (gated soak), 0 fail
Legacy harness in containers ltsc 4.6.2 / 4.7 / 4.8 20 total, 0 fail, 1 skip each

Notes:

  • Harness runs use DisableTestParallelization (the thread-pool-starvation repros mutate the global ThreadPool); this matches the container runner's -parallel none and makes runs deterministic.
  • The in-box native TdsParserStateObjectNative.Dispose() still carries an UNDONE comment about blocking for pending callbacks on logical (MARS) connections, and DisposeCounters() waits on _readingCount but not _pendingCallbacks. These are latent hardening opportunities, but no live deadlock was found in current MDS.

Checklist

  • Tests added
  • Root cause identified (re-entrant SNIClose in legacy System.Data.SqlClient; MDS mitigated via asyncClose deferral)
  • Public API changes documented (none)
  • Verified against reported repro (original repro was System.Data.SqlClient; confirmed non-reproducible in current MDS)
  • No breaking changes

Adds gated-server repro tests that close/dispose a connection while an
async network read is in flight, using the in-proc TDS server with a
query engine that stalls the response.

Findings:
- On managed SNI (Linux) these tests PASS: disposing the socket completes
  the pending ReadAsync with ObjectDisposedException, so there is no
  deadlock in the managed teardown path.
- The ICM deadlock is specific to the native SNIClose path on Windows,
  which cannot be exercised from a Linux host. These tests are intended
  to reproduce that hang on Windows/native SNI and serve as bounded-wait
  regression guards elsewhere.

No fix applied yet.
…ke, MARS)

Expands the ADO #43847 / ICM 775308542 investigation into whether SNIClose can deadlock with in-flight async I/O during connection close.

UnitTests (SNICloseDeadlockTest.cs), in-process TDS server:
- Strengthen the existing post-login query-read scenarios to assert the async read is genuinely pending at close (readTask not completed, connection Open).
- Add pre-login stall scenarios (bare TcpListener withholds the pre-login response) for Close and Dispose.
- Add faithful TLS-over-TDS handshake scenarios: the listener advertises ENCRYPT_ON, reads the client's ClientHello, then withholds the ServerHello so the client is inside SslStream.AuthenticateAsClient with a pending SNI read at close.

ManualTests (MarsCloseDeadlockTest.cs), live SQL Server:
- Add MARS (smux logical-session) Close/Dispose scenarios using WAITFOR DELAY to keep an async read pending; the in-process TDS server does not support MARS.

All scenarios pass on net9.0 and net462: current native SNI drains the pending async I/O and closes promptly in every path (post-login, pre-login, TLS handshake, and MARS).
Copilot AI review requested due to automatic review settings July 2, 2026 18:01
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Jul 2, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds regression coverage to ensure SqlConnection.Close() / Dispose() do not deadlock when native SNI has an in-flight async read during connection teardown, spanning post-login, pre-login, TLS-handshake, and MARS logical-session phases.

Changes:

  • Added in-process simulated-server unit tests covering close/dispose with a pending async read after login, during pre-login, and during TLS-over-TDS handshake.
  • Added a live SQL Server manual test covering the MARS SMUX logical-session path with a pending async read during close/dispose.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/SNICloseDeadlockTest.cs New simulated-server unit tests that force pending reads and verify close/dispose completes within a bounded timeout across multiple connection phases.
src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/AsyncTest/MarsCloseDeadlockTest.cs New manual live-server MARS test that keeps an async read pending on a logical session and verifies close/dispose completes promptly.

@paulmedynski paulmedynski added this to the 7.1.0-preview3 milestone Jul 2, 2026
…on-test docs

Resolves copilot-pull-request-reviewer feedback on #4420:

- Guard the finally-block connection.Dispose() cleanup with closedInTime in all
  four scenarios (post-login read, pre-login, TLS handshake, MARS). When a close
  deadlock is detected (closedInTime == false), skip the potentially-blocking
  Dispose() so the bounded-wait Assert fails fast instead of hanging.
- Correct the SNICloseDeadlockTest class doc: these are regression guards that
  PASS on the current code base (MDS drains in-flight async I/O and closes
  promptly); they would fail only if a future change reintroduced the deadlock.

Tests: SNICloseDeadlockTest 6/6 pass on net9.0 and net462; MarsCloseDeadlockTest
2/2 pass on net9.0 against a live server.
Replace each pair of Close/Dispose [Fact] methods plus their private helper with a single [Theory] parameterized by disposeInsteadOfClose ([InlineData(false)]/[InlineData(true)]), matching the MARS manual test's style. No behavior change; still 6 cases (3 theories x 2).
Copilot AI review requested due to automatic review settings July 3, 2026 09:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

…anup

Second round of copilot-pull-request-reviewer feedback on #4420:

- Run the Close/Dispose worker on a dedicated long-running thread
  (Task.Factory.StartNew(..., TaskCreationOptions.LongRunning)) instead of a
  thread-pool thread, so a hypothetical deadlock regression parks one dedicated
  thread rather than starving the shared pool.
- Wrap each scenario body in try/finally so cleanup always runs even if a
  precondition assert throws: the release event is always signaled, the TCP
  listener is always stopped, and the command/connection are disposed when the
  close worker never started or completed. The blocking connection.Dispose() is
  still skipped when a deadlock is actually detected (closedInTime == false), so
  the failure stays bounded and no listener/connection is leaked.

Applies to all four scenarios (post-login read, pre-login, TLS handshake, MARS).
Tests: SNICloseDeadlockTest 6/6 pass on net9.0 and net462; MarsCloseDeadlockTest
2/2 pass on net9.0.
@paulmedynski paulmedynski moved this from To triage to In progress in SqlClient Board Jul 3, 2026
@paulmedynski paulmedynski added the Area\Tests Issues that are targeted to tests or test projects label Jul 3, 2026
@paulmedynski paulmedynski marked this pull request as ready for review July 3, 2026 17:21
@paulmedynski paulmedynski requested a review from a team as a code owner July 3, 2026 17:22
Copilot AI review requested due to automatic review settings July 3, 2026 17:22
@paulmedynski paulmedynski moved this from In progress to In review in SqlClient Board Jul 3, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.17%. Comparing base (c30bbbc) to head (f53be89).
⚠️ Report is 19 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4420      +/-   ##
==========================================
- Coverage   65.78%   64.17%   -1.61%     
==========================================
  Files         286      284       -2     
  Lines       43696    66782   +23086     
==========================================
+ Hits        28745    42857   +14112     
- Misses      14951    23925    +8974     
Flag Coverage Δ
CI-SqlClient ?
PR-SqlClient-Project 64.17% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Only signal clientConnected when a pre-login byte is actually read.
Previously ReadByte() throwing or returning -1 still set the event,
letting the test pass without a genuine in-flight handshake read.
Mirrors the existing guard in the TLS-handshake scenario.

Addresses PR #4420 review (discussion_r3521209803).
@cheenamalhotra cheenamalhotra changed the title Tests: SNIClose close-during-async-IO regression tests (ADO #43847 / ICM 775308542) Tests: SNIClose close-during-async-IO regression tests AB#43847 Jul 7, 2026
@cheenamalhotra

Copy link
Copy Markdown
Member

Can you confirm these tests reproduce issue in SDS? If yes, we can safely resolve the ICM as resolved/non-existent in MDS.

…race tests

Add tools/SniCloseLegacyRepro: an xUnit harness that links the branch's SNIClose/MARS close-during-IO deadlock tests and runs them against the legacy System.Data.SqlClient driver (netfx in-box and the deprecated NuGet package). Includes a container runner (run-in-container.ps1) for exercising older in-box System.Data.dll versions, CPM props, and a README.

Promote the race-provoking variants into the driver test suite: SNICloseRaceDeadlockTest (UnitTests) and MarsCloseStressTest (ManualTests). Enable nullable context in SNICloseDeadlockTest and MarsCloseDeadlockTest.

Refs ICM 775308542 / ADO.Net #43847.
Add tools/SniCloseLegacyRepro/ReproAttempts.cs: harness-only attempts targeting conditions the shared SNIClose/MARS tests do not create - close under thread-pool starvation, sync-over-async on a single-threaded SynchronizationContext, cancelling OpenAsync during the TLS handshake, and Open() under client thread-pool starvation. None reproduces the issue on the exact reported bits (System.Data.dll 4.7.4081.0, 4 CPUs).

Note: an in-process TDS test server shares the client thread pool, so saturating it starves the server (a test artifact); against a real out-of-process server Open() is robust under starvation. README Findings updated accordingly.

Refs ICM 775308542 / ADO.Net #43847.
Copilot AI review requested due to automatic review settings July 15, 2026 14:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment thread tools/SniCloseLegacyRepro/ReproAttempts.cs
- Promote CancelOpenAsyncDuringTlsHandshake_DoesNotDeadlock from the legacy
  repro harness into the real driver UnitTests suite
  (SimulatedServerTests/SNICloseHandshakeCancellationTest.cs). The harness now
  links and runs the driver copy instead of a private one.
- Disable xUnit test parallelization in the harness (AssemblyInfo.cs). The
  ThreadPool-starvation repro tests mutate the global ThreadPool and were
  starving other tests connections under parallel execution; this matches the
  container runner (-parallel none) and makes runs deterministic.
- Gate the re-entrant SNIClose soak behind SNICLOSE_SOAK_ENABLE (opt-in). It
  fired once on the legacy in-box netfx driver (not on the Core lineage) but is
  non-deterministic, so it is skipped by default to keep CI clean.
- Forward soak env vars (workers/seconds) through run-in-container.ps1.
Copilot AI review requested due to automatic review settings July 15, 2026 21:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

@paulmedynski

Copy link
Copy Markdown
Contributor Author

@cheenamalhotra - I put together a tool that runs the tests against a Windows 2016 container that has the exact SDS version noted in the ICM. It failed once, and has never failed on the SDS NuGet package, nor with modern SqlClient.

…e header

- MarsCloseStressTest: set StallDelay to 00:02:00 (4x the 30s CloseBudget) so a
  prompt close is unambiguously attributable to the close path aborting the
  in-flight command, not the WAITFOR completing naturally (fixes the equal
  30s==30s boundary flagged in review thread r3588309486).
- run-in-container.ps1: add the standard .NET Foundation MIT license header.
Copilot AI review requested due to automatic review settings July 15, 2026 21:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

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

Labels

Area\Tests Issues that are targeted to tests or test projects

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

4 participants