Skip to content

Conversation

@benrr101
Copy link
Contributor

@benrr101 benrr101 commented Oct 20, 2025

Description

Hello all, in this latest installment, I'm rewriting majority of the AsyncHelper ContinueTask and CreateContinuationTask methods to have generic state. One thing that has bothered me a lot throughout the codebase is that many (if not all) of the ContinueTask or CreateContinuationTask callbacks have lines that decode the state object(s) passed into them. Something like this:

AsyncHelper.CreateContinuationTaskWithState(
    myTask,
    this,
    (object state) => {
        SqlCommand sqlCommand = (SqlCommand)state;
        sqlCommand.DoSomething();
    });

Not only is this challenging to read, it poses the potential for bugs where the type the state object is cast to could be anything, valid or invalid.

Instead, I've taken the liberty to:

  1. Rewrite these methods with up to two generics for the stateful methods. This allows full IDE assistance when writing callbacks, improving readability, as well as helping eliminate runtime bugs from incorrect type casts.
  2. Clean up the methods to follow a pattern
    1. Records for encapsulating the state object(s) internally to make all task continuations static lambdas
    2. Remove holes in the implemented methods - each method handles the entire continuation (please note that this does significantly increase redundant code, but I argue that this is acceptable because chaining the methods together introduces additional layers of call unwrapping and state wrapping, which decreases performance and makes debugging a nightmare - we should aim to remove these methods anyhow and move to async/await natives wherever possible)
  3. Removed the "exception converter" from the methods - this was being used around two times and could be handled better by setting the converted exception in the onFailure handler.
  4. Clean up calls to these methods
    1. If the method could be moved to using the stateful version, it has been
    2. If the method used a Tuple<x, y> before, it's been decomposed to use the two-generic overload
    3. There are still a handful that could not be migrated to static lambdas due to their excessive amount of closures being used.
  5. Add a large number of unit tests that test just about every scenario that could happen in the async handler.
    1. This introduces a dependency on Moq

You can now safely rewrite the above example as (generic isn't necessary since it's obvious via object types):

AsyncHelper.CreateContinuationTaskWithState(
    taskToContinue: myTask,
    state: this,
    onSuccess: this2 => this2.DoSomething());

Issues

N/A

Testing

Introduced many unit tests for the new code, existing code can be validated via CI.

@benrr101 benrr101 added this to the 7.0.0-preview3 milestone Oct 20, 2025
@benrr101 benrr101 requested a review from a team as a code owner October 20, 2025 23:54
Copilot AI review requested due to automatic review settings October 20, 2025 23:54
@benrr101 benrr101 added the Code Health 💊 Issues/PRs that are targeted to source code quality improvements. label Oct 20, 2025
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR modernizes the AsyncHelper utility class by introducing generic type parameters for state management in task continuation methods. The changes eliminate runtime type casting in continuation callbacks, improve code readability, and reduce the risk of type-related bugs.

Key changes:

  • Rewrote AsyncHelper methods to support up to two generic type parameters for stateful continuations
  • Introduced internal record types to encapsulate state objects for static lambda continuations
  • Added comprehensive unit tests using Moq for all AsyncHelper scenarios
  • Updated 100+ call sites across the codebase to use the new type-safe APIs

Reviewed Changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Utilities/AsyncHelper.cs New implementation with generic state parameters and continuation records
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlUtil.cs Removed old AsyncHelper implementation
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/Utilities/AsyncHelperTest.cs Comprehensive unit tests for all AsyncHelper methods and scenarios
src/Microsoft.Data.SqlClient/tests/UnitTests/Utilities/MockExtensions.cs Helper extension methods for Moq setup in tests
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.*.cs Updated to use generic AsyncHelper methods with decomposed tuples
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs Updated to use generic AsyncHelper methods, removed closure-based state
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser*.cs Updated AsyncHelper call sites to use named parameters and generic state
src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj Added Moq dependency and reorganized package references
src/Directory.Packages.props Added Moq package version specification

