Is your feature request related to a problem? Please describe.
vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs defines the cancellableTask / foregroundCancellableTask builder that ~260 blocks across FSharp.Editor are written in. It has explicit Source overloads for Task<'T>, unit -> Task<'T>, CancellableTask<'T>, Async<'T> and TaskAwaiter<'T> — and no mention of ValueTask anywhere in the file.
Consequences today:
-
Binding a ValueTask<'T> requires converting it, allocating a Task for no reason. The only such call site in the repo, Copilot/CopilotContextProvider.fs:439:
use! rental = documentContexts.GetProxyAsync<ICopilotDocumentContextProvider>(CopilotDescriptors.Context.Document, ct).AsTask()
The same ServiceBrokerClient.GetProxyAsync call binds with a bare let! in LanguageService/LanguageService.fs:552, because that block is FSharp.Core's task { }, whose LowPriority.Bind<^TaskLike, …> awaits any structural awaitable directly (src/FSharp.Core/tasks.fs:317-363). The gap is specific to cancellableTask.
-
and! does not work for anything that reaches the builder as a bare awaiter. MergeSources (line 1201) accepts only CancellationToken -> 'Awaiter, while the generic Source(task: 'Awaitable) (line 702) yields a bare 'Awaiter. So and! is unavailable for ValueTask<'T>, ValueTask, non-generic Task (which has no Source overload of its own either), ConfiguredTaskAwaitable<'T> and any other GetAwaiter-shaped type. It has simply never been noticed: and! is used nowhere in vsintegration.
-
CancellableTask.getCancellationToken () (line 962) is typed CancellationToken -> Task<CancellationToken> and implemented as Task.FromResult ct. Every one of its ~100 call sites across 48 files is let! ct = CancellableTask.getCancellationToken (), so the editor allocates a Task per computation for a value it already holds.
-
There is no async-disposal path (Using is IDisposable-only, line 750; DisposeAsync returns ValueTask) and no for … in over IAsyncEnumerable<'T> (MoveNextAsync returns ValueTask<bool>).
Describe the solution you'd like
Bind ValueTask natively — through its own ValueTaskAwaiter, never via .AsTask() — in every position the CE supports: let!, do!, match!, while!, and!, return!, use!, for.
Source overloads for ValueTask<'T>, ValueTask, unit -> ValueTask<'T> and CancellationToken -> ValueTask<'T>, in HighPriority, each returning the CancellationToken -> 'Awaiter function shape the existing Task overloads use (lines 880-897). That shape is what MergeSources needs, so it fixes and! at the same time; Source is inline and Bind's getAwaiter is [<InlineIfLambda>], so the lambda is inlined away and nothing is allocated. Adding Source(task: Task) alongside closes the same and! hole for non-generic Task.
getCancellationToken returns CancellationToken -> ValueTask<CancellationToken> (ValueTask<CancellationToken>(ct), allocation-free, still takes the synchronous IsCompleted shortcut in Bind). No call site changes.
TryFinallyAsync + an IAsyncDisposable Using as intrinsic members, so it outranks the extension IDisposable one exactly as FSharp.Core arranges it (src/FSharp.Core/tasks.fs:91-140 vs tasks.fs:352-361).
For over IAsyncEnumerable<'T>, over a WhileAsync whose guard awaits MoveNextAsync()'s ValueTask<bool>.
while! needs no new builder member: the compiler desugars it into let! + a plain while (src/Compiler/Checking/Expressions/CheckComputationExpressions.fs:1528-1628), so it starts working as soon as the guard's ValueTask<bool> binds.
There is no test for this CE anywhere in the repo today, so the change should come with one covering each of the above.
Describe alternatives you've considered
- Keep
.AsTask() at the call sites. Allocates a Task per call purely to satisfy the builder, and leaves and! broken.
- Rely on the existing generic
Source(task: 'Awaitable) (line 702). Even where it resolves, it produces a bare awaiter, which MergeSources rejects — so and! stays broken and the behaviour differs from every other bindable type.
- Route through FSharp.Core's
Task.ofValueTask / ValueTask module. Not available: both are inside #if NETSTANDARD2_1 || NET (src/FSharp.Core/tasks.fs:872-960), and FSharp.Editor is net472, which resolves FSharp.Core's netstandard2.0 asset.
- Make the builder produce
ValueTask as well. Out of scope; this is only about consuming them.
Additional context
FSharp.Editor targets net472 only (vsintegration/Directory.Build.props:4). Everything needed is already a compile-time reference, transitively, with no new PackageReference: ValueTask, ValueTask<'T>, ValueTaskAwaiter(<'T>) and IValueTaskSource<'T> from System.Threading.Tasks.Extensions/4.6.3 (lib/net462), and IAsyncDisposable / IAsyncEnumerable<'T> from Microsoft.Bcl.AsyncInterfaces/10.0.10 (lib/net462).
One net472 caveat for the implementation: the ValueTask.FromResult / ValueTask.CompletedTask / ValueTask.FromCanceled statics are .NET 5+ and do not exist there — the constructors ValueTask<'T>(v) and ValueTask() are what CopilotContextProvider.fs:563-569 already uses.
The file is derived from IcedTasks, which has the same gap upstream.
Is your feature request related to a problem? Please describe.
vsintegration/src/FSharp.Editor/Common/CancellableTasks.fsdefines thecancellableTask/foregroundCancellableTaskbuilder that ~260 blocks acrossFSharp.Editorare written in. It has explicitSourceoverloads forTask<'T>,unit -> Task<'T>,CancellableTask<'T>,Async<'T>andTaskAwaiter<'T>— and no mention ofValueTaskanywhere in the file.Consequences today:
Binding a
ValueTask<'T>requires converting it, allocating aTaskfor no reason. The only such call site in the repo,Copilot/CopilotContextProvider.fs:439:The same
ServiceBrokerClient.GetProxyAsynccall binds with a barelet!inLanguageService/LanguageService.fs:552, because that block is FSharp.Core'stask { }, whoseLowPriority.Bind<^TaskLike, …>awaits any structural awaitable directly (src/FSharp.Core/tasks.fs:317-363). The gap is specific tocancellableTask.and!does not work for anything that reaches the builder as a bare awaiter.MergeSources(line 1201) accepts onlyCancellationToken -> 'Awaiter, while the genericSource(task: 'Awaitable)(line 702) yields a bare'Awaiter. Soand!is unavailable forValueTask<'T>,ValueTask, non-genericTask(which has noSourceoverload of its own either),ConfiguredTaskAwaitable<'T>and any otherGetAwaiter-shaped type. It has simply never been noticed:and!is used nowhere invsintegration.CancellableTask.getCancellationToken ()(line 962) is typedCancellationToken -> Task<CancellationToken>and implemented asTask.FromResult ct. Every one of its ~100 call sites across 48 files islet! ct = CancellableTask.getCancellationToken (), so the editor allocates aTaskper computation for a value it already holds.There is no async-disposal path (
UsingisIDisposable-only, line 750;DisposeAsyncreturnsValueTask) and nofor … inoverIAsyncEnumerable<'T>(MoveNextAsyncreturnsValueTask<bool>).Describe the solution you'd like
Bind
ValueTasknatively — through its ownValueTaskAwaiter, never via.AsTask()— in every position the CE supports:let!,do!,match!,while!,and!,return!,use!,for.Sourceoverloads forValueTask<'T>,ValueTask,unit -> ValueTask<'T>andCancellationToken -> ValueTask<'T>, inHighPriority, each returning theCancellationToken -> 'Awaiterfunction shape the existingTaskoverloads use (lines 880-897). That shape is whatMergeSourcesneeds, so it fixesand!at the same time;SourceisinlineandBind'sgetAwaiteris[<InlineIfLambda>], so the lambda is inlined away and nothing is allocated. AddingSource(task: Task)alongside closes the sameand!hole for non-genericTask.getCancellationTokenreturnsCancellationToken -> ValueTask<CancellationToken>(ValueTask<CancellationToken>(ct), allocation-free, still takes the synchronousIsCompletedshortcut inBind). No call site changes.TryFinallyAsync+ anIAsyncDisposableUsingas intrinsic members, so it outranks the extensionIDisposableone exactly as FSharp.Core arranges it (src/FSharp.Core/tasks.fs:91-140vstasks.fs:352-361).ForoverIAsyncEnumerable<'T>, over aWhileAsyncwhose guard awaitsMoveNextAsync()'sValueTask<bool>.while!needs no new builder member: the compiler desugars it intolet!+ a plainwhile(src/Compiler/Checking/Expressions/CheckComputationExpressions.fs:1528-1628), so it starts working as soon as the guard'sValueTask<bool>binds.There is no test for this CE anywhere in the repo today, so the change should come with one covering each of the above.
Describe alternatives you've considered
.AsTask()at the call sites. Allocates aTaskper call purely to satisfy the builder, and leavesand!broken.Source(task: 'Awaitable)(line 702). Even where it resolves, it produces a bare awaiter, whichMergeSourcesrejects — soand!stays broken and the behaviour differs from every other bindable type.Task.ofValueTask/ValueTaskmodule. Not available: both are inside#if NETSTANDARD2_1 || NET(src/FSharp.Core/tasks.fs:872-960), andFSharp.Editoris net472, which resolves FSharp.Core's netstandard2.0 asset.ValueTaskas well. Out of scope; this is only about consuming them.Additional context
FSharp.Editortargets net472 only (vsintegration/Directory.Build.props:4). Everything needed is already a compile-time reference, transitively, with no newPackageReference:ValueTask,ValueTask<'T>,ValueTaskAwaiter(<'T>)andIValueTaskSource<'T>fromSystem.Threading.Tasks.Extensions/4.6.3(lib/net462), andIAsyncDisposable/IAsyncEnumerable<'T>fromMicrosoft.Bcl.AsyncInterfaces/10.0.10(lib/net462).One net472 caveat for the implementation: the
ValueTask.FromResult/ValueTask.CompletedTask/ValueTask.FromCanceledstatics are .NET 5+ and do not exist there — the constructorsValueTask<'T>(v)andValueTask()are whatCopilotContextProvider.fs:563-569already uses.The file is derived from IcedTasks, which has the same gap upstream.