Skip to content

[Breaking change]: ChangeToken.OnChange async overloads change binding of existing async/Task-returning callbacks #54635

Description

@svick

Description

Starting in .NET 11, Microsoft.Extensions.Primitives.ChangeToken provides new OnChange overloads that accept an asynchronous callback (Func<Task> / Func<TState, Task>) in addition to the existing synchronous callback (Action / Action<TState>) overloads.

Because the new overloads differ from the existing ones only by the callback's return type, adding them can change overload resolution for existing source code. Calls that pass an async lambda or a Task-returning callback previously bound to the Action overload; after recompiling against .NET 11, they bind to the new Func<Task> overload instead, which changes the runtime behavior. Such code continues to compile without changes.

PR: dotnet/runtime#129624

Version

.NET 11 Preview 7

Previous behavior

Only synchronous OnChange overloads existed:

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

Passing an async lambda bound to the Action overload, which compiled the lambda as an async void method. The callback was fire-and-forget: the change token was re-registered immediately when the callback returned (i.e. at the first await that yielded), without waiting for the asynchronous work to finish, and any exception thrown after the first await was raised on the synchronization context / thread pool rather than being observable by the caller.

// Bound to OnChange(Func<IChangeToken?>, Action) — compiled as 'async void'.
ChangeToken.OnChange(config.GetReloadToken, async () =>
{
    await Task.Delay(1000);
    Console.WriteLine("Reloaded");
});

New behavior

Two asynchronous overloads are added:

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

An async lambda (or any Task-returning callback) now binds to the new Func<Task> overload. The lambda is compiled as async Task instead of async void, and the change token is only re-registered once the returned Task completes. Changes that occur while the callback's task is still in flight are coalesced into a single subsequent invocation.

// Now binds to OnChange(Func<IChangeToken?>, Func<Task>) — compiled as 'async Task'.
// The token is re-registered only after the returned task completes.
ChangeToken.OnChange(config.GetReloadToken, async () =>
{
    await Task.Delay(1000);
    Console.WriteLine("Reloaded");
});

This re-binding is silent: the affected call sites continue to compile unchanged, and no new compiler ambiguity is introduced. When a callback is convertible to both Action and Func<Task> (for example, an async lambda, a Task-returning method group, or a statement lambda that always throws), C# overload resolution has a single preferred candidate (Func<Task>), no ambiguity error is produced.

Type of breaking change

  • Behavioral change: Existing binaries might behave differently at run time.

(This change is not binary incompatible: already-compiled binaries continue to bind to the overload they were compiled against. It is also not source incompatible: existing source continues to compile, though async/Task-returning callbacks silently re-bind to the new overload.)

Reason for change

Previously it was not possible to use ChangeToken.OnChange to run asynchronous logic before re-subscribing for the next change: the only callback type was synchronous (Action), which forced callers into async void (with its fire-and-forget semantics and unobservable exceptions) or into blocking on asynchronous work. The new overloads let callers run asynchronous logic and defer re-registration until it completes, which is generally more correct. This API was approved in dotnet/runtime#69099.

Recommended action

For most code, no action is required, and the new binding is an improvement: asynchronous work now completes before the change token is re-registered, and exceptions thrown synchronously by the callback are observable by the code that triggers the token.

To keep the previous fire-and-forget (async void) behavior, cast the callback to Action (or Action<TState>) so it continues to bind to the synchronous overload:

ChangeToken.OnChange(config.GetReloadToken, (Action)(async () =>
{
    await Task.Delay(1000);
    Console.WriteLine("Reloaded");
}));

There is no AppContext switch or configuration option that changes overload resolution; the behavior is determined at compile time.

Feature area

Extensions

Affected APIs

The change is caused by adding these new overloads to Microsoft.Extensions.Primitives.ChangeToken:

  • Microsoft.Extensions.Primitives.ChangeToken.OnChange(System.Func<Microsoft.Extensions.Primitives.IChangeToken?>, System.Func<System.Threading.Tasks.Task>)
  • Microsoft.Extensions.Primitives.ChangeToken.OnChange<TState>(System.Func<Microsoft.Extensions.Primitives.IChangeToken?>, System.Func<TState, System.Threading.Tasks.Task>, TState)

Existing calls to the following overloads silently re-bind to the new overloads above when the callback returns a Task:

  • Microsoft.Extensions.Primitives.ChangeToken.OnChange(System.Func<Microsoft.Extensions.Primitives.IChangeToken?>, System.Action)
  • Microsoft.Extensions.Primitives.ChangeToken.OnChange<TState>(System.Func<Microsoft.Extensions.Primitives.IChangeToken?>, System.Action<TState>, TState)

Metadata

Metadata

Assignees

Labels

🗺️ reQUESTTriggers an issue to be imported into Quest.breaking-changeIndicates a .NET Core breaking change

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
🔖 Ready

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions