Skip to content

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

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

Activity

  1. added this to the 7.1.0-preview1 milestone on Mar 25, 2026
  2. added theissue type on Mar 25, 2026
  3. moved this from To triage to Backlog in SqlClient Boardon Mar 25, 2026
  4. moved this from Backlog to In progress in SqlClient Boardon Apr 6, 2026
  5. priyankatiwari08 commented on Sep 17, 2026

    @priyankatiwari08
    Contributor

    /triage

  6. github-actions commented on Sep 17, 2026

    @github-actions

    🔍 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\Async
    Duplicates 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 SqlDataReader async reads: one path wraps a SqlException in IOException via SqlSequentialStream/SqlSequentialTextReader, while the other short-circuits through ADP.ClosedConnectionError() producing InvalidOperationException, depending on whether _cancelAsyncOnCloseTokenSource.Cancel() fires before or after context.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 flaky Debug.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() and ExecuteAsyncCall() so that the cancellation-token check and the context.Execute() fallthrough consistently produce one exception type (likely ObjectDisposedException or a dedicated InvalidOperationException variant) regardless of race outcome, and to audit SqlSequentialStream/SqlSequentialTextReader wrapping logic so it does not mask the same underlying disposal condition with IOException in the fast-completion case.
    • Once a fix lands, remove the TODO(GH-3604) workaround in DataStreamTest.cs and 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 · ◷

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions