dotnet / dotnet/SqlClient

SqlDataReader async read throws inconsistent exception types on disposal (IOException vs InvalidOperationException)

Open
#4,088 0 comments 0 reactions 0 assignees View on GitHub
Public API :new:
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.