Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5e23e3b
feat(compiler): register LanguageFeature.RuntimeAsync and add ILMetho…
TheAngryByrd Feb 28, 2026
35ce178
feat(compiler): wire 0x2000 async flag through IlxGen
TheAngryByrd Feb 28, 2026
f0252e1
feat(compiler): add type checker validation rules for runtime-async
TheAngryByrd Feb 28, 2026
0b2e62f
feat(compiler): add RuntimeFeature.Async capability check
TheAngryByrd Feb 28, 2026
e242a62
feat(compiler): add ExprContainsAsyncHelpersAwaitCall detection
TheAngryByrd Feb 28, 2026
e9b362b
feat(compiler): implement return-type unwrapping for async methods
TheAngryByrd Feb 28, 2026
28f9bef
feat(fsharp-core): add RuntimeAsyncAttribute for library extensibility
TheAngryByrd Feb 28, 2026
1518fe7
feat(fsharp-core): add RuntimeTaskBuilder and runtimeTask CE
TheAngryByrd Feb 28, 2026
e0cf3d0
test: add IL baseline and validation error tests for runtime-async
TheAngryByrd Feb 28, 2026
c29f2d0
fix(fsharp-core): remove net10.0 TFM (breaks bootstrap build)
TheAngryByrd Feb 28, 2026
581f761
fix(compiler): move RuntimeAsync return type validation after type in…
TheAngryByrd Feb 28, 2026
d2c9548
fix(tests): use enum cast for MethodImplOptions.Async (not available …
TheAngryByrd Feb 28, 2026
4609b93
test: update surface area baselines for RuntimeAsyncAttribute and ILM…
TheAngryByrd Feb 28, 2026
01c7675
test: add behavioral and edge case tests for runtime-async
TheAngryByrd Feb 28, 2026
7b70345
fix(fsharp-core): minor fixes to runtimeAsync and project file
TheAngryByrd Feb 28, 2026
6796790
fix(compiler): pre-unification approach for runtime-async return type…
TheAngryByrd Feb 28, 2026
66b374d
fix(compiler): handle non-generic Task/ValueTask return and fix async…
TheAngryByrd Feb 28, 2026
4d7cfd6
test: add runNewProcess helpers and update MethodImplAttribute behavi…
TheAngryByrd Feb 28, 2026
74ee991
docs: add runtime-async feature documentation
TheAngryByrd Feb 28, 2026
6c434e0
test: add runtimeTask CE unit tests
TheAngryByrd Feb 28, 2026
1df118a
test: add runtimeTask CE unit tests
TheAngryByrd Feb 28, 2026
b60e373
feat(samples): add runtime-async CE library sample with attribute as …
TheAngryByrd Mar 1, 2026
c1119fc
feat(samples): add ILDasm.targets for post-build IL disassembly
TheAngryByrd Mar 1, 2026
7e30de3
fix(compiler): detect AwaitAwaiter/UnsafeAwaitAwaiter in async body a…
TheAngryByrd Mar 2, 2026
b95200e
feat(samples): add generic awaitable Bind, ConfigureAwait, and Task.Y…
TheAngryByrd Mar 2, 2026
ad69e85
docs(samples): update README for generic awaitable support and new ex…
TheAngryByrd Mar 2, 2026
2be7bee
feat(compiler): add RuntimeAsyncAttribute to TcGlobals and update doc…
TheAngryByrd Mar 2, 2026
6ab9131
feat(compiler): implicit NoDynamicInvocation for RuntimeAsync-marked …
TheAngryByrd Mar 2, 2026
490ce5e
feat(compiler): gate runtime-async flag and optimizer inlining behind…
TheAngryByrd Mar 2, 2026
4ab2759
fix(compiler): prevent cross-module inlining of all runtime-async fun…
TheAngryByrd Mar 2, 2026
f10f8cb
test(runtimeasync): update tests and docs for [<RuntimeAsync>] archit…
TheAngryByrd Mar 2, 2026
435b3ac
feat(compiler): gate runtime-async body analysis and optimizer behind…
TheAngryByrd Mar 2, 2026
a011cda
fix(compiler+samples): fix inline Run architecture — remove enclosing…
TheAngryByrd Mar 2, 2026
f5e1f72
feat(compiler+samples): implement cloIsAsync for async closures, non-…
TheAngryByrd Mar 3, 2026
ccd76fb
fix(quality): fix WithAsync indentation in IlxGen.fs, update README K…
TheAngryByrd Mar 3, 2026
6c357f4
feat(compiler): auto-bridge 'T->Task<'T> for [<RuntimeAsync>] CE clos…
TheAngryByrd Mar 4, 2026
90d0f5e
refactor(samples): remove cast and sentinel from RuntimeTaskBuilder
TheAngryByrd Mar 4, 2026
29e8e15
test(runtimeasync): update tests for cast-free builder
TheAngryByrd Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
468 changes: 468 additions & 0 deletions docs/runtime-async.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions docs/samples/runtime-async-library/ILDasm.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project>

<ItemGroup>
<!-- ILDasm tool for post-build IL disassembly. ExcludeAssets prevents it from becoming a runtime dependency. -->
<PackageReference Include="Microsoft.NETCore.ILDAsm" Version="10.0.0" ExcludeAssets="all" />
</ItemGroup>

<Target Name="ILDasm" AfterTargets="Build">
<PropertyGroup>
<_ILDasmExe Condition="$([MSBuild]::IsOSPlatform('Windows'))">ildasm.exe</_ILDasmExe>
<_ILDasmExe Condition="!$([MSBuild]::IsOSPlatform('Windows'))">ildasm</_ILDasmExe>
<_ILDasmDir>$(NuGetPackageRoot)runtime.$(NETCoreSdkPortableRuntimeIdentifier).microsoft.netcore.ildasm/10.0.0/runtimes/$(NETCoreSdkPortableRuntimeIdentifier)/native/</_ILDasmDir>
<_ILDasmPath>$(_ILDasmDir)$(_ILDasmExe)</_ILDasmPath>
<_ILOutputPath>$(MSBuildProjectDirectory)/$(AssemblyName).il</_ILOutputPath>
</PropertyGroup>

<Error Condition="!Exists('$(_ILDasmPath)')"
Text="ILDasm not found at '$(_ILDasmPath)'. Run 'dotnet restore' and retry." />

<Exec Command="&quot;$(_ILDasmPath)&quot; /utf8 &quot;$(TargetPath)&quot; /out=&quot;$(_ILOutputPath)&quot;" />
<Message Importance="high" Text="IL disassembled to $(_ILOutputPath)" />
</Target>

</Project>
200 changes: 200 additions & 0 deletions docs/samples/runtime-async-library/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
## Runtime Async CE Library Sample

This sample demonstrates a `runtimeTask` computation expression (CE) defined in a library project and consumed by a separate app project. The key design insight is that **`Run` is non-inline with `[<MethodImplAttribute(0x2000)>]`** — the compiler emits it as `cil managed async` directly, and CE body closures are also `cil managed async` (because they contain `AsyncHelpers.Await` calls from inlined `Bind` members).

It is wired to the repo-built compiler so runtime-async IL is emitted end-to-end.

### Projects

- `RuntimeAsync.Library`: defines `RuntimeTaskBuilder` and task-returning library APIs using `runtimeTask`, plus `SimpleAsyncResource` (IAsyncDisposable) and `AsyncRange` (IAsyncEnumerable) helper types
- `RuntimeAsync.Demo`: references the library and runs all 12 example scenarios

### Key Design

The working solution uses a **non-inline Run + async closures** pattern:

#### RuntimeAsync Attribute

`[<RuntimeAsync>]` on the builder class is the single entry point for all runtime-async compiler behavior:

- It implicitly applies `NoDynamicInvocation` to all public inline members, so no explicit `[<NoDynamicInvocation>]` is needed on `Bind`, `Using`, or `For`.
- It gates the optimizer anti-inlining behavior (Fix 2 below).

```fsharp
[<RuntimeAsync; Sealed>]
type RuntimeTaskBuilder() =
// Delay wraps the CE body in a closure that is 'cil managed async'.
// The compiler automatically injects the sentinel (AsyncHelpers.Await(ValueTask.CompletedTask))
// into every Delay closure body, ensuring cloIsAsync = true even with no let!/do! bindings.
// The compiler also handles 'T → Task<'T> bridging automatically for [<RuntimeAsync>] builders,
// so no cast helper is needed.
member inline _.Delay([<InlineIfLambda>] f: unit -> 'T) : unit -> Task<'T> =
fun () -> f()

// Run is non-inline with [<MethodImplAttribute(0x2000)>] — emitted as 'cil managed async'.
// Delay closure returns Task<'T> at runtime (the 'cil managed async' runtime wraps T→Task<T>).
// Run awaits the closure result, then wraps T→Task<T> (because Run itself is 'cil managed async').
[<MethodImplAttribute(enum<MethodImplOptions> 0x2000)>]
member _.Run(f: unit -> Task<'T>) : Task<'T> =
AsyncHelpers.Await(f())

// Bind members — NoDynamicInvocation is implicit from [<RuntimeAsync>] on the type.
member inline _.Bind(t: Task<'T>, [<InlineIfLambda>] f: 'T -> 'U) : 'U =
f(AsyncHelpers.Await t)
// ... overloads for Task, ValueTask<'T>, ValueTask,
// ConfiguredTaskAwaitable, ConfiguredTaskAwaitable<'T>,
// ConfiguredValueTaskAwaitable, ConfiguredValueTaskAwaitable<'T>

// IAsyncDisposable and IAsyncEnumerable as intrinsic members
// (higher priority than IDisposable/seq extensions)
member inline this.Using(resource: 'T when 'T :> IAsyncDisposable, body: 'T -> 'U) : 'U = ...
member inline _.For(sequence: IAsyncEnumerable<'T>, body: 'T -> unit) : unit = ...

// Extension (lower priority): generic Bind for any awaitable via SRTP + UnsafeAwaitAwaiter
type RuntimeTaskBuilder with
member inline _.Bind(awaitable: ^Awaitable, f: ^TResult -> 'U) : 'U
when ^Awaitable : (member GetAwaiter: unit -> ^Awaiter)
and ^Awaiter :> ICriticalNotifyCompletion = ...
```

Consumer API functions use `runtimeTask { ... }` with **no attribute**:

```fsharp
// No [<MethodImplAttribute>] needed here — consumer just calls Run and returns the Task<T>.
let addFromTaskAndValueTask (left: Task<int>) (right: ValueTask<int>) : Task<int> =
runtimeTask {
let! l = left
let! r = right
return l + r
}
```

The consumer function calls `Run(closure)` and returns the `Task<int>` that `Run` returns. The consumer itself is NOT `cil managed async` — only `Run` and the CE body closures are.

### How It Works (Technical Details)

#### The Non-Inline Run Pattern

1. `Run` is `member _` (non-inline) with `[<MethodImplAttribute(0x2000)>]` — the compiler emits it as `cil managed async`
2. CE body closures contain `AsyncHelpers.Await` calls (from inlined `Bind` members, or the auto-injected sentinel) — they are also `cil managed async`
3. At runtime, the closure's `Invoke` returns `Task<'T>` (because it's `cil managed async` — the runtime wraps `'T → Task<'T>` automatically)
4. The compiler handles `'T → Task<'T>` bridging automatically for `[<RuntimeAsync>]` builders — no `cast` helper is needed
5. `AsyncHelpers.Await(Task<'T>)` unwraps `Task<'T>` to `'T`
6. `Run` wraps `'T` back to `Task<'T>` (because it's `cil managed async`)

#### True Inline-Nested CEs

Because `Run` is non-inline and returns a real `Task<T>`, `runtimeTask { ... }` CEs can be nested directly inside each other:

```fsharp
let trueInlineNestedRuntimeTask () : Task<int> =
runtimeTask {
let! a =
runtimeTask {
return 21
}
let! b =
runtimeTask {
return 21
}
return a + b // 42
}
```

The inner `runtimeTask { return 21 }` calls `Run` which returns a real `Task<int>`. The outer `Bind` calls `AsyncHelpers.Await<int>(Task<int>)` → gets 21. This works because `Run` is a real `cil managed async` method, not an inlined cast.

#### Three Required Compiler Fixes

**Fix 1 — IlxGen.fs return-type guard:** `ExprContainsAsyncHelpersAwaitCall` body analysis must only propagate `cil managed async` when the method returns a Task-like type (`Task`, `Task<T>`, `ValueTask`, `ValueTask<T>`). Without this guard, the optimizer might inline an async function into a non-Task-returning method (e.g., `main : int`), and the runtime would reject it with `TypeLoadException`.

**Fix 2 — Optimizer.fs anti-inlining guard:** Functions whose optimized bodies contain `AsyncHelpers.Await`/`AwaitAwaiter`/`UnsafeAwaitAwaiter` calls must not be cross-module inlined by the optimizer. Their optimization data is replaced with `UnknownValue`. Without this, the optimizer inlines async functions into non-async callers, causing `NullReferenceException` at runtime.

**Fix 3 — EraseClosures.fs async closure emission:** CE body closures contain `AsyncHelpers.Await` calls (from inlined `Bind` members, or the auto-injected sentinel). The `cloIsAsync` field in `IlxClosureInfo` is set when the closure body contains these calls. `EraseClosures.fs` emits the closure's `Invoke` method as `cil managed async` when `cloIsAsync = true`. Without this, the runtime rejects the closure with `TypeLoadException` because `AsyncHelpers.Await` can only be called from `cil managed async` methods.

**Fix 4 — CheckExpressions.fs type-checking coercion:** When inside an inline member of a `[<RuntimeAsync>]` type, the compiler allows `fun () -> f()` where `f()` returns `'T` but the closure's declared return type is `Task<'T>`. The compiler unwraps the Task-like return type for the lambda body, so the library author writes `fun () -> f()` without any cast helper.

**Fix 5 — IlxGen.fs Lambdas_return fix:** When the closure's declared return type is `Task<'T>` but the body type is `'T` (due to the coercion in Fix 4), the IL generator uses the declared `Task<'T>` for `Lambdas_return` so the closure's `Invoke` method declares the correct return type in IL.

**Fix 6 — CheckComputationExpressions.fs automatic sentinel injection:** When the builder type has `[<RuntimeAsync>]`, the CE desugaring automatically injects `AsyncHelpers.Await(ValueTask.CompletedTask)` as the first expression in ALL `Delay` closure bodies. This ensures `cloIsAsync = true` even when the CE body has no `let!`/`do!` bindings (e.g., `runtimeTask { return 42 }`), so the closure is always emitted as `cil managed async`.

### Examples

The sample includes 12 examples in `Api.fs`:

| Example | Demonstrates |
|---|---|
| `addFromTaskAndValueTask` | Binding `Task<T>` and `ValueTask<T>` |
| `bindUnitTaskAndUnitValueTask` | Binding unit `Task` and unit `ValueTask` via `do!` |
| `safeDivide` | `try/with` inside runtimeTask |
| `nestedRuntimeTask` | Composing runtimeTask functions |
| `deeplyNestedRuntimeTask` | 3-level deep nesting via helper functions |
| `consumeOlderTaskCE` | Consuming standard `task { }` CE results |
| `taskDelayYieldAndRun` | `Task.Delay`, `Task.Yield()` (generic awaitable), `Task.Run` |
| `useAsyncDisposable` | `use` with `IAsyncDisposable` resource |
| `iterateAsyncEnumerable` | `for` over `IAsyncEnumerable<T>` |
| `configureAwaitExample` | `.ConfigureAwait(false)` on Task and Task<T> |
| `inlineNestedRuntimeTask` | Nesting runtimeTask CEs via separate functions |
| `trueInlineNestedRuntimeTask` | True inline-nested runtimeTask CEs (enabled by non-inline Run) |

### Prerequisites

- .NET 10 SDK
- F# preview language enabled (already set in each project)
- .NET SDK restore access (normal `dotnet run` prerequisites)
- `DOTNET_RuntimeAsync=1` set before launching the process (required for loading runtime-async methods)

### Build

```bash
dotnet build docs/samples/runtime-async-library/RuntimeAsync.Demo/RuntimeAsync.Demo.fsproj -c Release
```

### Run

**Linux/macOS:**
```bash
DOTNET_RuntimeAsync=1 dotnet run --project docs/samples/runtime-async-library/RuntimeAsync.Demo/RuntimeAsync.Demo.fsproj -c Release
```

**Windows (PowerShell):**
```powershell
$env:DOTNET_RuntimeAsync = "1"
dotnet run --project docs/samples/runtime-async-library/RuntimeAsync.Demo/RuntimeAsync.Demo.fsproj -c Release
```

### Expected Output

```
Task<T> + ValueTask<T> -> 15
Task + ValueTask -> completed
try/with -> 0
nested runtimeTask -> 44
deeply nested runtimeTask -> 100
consume older task CE -> 84
Task.Delay + Task.Yield + Task.Run -> 42
IAsyncDisposable -> async resource used
IAsyncEnumerable sum -> 15
ConfigureAwait(false) -> 99
inline-nested runtimeTask -> 40
true inline-nested runtimeTask -> 42
```

### IL Verification

Both projects have an `ILDasm.targets` file that runs ILDasm after build, producing `.il` files in their respective output directories.

To verify manually:

```bash
# Build the library
dotnet build docs/samples/runtime-async-library/RuntimeAsync.Library/RuntimeAsync.Library.fsproj -c Release
```

In the output IL:

- `RuntimeTaskBuilder::Run` → should show `cil managed async` (non-inline, has `[<MethodImplAttribute(0x2000)>]`)
- CE body closures (e.g., `addFromTaskAndValueTask@57`) → `Invoke` method should show `cil managed async` (contains `AsyncHelpers.Await` calls)
- `Api::*` consumer functions → should show `cil managed` (NOT `cil managed async` — they just call `Run` and return the `Task<T>`)
- `Program::main` → should show `cil managed` (NOT `cil managed async`)

> **Note:** Running without `DOTNET_RuntimeAsync=1` fails with `TypeLoadException` because runtime-async methods are not enabled for that process.
32 changes: 32 additions & 0 deletions docs/samples/runtime-async-library/RuntimeAsync.Demo/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
open System.Threading.Tasks
open RuntimeAsync.Library

[<EntryPoint>]
let main _ =
let fromTaskLike = (Api.addFromTaskAndValueTask (Task.FromResult 10) (ValueTask<int>(5))).Result
let fromUnitTasks = (Api.bindUnitTaskAndUnitValueTask ()).Result
let fromTryWith = (Api.safeDivide 10 0).Result
let fromNested = (Api.nestedRuntimeTask ()).Result
let fromDeepNested = (Api.deeplyNestedRuntimeTask ()).Result
let fromOlderCE = (Api.consumeOlderTaskCE ()).Result
let fromDelayYieldRun = (Api.taskDelayYieldAndRun ()).Result
let fromAsyncDisposable = (Api.useAsyncDisposable ()).Result
let fromAsyncEnumerable = (Api.iterateAsyncEnumerable ()).Result
let fromConfigureAwait = (Api.configureAwaitExample ()).Result
let fromInlineNested = (Api.inlineNestedRuntimeTask ()).Result
let fromTrueInlineNested = (Api.trueInlineNestedRuntimeTask ()).Result

printfn "Task<T> + ValueTask<T> -> %d" fromTaskLike
printfn "Task + ValueTask -> %s" fromUnitTasks
printfn "try/with -> %d" fromTryWith
printfn "nested runtimeTask -> %d" fromNested
printfn "deeply nested runtimeTask -> %d" fromDeepNested
printfn "consume older task CE -> %d" fromOlderCE
printfn "Task.Delay + Task.Yield + Task.Run -> %d" fromDelayYieldRun
printfn "IAsyncDisposable -> %s" fromAsyncDisposable
printfn "IAsyncEnumerable sum -> %d" fromAsyncEnumerable
printfn "ConfigureAwait(false) -> %d" fromConfigureAwait
printfn "inline-nested runtimeTask -> %d" fromInlineNested
printfn "true inline-nested runtimeTask -> %d" fromTrueInlineNested

0
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<FscToolPath>$(MSBuildThisFileDirectory)..\..\..\..\artifacts\bin\fsc\Release\net10.0\</FscToolPath>
<FscToolExe>fsc.exe</FscToolExe>
<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
</PropertyGroup>

<ItemGroup>
<Reference Include="$(MSBuildThisFileDirectory)..\..\..\..\artifacts\bin\FSharp.Core\Release\netstandard2.0\FSharp.Core.dll" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\RuntimeAsync.Library\RuntimeAsync.Library.fsproj" />
</ItemGroup>

<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>

<Import Project="..\ILDasm.targets" />
</Project>
Loading
Loading