Summary
Behaviors see a Task or ValueTask as an ordinary return value. A reusable behavior cannot hard-code the result type, and Native AOT cannot look that result up with reflection.
Add ProceedExtension.Proceed so a behavior can write:
return invocation.Proceed(next, async (outcome, again) =>
{
if (outcome.Exception is IOException)
return await again();
return ProceedOutcome.FromValue((int)outcome.Value! + 1, outcome.Elapsed);
});
API
IMethodReturn Proceed(this IMethodInvocation invocation, ExecuteHandler next, ProceedHandler callback) in ProceedExtension.cs.
ProceedHandler is ValueTask<ProceedOutcome> (ProceedOutcome outcome, ProceedAgain again).
ProceedAgain calls next once more and returns that attempt. It does not re-enter the current behavior.
ProceedOutcome exposes Value, Exception, and Elapsed. FromValue and FromException build a replacement attempt. For Task<T> and ValueTask<T>, Value is T, not the awaitable.
Behavior
- Call
next once before the callback. again may call it again. Attempts do not overlap.
- Synchronous members, constructors,
ref returns, and non-awaitables (IAsyncEnumerable<T>, custom awaitables) run the callback before Proceed returns. The callback's ValueTask must already be completed. Otherwise throw InvalidOperationException.
- Preserve
ref and out values on synchronous returns.
- A throw from the target or from
next becomes outcome.Exception. When the callback keeps that exception, Proceed returns CreateExceptionReturn, so the stunt still throws on the caller thread.
Task, ValueTask, Task<T>, and ValueTask<T> return a new awaitable of the same declared kind immediately. The callback runs when that attempt finishes. Unwrap with await and ConfigureAwait(false). A faulted or canceled awaitable stays faulted or canceled, with the original exception, and IMethodReturn.Exception stays null.
- A null task or value task is a synchronous failure (
NullReferenceException).
ValueTask and ValueTask<T> are single-consumption. Each Proceed awaits the attempt it received and returns a new awaitable, so an outer behavior awaits that new one.
Elapsed includes the time spent awaiting the attempt.
Native AOT
AsyncRegistry.Register<T>() registers the closed adapters for Task<T> and ValueTask<T>. The generator emits that call from the stunt static constructor for every closed type argument a generated member returns. Non-generic Task and ValueTask adapters ship with the library.
When dynamic code is unavailable and the adapter is missing, throw NotSupportedException naming AsyncRegistry.Register<{T}>() and a module initializer. Open generic intercepted methods stay on the existing ST015 path and do not receive a closed adapter.
Acceptance
- A synchronous callback can replace a value or recover an exception, and
again can retry.
- A faulted
Task<T> preserves exception identity. A canceled task stays canceled.
- Two behaviors compose over one
ValueTask<T> and consume it once.
- A class stunt reports a base-call exception and can adjust a base
Task<T>.
- A generated stunt for
Task<int> and ValueTask<string> contains AsyncRegistry.Register<int>() and AsyncRegistry.Register<string>().


Summary
Behaviors see a
TaskorValueTaskas an ordinary return value. A reusable behavior cannot hard-code the result type, and Native AOT cannot look that result up with reflection.Add
ProceedExtension.Proceedso a behavior can write:API
IMethodReturn Proceed(this IMethodInvocation invocation, ExecuteHandler next, ProceedHandler callback)inProceedExtension.cs.ProceedHandlerisValueTask<ProceedOutcome> (ProceedOutcome outcome, ProceedAgain again).ProceedAgaincallsnextonce more and returns that attempt. It does not re-enter the current behavior.ProceedOutcomeexposesValue,Exception, andElapsed.FromValueandFromExceptionbuild a replacement attempt. ForTask<T>andValueTask<T>,ValueisT, not the awaitable.Behavior
nextonce before the callback.againmay call it again. Attempts do not overlap.refreturns, and non-awaitables (IAsyncEnumerable<T>, custom awaitables) run the callback beforeProceedreturns. The callback'sValueTaskmust already be completed. Otherwise throwInvalidOperationException.refandoutvalues on synchronous returns.nextbecomesoutcome.Exception. When the callback keeps that exception,ProceedreturnsCreateExceptionReturn, so the stunt still throws on the caller thread.Task,ValueTask,Task<T>, andValueTask<T>return a new awaitable of the same declared kind immediately. The callback runs when that attempt finishes. Unwrap withawaitandConfigureAwait(false). A faulted or canceled awaitable stays faulted or canceled, with the original exception, andIMethodReturn.Exceptionstays null.NullReferenceException).ValueTaskandValueTask<T>are single-consumption. EachProceedawaits the attempt it received and returns a new awaitable, so an outer behavior awaits that new one.Elapsedincludes the time spent awaiting the attempt.Native AOT
AsyncRegistry.Register<T>()registers the closed adapters forTask<T>andValueTask<T>. The generator emits that call from the stunt static constructor for every closed type argument a generated member returns. Non-genericTaskandValueTaskadapters ship with the library.When dynamic code is unavailable and the adapter is missing, throw
NotSupportedExceptionnamingAsyncRegistry.Register<{T}>()and a module initializer. Open generic intercepted methods stay on the existingST015path and do not receive a closed adapter.Acceptance
againcan retry.Task<T>preserves exception identity. A canceled task stays canceled.ValueTask<T>and consume it once.Task<T>.Task<int>andValueTask<string>containsAsyncRegistry.Register<int>()andAsyncRegistry.Register<string>().