Skip to content

[browser][wasi] Allow uncontended synchronous waits - #135335

Open
pavelsavara wants to merge 3 commits into
dotnet:mainfrom
pavelsavara:single-threaded-uncontended-waits
Open

pavelsavara wants to merge 3 commits into
dotnet:mainfrom
pavelsavara:single-threaded-uncontended-waits

Conversation

@pavelsavara

@pavelsavara pavelsavara commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Summary

  • allow synchronous waits to complete when a semaphore count, task completion, or set state is already available
  • return the normal result for unavailable zero-timeout waits
  • throw PlatformNotSupportedException only when a synchronous wait would actually block on a runtime without multithreading
  • apply the behavior consistently to SemaphoreSlim, ManualResetEventSlim, tasks, and monitors
  • leave Thread.Sleep and WaitHandle behavior unchanged
  • preserve the original Task wait setup and continuation paths on multithreaded targets

The APIs remain annotated as unsupported on Browser. The compatibility warning is still necessary because whether a synchronous wait blocks depends on runtime state.

Behavior comparison

The following table compares single-threaded Browser behavior:

Scenario .NET 10, before #123329 #123329 / current behavior This change
Available SemaphoreSlim count Acquires successfully Throws PNSE Acquires successfully
Set ManualResetEventSlim Returns immediately Throws PNSE Returns immediately
Completed Task.Wait() Returns immediately Returns immediately Returns immediately
Unavailable SemaphoreSlim.Wait(0) Returns false Returns false Returns false
Unset ManualResetEventSlim.Wait(0) Returns false Throws PNSE Returns false
Incomplete Task.Wait(0) / WaitAll(..., 0) / WaitAny(..., 0) Returns the normal timeout result Throws PNSE Returns the normal timeout result
Monitor.Wait(obj, 0) Returns false after releasing and reacquiring the monitor Throws PNSE Returns false after releasing and reacquiring the monitor
Unsatisfied SemaphoreSlim or ManualResetEventSlim with positive/infinite timeout Synchronously waits and can freeze or deadlock the event loop Throws PNSE Throws PNSE at the actual blocking path
WaitHandle.WaitOne, WaitAny, WaitAll, and SignalAndWait Existing timeout and signal behavior Unchanged by #123329 Unchanged

WASI already threw PNSE unconditionally for several synchronous waits before #123329. This change allows its immediately satisfiable and zero-timeout cases while retaining PNSE for actual blocking.

Motivation

The fail-fast behavior from #123329 prevents browser event-loop deadlocks, but it also rejects waits that can be proven not to block. This prevents otherwise single-thread-compatible libraries from using synchronous wrappers around uncontended synchronization objects.

The updated behavior follows the compatibility-analyzer model discussed in dotnet/roslyn#85869: the APIs remain marked unsupported, while reviewed call sites can work when their state guarantees immediate completion.

Testing

  • .\build.cmd -bl -os browser -subset clr+libs+host -c Debug
    • Passed with 0 warnings and 0 errors.
  • .\build.cmd -bl -os browser -subset clr.corelib+clr.nativecorelib+libs.pretest -c Debug /p:RuntimeFlavor=CoreCLR
    • Passed with 0 warnings and 0 errors.
  • .\dotnet.cmd build -bl /p:TargetOS=browser /p:TargetArchitecture=wasm /p:Configuration=Debug /p:RuntimeFlavor=CoreCLR /t:Test /p:Scenario=WasmTestOnV8 .\src\libraries\System.Threading\tests\System.Threading.Tests.csproj
    • 640 run, 552 passed, 88 skipped, 0 failed.
  • Focused Browser/V8 task wait regression:
    • 1 passed, 0 failed.
  • Windows x64 Debug CoreLib rebuild:
    • Passed with 0 warnings and 0 errors.
  • Windows x64 Debug System.Threading.Tasks.Tests.TaskRtTests_Core:
    • 37 total, 36 passed, 1 single-thread-only skip, 0 failed.
  • Focused Browser/V8 tests matching the first CI failure round:
    • Microsoft.Extensions.Caching.Memory.TokenExpirationTests.TokenExpiresOnRegister: 1 passed.
    • System.Threading.Tests.TimerFiringTests.Timer_FiresOnlyOnce_OnDueTime_With_InfinitePeriod: 1 passed.
    • System.Diagnostics.Tests.StopwatchTests: 6 passed.
  • git diff --check
    • Passed.

