Skip to content

The vsintegration cancellableTask computation expression cannot bind ValueTask #20596

Description

@xperiandri

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions