Most defects in this area compile. The code runs, but cancellation does not reach the work,
an Async never starts, a ValueTask is read twice, or an exception changes type. Choose the
builder from the boundary type, convert once at the boundary, and pass the token explicitly.
Builder choice
| Situation | Use |
|---|---|
Code returns Task or Task<'T> to .NET callers (ASP.NET, interfaces, libraries) |
task { } |
Interface or hot path returns ValueTask<'T> |
valueTask { } from IcedTasks, or task { } then ValueTask<'T>(t) at the edge |
| Work must be re-runnable or retried, or the token must flow implicitly | async { } |
Task-based code that must pass a token to bound Async and CancellableTask values |
cancellableTask { } from IcedTasks |
Task<Result<_,_>> |
taskResult { } from FsToolkit.ErrorHandling |
FSharp.Core has task and async, but no valueTask builder. If the code does not open
IcedTasks, valueTask { } gives FS0039. FS0193 between ValueTask<'T> and Task<'T>,
or between Task<'T> and Async<'T>, means the builder does not match the declared return
type. Change the builder or convert once. Do not add a conversion at each call site.
Hot and cold
taskandvalueTaskare hot. The body runs up to the first incomplete await when you call the function. ATaskvalue is a running operation, not a recipe. To retry, takeunit -> Task<'T>, notTask<'T>.asyncis cold. Nothing runs untilAsync.Start,Async.StartAsTask,Async.RunSynchronously, or alet!.work |> ignoreon anAsyncdrops the work, and the compiler does not warn.Async.AwaitTask(api.Get x)callsapi.Getwhen theAsyncvalue is built, not when it runs. A retry of that value awaits the same failed task again. Build the task inside the body:async { return! Async.AwaitTask(api.Get x) }.- Fire-and-forget:
Async.Start(work, ct)forAsync, orwork () |> ignorefor a hotTask. Put an exception handler inside the work. An unhandled exception inAsync.Startwork is rethrown on a thread-pool thread and stops the process. Never ignore anAsyncvalue.
Conversion at boundaries
| From | To | Use | Note |
|---|---|---|---|
Async<'T> |
Task<'T> |
Async.StartAsTask(work, cancellationToken = ct) |
Always pass the token. |
Async<'T> |
Task<'T> on the caller's thread |
Async.StartImmediateAsTask(work, ct) |
Same token rule. |
Task<'T> |
Async<'T> |
Async.AwaitTask t |
Faults arrive as AggregateException. |
ValueTask<'T> |
Task<'T> |
vt.AsTask() once |
After this, await only the Task. |
Task<'T> |
ValueTask<'T> |
ValueTask<'T>(t) |
|
Async<'T> inside task |
let! x = work |
Runs on Async.DefaultCancellationToken, not the caller's token. |
Do not block: .Result, .Wait(), GetAwaiter().GetResult(), and
Async.RunSynchronously hold a thread, throw at the call instead of in the returned task, and
.Result and .Wait() wrap the exception in AggregateException. Do not use
ContinueWith(...).Unwrap() in F#. task { let! x = t ... } composes directly.
Cancellation
A CancellationToken parameter that the body does not pass on is a defect. The tests still pass.
task { let! x = someAsync }ignores the function'sct. Start theAsyncwithAsync.StartAsTask(work, cancellationToken = ct), or write the body asasyncand convert at the edge, or usecancellableTaskand bind theAsyncinside it.- Pass
ctto every .NET call that accepts one:Task.Delay(ms, ct),stream.ReadAsync(buffer, ct). - Inside
async,Async.CancellationTokenreads the token.Async.Sleepandlet!check it. Async.StartChilddoes not cancel a sibling when another child fails.Async.Paralleldoes. The sibling stops only if its .NET call received the token fromAsync.CancellationToken.CancellationToken.Nonein a child means a failure cannot stop it.
let handle (lib: Library) (id: int) (ct: CancellationToken) : Task<Report> =
let work =
async {
let! report = buildReport lib id
let audit =
async {
try
do! lib.Audit $"report {id} served"
with ex ->
eprintfn "audit failed: %s" ex.Message
}
Async.Start(audit, ct)
return report
}
Async.StartAsTask(work, cancellationToken = ct)
Exceptions
- An exception inside
task { }faults the returned task. Code beforetask { }throws at the call. Keep validation inside the builder so callers see one failure path. let!andGetAwaiter().GetResult()give the original exception..Result,.Wait(), andAsync.AwaitTaskgiveAggregateException. Insideasync, atry ... witharoundAsync.AwaitTaskalso seesAggregateException, sowith :? MyExceptionand F# exception patterns do not match. Unwrap one level at the boundary: match:? AggregateException as a when a.InnerExceptions.Count = 1and raisea.InnerExceptions.[0].Task.WhenAllfaults with every inner exception, andlet!rethrows only the first.- Cancellation surfaces as
OperationCanceledExceptionorTaskCanceledException. Do not catch it with a generalwith ex ->that logs and continues.
ValueTask
A ValueTask<'T> can be consumed once. A pooled source, such as a socket, recycles its
storage when you read the result. A second read throws InvalidOperationException. A Task
source does not throw, so the defect appears only when the source is pooled.
- Do not
let!the sameValueTasktwice. Do not read.Resultafterlet!. - Do not pass
vt.AsTask()toTask.WhenAnyand then readvt.Result. Keep theTask:let t = vt.AsTask(), then awaittas often as needed. - Do not store a
ValueTaskin a field or a list for later. Convert it withAsTask()first.
let read = source.ReadAsync().AsTask()
let! winner = Task.WhenAny(read, Task.Delay timeoutMs)
if obj.ReferenceEquals(winner, read) then
let! frame = read
return Some frame
else
return None
State machine diagnostics
- FS3511: the compiler cannot compile a
taskstate machine statically and uses a slower dynamic one. Alet recinside the builder is one cause. Move thelet recoutside the builder. Other causes are non-inlined lambdas that hold resumable code and some generic-constraint cases. Treat FS3511 on a hot path as a defect. - FS3501 and the related resumable-code errors come from a custom builder. Builder methods
that take or return resumable code must be
inline. Use an existing builder from IcedTasks or FsToolkit before you write one.
Verify
- Test each function that takes a
CancellationToken. Cancel during the work. Check that the work stops, not only that the returned task completes. - For each fire-and-forget call, check that the side effect happens.
- For a
ValueTaskAPI, test with a source that throws on a second read, as in scripts/Proofs.fsx. - Build in Release and treat FS3511 on changed code as a failure.
To check the facts in this skill, run the bundled proof:
pwsh ./scripts/run.ps1
The runner runs scripts/Proofs.fsx and builds
scripts/StateMachine in Release. It fails when a fact differs or when
FS3511 is not reported only for the let rec case. Each PASS line prints one fact and its
observed value. The IcedTasks version is pinned in the script.