Skip to content

Avoid blocking SemaphoreSlim.Wait(0) on single-threaded browser WASM - #2756

Merged
JamesNK merged 1 commit into
grpc:masterfrom
pavelsavara:fix/2753-semaphore-wait-browser
Aug 11, 2026
Merged

JamesNK merged 1 commit into
grpc:masterfrom
pavelsavara:fix/2753-semaphore-wait-browser

Conversation

@pavelsavara

Copy link
Copy Markdown
Contributor

Fixes #2753.
Related runtime issue: dotnet/runtime#131859.

Problem

On .NET 11 preview single-threaded browser-wasm (Blazor WebAssembly), every
gRPC-Web call made through GrpcChannel hangs forever: the returned task never
completes, no HTTP request is issued, and no exception surfaces anywhere.

The root cause is a synchronous, non-blocking try-acquire in the balancer:

// src/Grpc.Net.Client/Balancer/Subchannel.cs — ConnectTransportAsync
if (!_connectSemaphore.Wait(0))
{
    ...
}

A runtime change (dotnet/runtime#123329)
makes SemaphoreSlim.WaitCore throw PlatformNotSupportedException on
single-threaded WASM for any potentially-blocking wait. The multithreading guard
sits ahead of the immediate-acquire path, and only the fail-fast branch
(timeout == 0 && count == 0) escapes it. So Wait(0) against an available
semaphore — an operation that by definition cannot block — walks into the guard
and throws:

System.PlatformNotSupportedException: Arg_PlatformNotSupported
  at System.Runtime.CompilerServices.RuntimeFeature.ThrowIfMultithreadingIsNotSupported()
  at System.Threading.SemaphoreSlim.WaitCore(Int64 millisecondsTimeout, CancellationToken cancellationToken)
  at System.Threading.SemaphoreSlim.Wait(Int32 millisecondsTimeout)
  at Grpc.Net.Client.Balancer.Subchannel.ConnectTransportAsync()

Because the connect runs as a fire-and-forget task (_ = RunCall(...)), the
exception never resolves the call's response, so the caller's await stays
pending forever with no observable error.

The balancer path is active even for a plain
GrpcChannel.ForAddress("https://…", new GrpcChannelOptions { HttpHandler = new GrpcWebHandler(...) })
channel, because BalancerHttpHandler wraps the user handler chain when
SUPPORT_LOAD_BALANCING is compiled in — which it is for the TFM build Blazor
WebAssembly serves.

Fix

Replace the blocking Wait(0) with WaitAsync(millisecondsTimeout: 0):

var acquireSemaphoreTask = _connectSemaphore.WaitAsync(millisecondsTimeout: 0);
if (!acquireSemaphoreTask.Result)
{
    SubchannelLog.QueuingConnect(_logger, Id);
    waitSemaporeTask = _connectSemaphore.WaitAsync(connectContext.CancellationToken);
}

SemaphoreSlim.WaitAsync never blocks a thread, so it does not go through the
ThrowIfMultithreadingIsNotSupported guard. With a zero timeout it always
completes synchronously, returning a cached completed task whose Result
reports whether the semaphore was acquired. This preserves the exact original
semantics — try-acquire without waiting, and otherwise queue a full async wait —
while working correctly on single-threaded browser WASM.

Testing

  • Grpc.Net.Client builds cleanly across all target frameworks.
  • Grpc.Net.Client.Tests on net10.0: full suite passes (510/510), including
    the Balancer/Subchannel connect tests (62/62).

@JamesNK
JamesNK merged commit b9104b1 into grpc:master Aug 11, 2026
3 checks passed
This was referenced Sep 21, 2026
This was referenced Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants