Context
Follow-up from #265 (the Outcome.Try feature). Codex flagged this on the synchronous side-effecting overload Outcome.Try<TException>(Action operation, …) — first as P2, then escalated to P1 on the deep review.
Problem
A parameterless async lambda binds to the Action parameter as async void:
Outcome.Try<InvalidOperationException>(async () => { await SaveAsync(); /* throws */ }, Map);
Overload resolution picks Action because there is no token-less async overload. The Action returns at the first await, so Try observes no exception, returns Success, and any exception raised after the first await escapes the catch out-of-band (an unobserved async void exception, which typically crashes the process). The same shape silently fire-and-forgets a task for () => ReturnsTask().
The value overloads are not affected: an async () => … returning Task<T> does not bind to Func<T> (compile error), and the token-threading async overloads (Func<CancellationToken, Task> / Func<CancellationToken, Task<T>>) require an argument, so a parameterless async lambda misses them and falls to Action.
Options to weigh in this follow-up
- Analyzer (the originally-planned FCE023). Flag an async lambda passed to a synchronous
Outcome.Try overload and point to the token overload. Keeps the public API narrow; consistent with the FCE019–FCE022 usage-guard family. Severity to decide (Warning surfaces it; Error blocks the build where the analyzers run).
- Token-less async overloads. Add
Func<Task> / Func<Task<T>> (delegating to the token overloads with default). Async lambdas then bind there and are awaited + mapped — a structural fix that also covers the fire-and-forget case, at the cost of two more public overloads and some tension with the "narrow Try" design.
These are not mutually exclusive, but option 2 largely obviates the analyzer for the lambda case.
References
Context
Follow-up from #265 (the
Outcome.Tryfeature). Codex flagged this on the synchronous side-effecting overloadOutcome.Try<TException>(Action operation, …)— first as P2, then escalated to P1 on the deep review.Problem
A parameterless
asynclambda binds to theActionparameter asasync void:Overload resolution picks
Actionbecause there is no token-less async overload. TheActionreturns at the firstawait, soTryobserves no exception, returnsSuccess, and any exception raised after the firstawaitescapes thecatchout-of-band (an unobservedasync voidexception, which typically crashes the process). The same shape silently fire-and-forgets a task for() => ReturnsTask().The value overloads are not affected: an
async () => …returningTask<T>does not bind toFunc<T>(compile error), and the token-threading async overloads (Func<CancellationToken, Task>/Func<CancellationToken, Task<T>>) require an argument, so a parameterless async lambda misses them and falls toAction.Options to weigh in this follow-up
Outcome.Tryoverload and point to the token overload. Keeps the public API narrow; consistent with the FCE019–FCE022 usage-guard family. Severity to decide (Warning surfaces it; Error blocks the build where the analyzers run).Func<Task>/Func<Task<T>>(delegating to the token overloads withdefault). Async lambdas then bind there and are awaited + mapped — a structural fix that also covers the fire-and-forget case, at the cost of two more public overloads and some tension with the "narrowTry" design.These are not mutually exclusive, but option 2 largely obviates the analyzer for the lambda case.
References
Outcome.Trydecision)