Repository navigation
SqlDataReader async read throws inconsistent exception types on disposal (IOException vs InvalidOperationException) #4088
Description
Activity
- addedPublic API 🆕Issues/PRs that introduce new APIs to the driver.Issues/PRs that introduce new APIs to the driver.
on Mar 25, 2026 - added 4 commits that reference this issue
on Mar 25, 2026 /triage
🔍 Triage Summary (on-demand re-triage)
Check Result Issue type Bug Environment Not applicable — this is a code-analysis/root-cause report (race condition in driver internals), not an environment-dependent repro. Reporter already identified specific failing code paths ( SqlDataReader.ContinueAsyncCall,ExecuteAsyncCall,SqlSequentialStream) across Azure SQL vs. local SQL Server latency conditions.Area Area\AsyncDuplicates Potentially related: #4638 ("Preserve the original exception when SqlDataReader teardown also times out"), #3604 (tracking the underlying flaky Debug.Assert()s referenced by this issue) Regression Not indicated — appears to be a long-standing race condition, not a regression from a specific version Analysis
This issue documents a race condition between two disposal paths in
SqlDataReaderasync reads: one path wraps aSqlExceptioninIOExceptionviaSqlSequentialStream/SqlSequentialTextReader, while the other short-circuits throughADP.ClosedConnectionError()producingInvalidOperationException, depending on whether_cancelAsyncOnCloseTokenSource.Cancel()fires before or aftercontext.Execute()is reached. Network latency (Azure SQL vs. local SQL Server) changes which path wins, making the observed exception type non-deterministic. This directly relates to already-known flakyDebug.Assert()s tracked in #3604 and to #4638, which discusses exception-preservation behavior during reader teardown. Severity: P2 — not a crash or data-loss bug, but a correctness/consistency issue affecting exception-handling contracts for async cancellation-on-dispose scenarios, likely to cause flaky tests and inconsistent app-level error handling.Next Steps
- Recommend reviewing Preserve the original exception when SqlDataReader teardown also times out #4638 and Fix commented-out Debug.Assert()s that are failing #3604 together with this issue before scoping a fix, since all three touch the same disposal/cancellation race in the async read path.
- Assign this issue to Copilot coding agent to investigate
SqlDataReader.ContinueAsyncCall()andExecuteAsyncCall()so that the cancellation-token check and thecontext.Execute()fallthrough consistently produce one exception type (likelyObjectDisposedExceptionor a dedicatedInvalidOperationExceptionvariant) regardless of race outcome, and to auditSqlSequentialStream/SqlSequentialTextReaderwrapping logic so it does not mask the same underlying disposal condition withIOExceptionin the fast-completion case. - Once a fix lands, remove the
TODO(GH-3604)workaround inDataStreamTest.csand replace the accept-any-exception try/catch with an assertion on the single deterministic exception type.
Note: This triage summary is auto-generated by an AI agent. The analysis and suggestions above have not been verified by a human maintainer. Please treat as preliminary guidance only.
Generated by SqlClient Issue Auto-Triage for #4088 · copilot · auto · 46.2 AIC · ⌖ 13.1 AIC · ⊞ 11.9K · ◷
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn progress
Description
When a
ReadAsynctask is pending on aSqlDataReaderand the reader is disposed, the exception surfaced byTask.Wait()is inconsistent: sometimes it is anIOException, sometimes anInvalidOperationException. The outcome depends on a race condition inside the driver, and manifests differently depending on network latency (Azure SQL vs local SQL Server).Root Cause
There are two competing disposal paths inside
SqlDataReader:IOException path — The
ReadAsynccontinuation entersContinueAsyncCall, reachescontext.Execute(), and the underlying stream read fails with aSqlException(due to the reader being disposed).SqlSequentialStreamwraps this in anIOExceptionbecauseStream.ReadAsyncshould not surfaceSqlException.InvalidOperationException path —
_cancelAsyncOnCloseTokenSource.Cancel()fires (from reader disposal) before the continuation reachescontext.Execute(). The cancellation token is checked inContinueAsyncCallorExecuteAsyncCall, and the method falls through toADP.ClosedConnectionError(), which returns anInvalidOperationException.The race winner depends on timing:
context.Execute()→IOException._cancelAsyncOnCloseTokenSource.Cancel()fires →InvalidOperationException.Relevant Code
SqlDataReader.ContinueAsyncCall<T>()— the final fallthrough at the end returnsADP.ClosedConnectionError()(InvalidOperationException) when the reader is closed, regardless of what the caller expects.SqlDataReader.ExecuteAsyncCall<T>()— same issue with the cancellation check at the top.SqlSequentialStream/SqlSequentialTextReader— wrapsSqlExceptioninIOExceptionwhen the read faults.Expected Behavior
The exception type thrown by a pending
ReadAsynctask when the reader is disposed should be deterministic and consistent regardless of network latency or server type.Actual Behavior
AggregateExceptionwrappingIOExceptionAggregateExceptionwrappingInvalidOperationExceptionWorkaround
Tests in
DataStreamTest.cs(DEBUG-only#if DEBUGblocks) currently use atry/catchpattern that accepts either exception type or no exception at all, guarded byTODO(GH-3604)comments.Related