.NET WASM multithreading: SynchronizationLockException corruption / Timers freezing
- 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.0true
$(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 = 0last 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 = 0last 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
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