Skip to content

Added async overload of ChangeToken.OnChange#129624

Open
svick wants to merge 11 commits into
dotnet:mainfrom
svick:async-changetoken-onchange
Open

Added async overload of ChangeToken.OnChange#129624
svick wants to merge 11 commits into
dotnet:mainfrom
svick:async-changetoken-onchange

Conversation

@svick

@svick svick commented Jun 19, 2026

Copy link
Copy Markdown
Member

Fixes #69099.

Adds async (Func<Task>) overloads of ChangeToken.OnChange, so callers can run asynchronous logic when a change token fires without resorting to async void or blocking:

public static IDisposable OnChange(Func<IChangeToken?> changeTokenProducer, Func<Task> changeTokenConsumer);
public static IDisposable OnChange<TState>(Func<IChangeToken?> changeTokenProducer, Func<TState, Task> changeTokenConsumer, TState state);

These match the shape approved in #69099.

Behavior

The existing ChangeTokenRegistration<TState> is refactored into an abstract base class holding the registration/disposal state machine, with SyncChangeTokenRegistration and AsyncChangeTokenRegistration subclasses. The synchronous overloads behave exactly as before.

For the async overloads:

  • The consumer is invoked synchronously, so synchronous exceptions from it are propagated to the code that triggers the change token, just like the synchronous overloads.
  • The change token is only re-registered once the returned Task completes. Changes that occur while the consumer's task is still in flight are coalesced into a single subsequent invocation.
  • Asynchronous faults from the consumer's task cannot be propagated without blocking, so they are left unobserved (observable only through TaskScheduler.UnobservedTaskException). This is documented in the API remarks.
  • When the consumer completes synchronously, re-registration happens without allocating an async state machine.

Breaking change

This is a source (not binary) breaking change, as called out in the proposal's Risk section: existing code that passes an async lambda to OnChange previously bound to the Action overload (compiling to async void / fire-and-forget). With these overloads present, such call sites now bind to the Func<Task> overload and re-registration is deferred until the returned task completes. The new behavior is generally more correct. A throwing statement lambda can also become ambiguous between Action and Func<Task> (CS0121); the two pre-existing tests affected were updated with explicit (Action) casts.

Tests

  • Null-argument validation for all four overloads.
  • Change firing (with and without state) for the async overloads.
  • Disposal stops further consumer invocations.
  • Re-registration is deferred until the consumer's task completes (in-flight changes coalesce).
  • Synchronous consumer exceptions propagate to the trigger; asynchronous faults do not; the subscription survives both.
  • Producer exceptions during initial registration propagate to the caller; producer exceptions when the token fires propagate to the trigger (all four overloads).
  • Disposal during a running consumer (both from within the consumer and from another thread) suppresses re-registration. The cross-thread case requires real multithreading and is gated with [ConditionalTheory(... IsMultithreadingSupported)].

Note

This PR description was generated by GitHub Copilot.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 extends Microsoft.Extensions.Primitives.ChangeToken with Task-returning OnChange overloads so consumers can run asynchronous work and delay re-registration until that work completes, and it refactors the internal registration logic to share more between sync and async paths.

Changes:

  • Add ChangeToken.OnChange(Func<IChangeToken?>, Func<Task>) and ChangeToken.OnChange<TState>(Func<IChangeToken?>, Func<TState, Task>, TState) public overloads.
  • Refactor registration implementation into shared base + sync/async derived registrations.
  • Update/add unit tests covering async behavior, disposal while in-flight, and overload binding.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs Adds async overloads and refactors internal registration to support deferred re-registration after Task completion.
src/libraries/Microsoft.Extensions.Primitives/ref/Microsoft.Extensions.Primitives.cs Updates ref assembly to include the new public overloads.
src/libraries/Microsoft.Extensions.Primitives/tests/ChangeTokenTest.cs Adjusts existing tests for overload binding and adds new tests for async/disposal/coalescing semantics.

Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs
Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs
@tarekgh tarekgh added this to the 11.0.0 milestone Jun 19, 2026
Copilot AI review requested due to automatic review settings June 24, 2026 13:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs:165

  • SetDisposable assumes the incoming disposable is non-null and calls Dispose() on it in the disposed-sentinel race. However, IChangeToken implementations (including this test suite's TestChangeToken) can return null from RegisterChangeCallback, which would make those Dispose() calls throw NullReferenceException under disposal races. Make SetDisposable accept IDisposable? and null-check before disposing so this stays race-safe even when the registration is null.
            private void SetDisposable(IDisposable disposable)
            {
                // We don't want to transition from _disposedSentinel => anything since it's terminal
                // but we want to allow going from previously assigned disposable, to another
                // disposable.
                IDisposable? current = Volatile.Read(ref _disposable);

                // If Dispose was called, then immediately dispose the disposable
                if (current == _disposedSentinel)
                {
                    disposable.Dispose();
                    return;
                }

                // Otherwise, try to update the disposable
                IDisposable? previous = Interlocked.CompareExchange(ref _disposable, disposable, current);

                if (previous == _disposedSentinel)
                {
                    // The subscription was disposed so we dispose immediately and return
                    disposable.Dispose();
                }

Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs
Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 24, 2026 14:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs
Copilot AI review requested due to automatic review settings June 24, 2026 14:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs
Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs
Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs
@svick svick marked this pull request as ready for review June 24, 2026 17:21
@svick svick requested a review from rosebyte June 24, 2026 17:28

@rosebyte rosebyte left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Only a couple of non-blocking comments.

Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs Outdated
Comment thread src/libraries/Microsoft.Extensions.Primitives/src/ChangeToken.cs Outdated
{
// The consumer is invoked synchronously here, so that synchronous exceptions from it are propagated
// to the code that triggers the change token, just like the sync overload does.
Task consumerTask = _changeTokenConsumer(State) ?? throw new InvalidOperationException("The task returned by changeTokenConsumer must not be null.");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We have a method for IOE too in ThrowHelper IIRC.

Copilot AI review requested due to automatic review settings June 25, 2026 16:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +127 to +133
protected void RegisterChangeTokenCallback(IChangeToken? token)
{
if (token is null)
{
return;
}
IDisposable registraton = token.RegisterChangeCallback(s => ((ChangeTokenRegistration<TState>?)s)!.OnChangeTokenFired(), this);
IDisposable? registration = token.RegisterChangeCallback(static s => ((ChangeTokenRegistration<TState>?)s)!.OnChangeTokenFired(), this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-Extensions-Primitives breaking-change Issue or PR that represents a breaking API or functional change over a previous release. needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API Proposal]: Add async overload of ChangeToken.OnChange

4 participants