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
(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)
Description
Starting in .NET 11,
Microsoft.Extensions.Primitives.ChangeTokenprovides newOnChangeoverloads 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
asynclambda or aTask-returning callback previously bound to theActionoverload; after recompiling against .NET 11, they bind to the newFunc<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
OnChangeoverloads existed:Passing an
asynclambda bound to theActionoverload, which compiled the lambda as anasync voidmethod. The callback was fire-and-forget: the change token was re-registered immediately when the callback returned (i.e. at the firstawaitthat yielded), without waiting for the asynchronous work to finish, and any exception thrown after the firstawaitwas raised on the synchronization context / thread pool rather than being observable by the caller.New behavior
Two asynchronous overloads are added:
An
asynclambda (or anyTask-returning callback) now binds to the newFunc<Task>overload. The lambda is compiled asasync Taskinstead ofasync void, and the change token is only re-registered once the returnedTaskcompletes. Changes that occur while the callback's task is still in flight are coalesced into a single subsequent invocation.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
ActionandFunc<Task>(for example, anasynclambda, aTask-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
(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.OnChangeto run asynchronous logic before re-subscribing for the next change: the only callback type was synchronous (Action), which forced callers intoasync 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 toAction(orAction<TState>) so it continues to bind to the synchronous overload: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)