Skip to content

RpcClient.InvokeCoreAsync doesn't honor CancellationToken #82814

@grokys

Description

@grokys

Version Used: 4.12, but seems to be present in main too

Steps to Reproduce:

  1. Have a solution which takes a long time to load
  2. Pass a cancellation token to MSBuildWorkspace.OpenSolutionAsync
  3. Try to cancel the load

The issue is that RpcClient.InvokeCoreAsync does not respect the passed cancellation token when doing the final await.

Expected Behavior:

The operation is cancelled.

Actual Behavior:

The operation is not cancelled.

Machine-generated report contains more information # Bug Report: RpcClient.InvokeCoreAsync doesn't honor CancellationToken when waiting for response

Summary

The RpcClient.InvokeCoreAsync method in Microsoft.CodeAnalysis.MSBuild doesn't honor the provided CancellationToken when waiting for the server response, causing operations to hang indefinitely even when cancelled.

Location

  • File: src/Workspaces/Core/MSBuild/Rpc/RpcClient.cs
  • Method: private async Task<object?> InvokeCoreAsync(...)
  • Line: ~150 (final return await statement)

Problem Description

The InvokeCoreAsync method correctly handles cancellation during the sending phase of an RPC request (lines ~139-147), but completely ignores the cancellationToken when waiting for the response (line ~150):

return await requestCompletionSource.Task.ConfigureAwait(continueOnCapturedContext: false);

This causes the following issues:

  1. If the server is slow or unresponsive, the operation hangs indefinitely
  2. When a caller cancels the operation (e.g., via timeout), the cancellation is ignored
  3. The requestCompletionSource.Task will never complete unless the server responds or disconnects

Reproduction Steps

  1. Call MSBuildWorkspace.OpenSolutionAsync() with a CancellationToken
  2. Have the MSBuild server process be slow or hang during solution loading
  3. Cancel the CancellationToken (e.g., via CancellationTokenSource.CancelAfter())
  4. Observe that the operation does not cancel and continues waiting

Expected Behavior

When the cancellationToken is cancelled, the InvokeCoreAsync method should:

  1. Cancel the pending request
  2. Remove the request from _outstandingRequests
  3. Throw OperationCanceledException

Actual Behavior

The operation continues waiting indefinitely for the server response, ignoring the cancellation request.

Proposed Fix

Option 1: Use Task.WaitAsync (Recommended for .NET 6+)

return await requestCompletionSource.Task.WaitAsync(cancellationToken)
    .ConfigureAwait(continueOnCapturedContext: false);

Option 2: Register cancellation callback

using (cancellationToken.Register(() =>
{
    _outstandingRequests.TryRemove(requestId, out _);
    requestCompletionSource.TrySetCanceled(cancellationToken);
}))
{
    return await requestCompletionSource.Task.ConfigureAwait(continueOnCapturedContext: false);
}

Impact

This bug affects any code that:

  • Uses MSBuildWorkspace.OpenSolutionAsync() or OpenProjectAsync() with timeouts
  • Needs to cancel long-running MSBuild operations
  • Relies on cancellation tokens for resource cleanup or timeout enforcement

Workaround

Callers can work around this by using Task.WhenAny to enforce their own timeout:

var openTask = workspace.OpenSolutionAsync(solutionPath, cancellationToken);
var timeoutTask = Task.Delay(timeout, CancellationToken.None);

if (await Task.WhenAny(openTask, timeoutTask) == timeoutTask)
{
    workspace.Dispose();
    throw new TimeoutException("Operation timed out");
}

await openTask; // Propagate any exceptions

Additional Context

  • Found while debugging integration tests that use MSBuildWorkspace with 60-second timeouts
  • The cancellation token shows IsCancellationRequested = true in the debugger, but the await continues indefinitely
  • This is a correctness issue with async/await patterns where cancellation tokens should be honored throughout the entire async operation

Related Code Paths

All public methods that call InvokeCoreAsync are affected:

  • InvokeAsync(int targetObject, ...)
  • InvokeNullableAsync<T>(...)
  • InvokeAsync<T>(...)

All of these ultimately call InvokeCoreAsync which has the bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions