Skip to content

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

@paulmedynski

Description

@paulmedynski

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<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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Public API 🆕Issues/PRs that introduce new APIs to the driver.

    Type

    Projects

    Status

    Backlog

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions