dotnet / dotnet/runtime

.NET WASM multithreading: SynchronizationLockException corruption / Timers freezing

Open
#133,984 3 comments 0 reactions 0 assignees View on GitHub
arch-wasm area-System.Threading untriaged
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Description

Ordinary timer load with `WasmEnableThreads=true` leads to a `SynchronizationLockException` being thrown on net10 and timers not firing on net11.

### Reproduction Steps

Create this minimal C# repro project, run with `dotnet run -c Release` and simply open the home page:

`Program.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices.JavaScript;
using System.Threading;
using System.Threading.Tasks;

// .NET WASM multithreading: one timer workload, two different failures.
//
// net10.0 timers fire, but corrupt TimerQueue's internal System.Threading.Lock
// -> SynchronizationLockException / LockRecursionException
// net11.0 timers never fire at all -> silence, no exception
//
// The source is identical for both; only the target framework differs.

var timers = new List();

for (var i = 0; i < 60; i++)
{
timers.Add(new Timer(_ => Interlocked.Increment(ref Repro.TimerFires), null, 5 + i, 3));
}

for (var i = 0; i < 100; i++)
{
_ = Task.Run(async () =>
{
for (var j = 0; j < 400; j++)
{
await Task.Delay(1);
Interlocked.Increment(ref Repro.DelayCompletions);
}
});
}

AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
Interlocked.Increment(ref Repro.Unhandled);
if (e.ExceptionObject is Exception ex)
{
Repro.LastError = ex.ToString();
}
};

// Drive the display off a plain thread, not Task.Delay: the timer subsystem is
// the thing under test, so it must not be what refreshes the screen. On net11
// this is also what proves the managed side is alive while timers are dead.
try
{
var renderer = new Thread(() =>
{
while (true) { Repro.Render(); Thread.Sleep(500); }
});
renderer.IsBackground = true;
renderer.Start();
}
catch (PlatformNotSupportedException)
{
// Single-threaded wasm has no threads at all. Timers work correctly there,
// so driving the display from one is safe in that configuration.
_ = Task.Run(async () =>
{
while (true) { Repro.Render(); await Task.Delay(500); }
});
}

await new TaskCompletionSource().Task;

public static partial class Repro
{
public static int TimerFires, DelayCompletions, Unhandled;
public static volatile string LastError = "(none yet)";
private static int _renders;

[JSImport("globalThis.eval")]
private static partial void Eval(string js);

public static void Render() => Show(
$"target framework = {(Environment.Version.Major >= 11 ? "net11.0" : "net10.0")}\n" +
$"renders = {Interlocked.Increment(ref _renders)}\n" +
$"timerFires = {TimerFires}\n" +
$"delayCompletions = {DelayCompletions}\n" +
$"unhandledErrors = {Unhandled}\n" +
$"\nlast unhandled exception:\n{LastError}");

// Creates the

 on first call, then fills it. A backtick literal is used

// because the stack trace spans lines; backticks and ${ in it are escaped.
private static void Show(string text)
{
var safe = text.Replace(@"\", @"\\").Replace("`", @"\`").Replace("${", @"\${");

Eval("(globalThis.__out ??= document.body.appendChild("
+ "Object.assign(document.createElement('pre'), { id: 'out', style: 'font-size:0.9rem' })))"
+ ".textContent = `" + safe + "`;");
}
}
```

`repro.csproj`
```xaml


net10.0;net11.0

true
$(NoWarn);CA1416
true


true



```

`global.json`
```json
{ "sdk": { "version": "11.0.100-preview.7.26381.103", "allowPrerelease": true, "rollForward": "latestFeature" } }
```

### Expected behavior

```
target framework =
renders =
timerFires =
delayCompletions = 40000
unhandledErrors = 0

last unhandled exception:
(none yet)
```

### Actual behavior

net10
```
target framework = net10.0
renders =
timerFires =
delayCompletions =
unhandledErrors =

last unhandled exception:
System.Threading.SynchronizationLockException: Lock_Exit_SynchronizationLockException
at System.Threading.Lock.Exit(ThreadId currentThreadId)
at System.Threading.TimerQueueTimer.Fire(Boolean isThreadPool)
at System.Threading.TimerQueueTimer.System.Threading.IThreadPoolWorkItem.Execute()
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
at System.Threading.Thread.StartCallback()
```

net11
```
target framework = net11.0
renders =
timerFires = 0
delayCompletions = 0
unhandledErrors = 0

last unhandled exception:
(none yet)
```

### Regression?

_No response_

### Known Workarounds

_No response_

### Configuration

This was on an x64 Windows machine with the 10.0.302 SDK.

### Other information

_No response_

Contributor guide

Open the contributing guide

Research direction

Run the minimal repro from Program.cs with repro.csproj and global.json, comparing net10.0 and net11.0 under WasmEnableThreads=true. Start from the TimerQueueTimer and Lock.Exit stack-trace entries and compare behavior with threads disabled; done means timers fire, delays complete, and no unhandled exception occurs.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp, wasm
Domain
operating-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.