Description
When a ReadAsync task is pending on a SqlDataReader and the reader is disposed, the exception surfaced by Task.Wait() is inconsistent: sometimes it is an IOException, sometimes an InvalidOperationException. 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 ReadAsync continuation enters ContinueAsyncCall, reaches context.Execute(), and the underlying stream read fails with a SqlException (due to the reader being disposed). SqlSequentialStream wraps this in an IOException because Stream.ReadAsync should not surface SqlException.
-
InvalidOperationException path — _cancelAsyncOnCloseTokenSource.Cancel() fires (from reader disposal) before the continuation reaches context.Execute(). The cancellation token is checked in ContinueAsyncCall or ExecuteAsyncCall, and the method falls through to ADP.ClosedConnectionError(), which returns an InvalidOperationException.
The race winner depends on timing:
- Low latency (local SQL Server): Data arrives faster, so the continuation is more likely to already be inside
context.Execute() → IOException.
- High latency (Azure SQL): Longer round-trip means the continuation is less likely to have entered the execute path before
_cancelAsyncOnCloseTokenSource.Cancel() fires → InvalidOperationException.
Relevant Code
SqlDataReader.ContinueAsyncCall<T>() — the final fallthrough at the end returns ADP.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 — wraps SqlException in IOException when the read faults.
Expected Behavior
The exception type thrown by a pending ReadAsync task when the reader is disposed should be deterministic and consistent regardless of network latency or server type.
Actual Behavior
- Against local SQL Server: usually
AggregateException wrapping IOException
- Against Azure SQL: usually
AggregateException wrapping InvalidOperationException
- Sometimes no exception at all (read completes before disposal)
Workaround
Tests in DataStreamTest.cs (DEBUG-only #if DEBUG blocks) currently use a try/catch pattern that accepts either exception type or no exception at all, guarded by TODO(GH-3604) comments.
Related
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