Comment on lines 89 to 93
typedState2.OnFailure?.Invoke(typedState2.State, task.Exception);
}
finally
{
typedState2.TaskCompletionSource.TrySetException(task.Exception);
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing task.Exception directly (an AggregateException) instead of task.Exception.InnerException. This is inconsistent with the non-generic ContinueTask method at line 27, which passes InnerException. The failure handler signature expects a single Exception, not an AggregateException.

Suggested change
typedState2.OnFailure?.Invoke(typedState2.State, task.Exception);
}
finally
{
typedState2.TaskCompletionSource.TrySetException(task.Exception);
typedState2.OnFailure?.Invoke(typedState2.State, task.Exception.InnerException);
}
finally
{
typedState2.TaskCompletionSource.TrySetException(task.Exception.InnerException);

Copilot uses AI. Check for mistakes.
Comment on lines 87 to 93
try
{
typedState2.OnFailure?.Invoke(typedState2.State, task.Exception);
}
finally
{
typedState2.TaskCompletionSource.TrySetException(task.Exception);
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing task.Exception (an AggregateException) instead of task.Exception.InnerException. This is inconsistent with the non-generic ContinueTask method at line 34, which passes InnerException.

Suggested change
try
{
typedState2.OnFailure?.Invoke(typedState2.State, task.Exception);
}
finally
{
typedState2.TaskCompletionSource.TrySetException(task.Exception);
Exception exc = task.Exception.InnerException;
try
{
typedState2.OnFailure?.Invoke(typedState2.State, exc);
}
finally
{
typedState2.TaskCompletionSource.TrySetException(exc);

Copilot uses AI. Check for mistakes.
{
try
{
typedState2.OnFailure?.Invoke(typedState2.State1, typedState2.State2, task.Exception);
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing task.Exception directly (an AggregateException) instead of task.Exception.InnerException. Should be consistent with the non-generic version and unwrap the inner exception.

Suggested change
typedState2.OnFailure?.Invoke(typedState2.State1, typedState2.State2, task.Exception);
typedState2.OnFailure?.Invoke(typedState2.State1, typedState2.State2, task.Exception.InnerException);

Copilot uses AI. Check for mistakes.
}
finally
{
typedState2.TaskCompletionSource.TrySetException(task.Exception);
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing task.Exception (an AggregateException) instead of task.Exception.InnerException. Should unwrap the inner exception for consistency.

Suggested change
typedState2.TaskCompletionSource.TrySetException(task.Exception);
if (task.Exception.InnerExceptions.Count == 1)
{
typedState2.TaskCompletionSource.TrySetException(task.Exception.InnerException);
}
else
{
typedState2.TaskCompletionSource.TrySetException(task.Exception);
}

Copilot uses AI. Check for mistakes.
Comment on lines +1740 to 1723
// @TODO: This doesn't do anything, afaik.
throw exception;
Copy link

Copilot AI Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO comment indicates that throwing the exception in the onFailure handler may not have the intended effect. The exception is rethrown but might not propagate correctly since this is in a continuation callback. This should either be removed if truly ineffective, or the exception handling should be revised.

Copilot uses AI. Check for mistakes.
@benrr101 benrr101 force-pushed the dev/russellben/asynchelper-generic-state branch from db04282 to 0e3250f Compare October 21, 2025 17:01
@paulmedynski paulmedynski self-assigned this Oct 22, 2025
Copy link
Contributor

@paulmedynski paulmedynski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked at everything except AsyncHelper.cs for now. Maybe a quick call to explain this a bit more for me (and the team?) would help.

paulmedynski
paulmedynski previously approved these changes Oct 31, 2025
paulmedynski
paulmedynski previously approved these changes Nov 4, 2025
mdaigle
mdaigle previously approved these changes Nov 4, 2025
onCancellation.VerifyNeverCalled();
mockOnSuccess.Verify(action => action(), Times.Once);
mockOnFailure.VerifyNeverCalled();
mockOnFailure.VerifyNeverCalled();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mockOnFailure.VerifyNeverCalled();
mockOnCancellation.VerifyNeverCalled();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be worth requesting changes over!

paulmedynski
paulmedynski previously approved these changes Nov 6, 2025
# Conflicts:
#	src/Microsoft.Data.SqlClient/netcore/src/Microsoft/Data/SqlClient/SqlCommand.netcore.cs
#	src/Microsoft.Data.SqlClient/netfx/src/Microsoft.Data.SqlClient.csproj
#	src/Microsoft.Data.SqlClient/netfx/src/Microsoft/Data/SqlClient/SqlCommand.netfx.cs
Copilot AI review requested due to automatic review settings November 6, 2025 23:35
@benrr101 benrr101 dismissed stale reviews from paulmedynski and mdaigle via 3f5cfa9 November 6, 2025 23:35
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 10 comments.

Comments suppressed due to low confidence (2)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs:2888

  • Variable source may be null at this access as suggested by this null check.
    Variable source may be null at this access as suggested by this null check.
                                    source.SetCanceled();

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs:2893

  • Variable source may be null at this access as suggested by this null check.
                                source.SetException(task.Exception.InnerException);

paulmedynski
paulmedynski previously approved these changes Nov 7, 2025
mdaigle
mdaigle previously approved these changes Nov 7, 2025
Fix event source listener test so it is ok with the trace from the unobserved exception
@benrr101 benrr101 dismissed stale reviews from mdaigle and paulmedynski via 10f39c9 November 11, 2025 00:55
paulmedynski
paulmedynski previously approved these changes Nov 11, 2025
Copilot AI review requested due to automatic review settings November 14, 2025 20:02
Copilot finished reviewing on behalf of benrr101 November 14, 2025 20:04
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 13 comments.

Comments suppressed due to low confidence (2)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs:2888

  • Variable source may be null at this access as suggested by this null check.
    Variable source may be null at this access as suggested by this null check.
                                    source.SetCanceled();

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs:2893

  • Variable source may be null at this access as suggested by this null check.
                                source.SetException(task.Exception.InnerException);

Task taskToContinue = Task.CompletedTask;
TaskCompletionSource<object?> taskCompletionSource = GetTaskCompletionSource();
const int state1 = 123;
const int state2 = 234
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing semicolon at the end of the constant declaration. This will cause a compilation error.

Suggested change
const int state2 = 234
const int state2 = 234;

Copilot uses AI. Check for mistakes.
completion,
timeout,
onFailure: static () => SQL.CR_ReconnectTimeout(),
onTimeout: static () => SQL.CR_ReconnectTimeout(),
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter name should be onTimeout to match the method signature of SetTimeoutException. Using onFailure will cause a compilation error.

Copilot uses AI. Check for mistakes.
{
((SqlConnection)state).GetOpenTdsConnection().DecrementAsyncCount();
});
onFailure: static (this2, _, _) =>
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect parameter usage in the onFailure callback. The signature expects Action<TState1, TState2, Exception> but the lambda is using (this2, _, _) which discards both the second state parameter and the exception. The second parameter should be the Tuple state (parameters), not a discard. This should be (this2, parameters, exception) or similar.

Suggested change
onFailure: static (this2, _, _) =>
onFailure: static (this2, parameters, exception) =>

Copilot uses AI. Check for mistakes.
AsyncHelper.ContinueTaskWithState(
task: cancellableReconnectTS.Task,
completion: source,
taskToContinue:cancellableReconnectTS.Task,
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after the colon in the named argument. This should be taskToContinue: cancellableReconnectTS.Task for consistency with the rest of the codebase.

Suggested change
taskToContinue:cancellableReconnectTS.Task,
taskToContinue: cancellableReconnectTS.Task,

Copilot uses AI. Check for mistakes.
/// * <paramref name="onSuccess"/> is called
/// * IF an exception is thrown during execution of <paramref name="onSuccess"/>, the
/// helper will try to set an exception on the <paramref name="taskCompletionSource"/>.
/// * <paramref name="taskCompletionSource"/> is *not* with result on success. This
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Inconsistent comment style. The comment states "is not with result on success" which appears to be missing a word. It should read "is not set with result on success" to be grammatically correct.

Suggested change
/// * <paramref name="taskCompletionSource"/> is *not* with result on success. This
/// * <paramref name="taskCompletionSource"/> is *not* set with result on success. This

Copilot uses AI. Check for mistakes.
// Assert
// - Collected trace event IDs are in the range of official trace event IDs
// @TODO: This is brittle, refactor the SqlClientEventSource code so the event IDs it can throw are accessible here
HashSet<int> acceptableEventIds = new HashSet<int>(Enumerable.Range(0, 21));
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Collected trace event IDs are in the range of official trace event IDs" but then creates a range from 0-20 (Enumerable.Range(0, 21) creates 0 through 20), while the original code checked for range 1-21 (Enumerable.Range(1, 21)). This changes the test behavior - event ID 0 was not previously accepted, and event ID 21 is no longer accepted. This may cause test failures if event ID 21 is valid or if event ID 0 is invalid.

Suggested change
HashSet<int> acceptableEventIds = new HashSet<int>(Enumerable.Range(0, 21));
HashSet<int> acceptableEventIds = new HashSet<int>(Enumerable.Range(1, 21));

Copilot uses AI. Check for mistakes.
Comment on lines +1307 to +1309
// - Force collection of unobserved task
GC.Collect();
GC.WaitForPendingFinalizers();
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to 'GC.Collect()'.

Suggested change
// - Force collection of unobserved task
GC.Collect();
GC.WaitForPendingFinalizers();
// - No need to force collection of unobserved task; rely on explicit observation

Copilot uses AI. Check for mistakes.
}

// Force observation of any exception
_ = taskToRun.Exception;
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable taskToRun may be null at this access as suggested by this null check.

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +105
catch (Exception e)
{
typedState.TaskCompletionSource.TrySetException(e);
}
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic catch clause.

Copilot uses AI. Check for mistakes.
Comment on lines +298 to +301
catch (Exception e)
{
typedState2.TaskCompletionSource.TrySetException(e);
}
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic catch clause.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Code Health 💊 Issues/PRs that are targeted to source code quality improvements.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants