SqlDataReader async read throws inconsistent exception types on disposal (IOException vs InvalidOperationException)
- Dominant language
- C#
- Stars
- 989
- Forks
- 340
- Avg merge
- 4d 19h
- Merged PRs (30d)
- 72
Description
## 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`:
1. **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`.
2. **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()` — the final fallthrough at the end returns `ADP.ClosedConnectionError()` (`InvalidOperationException`) when the reader is closed, regardless of what the caller expects.
- `SqlDataReader.ExecuteAsyncCall()` — 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
- #3604
Contributor guide
Assessment
This issue has not been assessed yet.