Direct Mono and WASI execution was not run.

Resolves #134972

Note

This pull request was prepared with assistance from GitHub Copilot.

Single-threaded runtimes cannot make progress once a synchronous wait
blocks, but immediately satisfiable and zero-timeout waits do not require
multithreading.

Move the platform guard to actual blocking paths for synchronization
objects and tasks, and cover the behavior on Browser.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 pipeline(s).
12 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @VSadov
See info in area-owners.md if you want to be subscribed.

@pavelsavara pavelsavara added arch-wasm WebAssembly architecture os-browser Browser variant of arch-wasm labels Oct 7, 2026
@pavelsavara pavelsavara added this to the 12.0.0 milestone Oct 7, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.

Keep WaitHandle timeout behavior unchanged. These APIs are supported on
Browser and dotnet#123329 explicitly removed their blocking guard.

Limit the conditional PNSE behavior to the synchronous APIs that already
throw on runtimes without multithreading.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@pavelsavara

Copy link
Copy Markdown
Member Author

@lewing please help me to get this finished and merged into Net11, so that we avoid behavior breaking change. Thanks

Comment thread src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs Outdated
Limit the zero-timeout Task shortcuts to runtimes without multithreading.
Multithreaded targets continue using the original blocking setup and
continuation paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
/// <param name="cancellationToken">The token.</param>
/// <returns>true if the task is completed; otherwise, false.</returns>
private bool SpinThenBlockingWait(int millisecondsTimeout, CancellationToken cancellationToken)
{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{
if (!RuntimeFeature.IsMultithreadingSupported)
{
if (IsCompleted)
{
return true;
}
if (millisecondsTimeout == 0)
{
return false;
}
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();
}

I think it would be easier to understand if the ST handling is in a separate block like this. It also a bit smaller since we avoid calling Environment.TickCount.

uint startTimeTicks = infiniteWait ? 0 : (uint)Environment.TickCount;
bool returnValue = SpinWait(millisecondsTimeout);
if (!returnValue)
if (!returnValue && (millisecondsTimeout != 0 || RuntimeFeature.IsMultithreadingSupported))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!returnValue && (millisecondsTimeout != 0 || RuntimeFeature.IsMultithreadingSupported))
if (!returnValue)

Comment on lines 3215 to 3216
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();

{
bool infiniteWait = millisecondsTimeout == Timeout.Infinite;
uint startTimeTicks = infiniteWait ? 0 : (uint)Environment.TickCount;
bool returnValue = SpinWait(millisecondsTimeout);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!RuntimeFeature.IsMultithreadingSupported) in SpinWait can be deleted or replaced by assert.

Comment on lines +5523 to +5525
if (signaledTaskIndex == -1 &&
tasks.Length != 0 &&
(millisecondsTimeout != 0 || RuntimeFeature.IsMultithreadingSupported))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (signaledTaskIndex == -1 &&
tasks.Length != 0 &&
(millisecondsTimeout != 0 || RuntimeFeature.IsMultithreadingSupported))
if (signaledTaskIndex == -1 && tasks.Length != 0)

tasks.Length != 0 &&
(millisecondsTimeout != 0 || RuntimeFeature.IsMultithreadingSupported))
{
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();
if (!RuntimeFeature.IsMultithreadingSupported)
{
if (millisecondsTimeout != 0)
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();
return -1;
}

Comment on lines +5176 to +5178
if (millisecondsTimeout != 0 || RuntimeFeature.IsMultithreadingSupported)
{
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (millisecondsTimeout != 0 || RuntimeFeature.IsMultithreadingSupported)
{
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();
if (!RuntimeFeature.IsMultithreadingSupported)
{
if (millisecondsTimeout != 0)
RuntimeFeature.ThrowIfMultithreadingIsNotSupported();
return false;
}
// Block waiting for the tasks to complete.
if (!WaitAllBlockingCore(waitedOnTaskList, millisecondsTimeout, cancellationToken))
{
return false;
}
  • delete returnValue from the method since it is always going to be true now

  • RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); in WaitAllBlockingCore can be deleted or replaced by assert

This branch has not been deployed

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

Labels

arch-wasm WebAssembly architecture area-System.Threading os-browser Browser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SemaphoreSlim.Wait throws on single-threaded runtimes even when the semaphore is available

3 participants