Repository navigation
BuildDatasyncOfflineOptions throws PlatformNotSupportedException on .NET 11 Blazor WebAssembly #574
Description
Describe the bug
On .NET 11, PullAsync throws PlatformNotSupportedException on the first call in a Blazor
WebAssembly app. The same code works on .NET 10.
The blocking lock in BuildDatasyncOfflineOptions is the cause:
OfflineDbContext.BuildDatasyncOfflineOptions() // OfflineDbContext.cs#L147
-> DisposableLock.AcquireLock() // DisposableLock.cs#L23
-> SemaphoreSlim.Wait()
.NET 11 makes every blocking SemaphoreSlim.Wait(...) throw on single-threaded browser-wasm, even
when the semaphore is free. SemaphoreSlim.Wait has been [UnsupportedOSPlatform("browser")] for
several releases; .NET 11 started enforcing it (dotnet/runtime#123329, confirmed as by design in
dotnet/runtime#131859). Through .NET 10 an uncontended Wait() just took the count and returned,
so this lock worked by luck.
Stack trace, from our production telemetry:
System.PlatformNotSupportedException: Arg_PlatformNotSupported
at System.Runtime.CompilerServices.RuntimeFeature.ThrowIfMultithreadingIsNotSupported()
at System.Threading.SemaphoreSlim.WaitCore(Int64, CancellationToken)
at System.Threading.SemaphoreSlim.Wait()
at CommunityToolkit.Datasync.Client.Threading.DisposableLock.AcquireLock()
at CommunityToolkit.Datasync.Client.Offline.OfflineDbContext.BuildDatasyncOfflineOptions()
at CommunityToolkit.Datasync.Client.Offline.OfflineDbContext.PullAsync(IEnumerable`1, PullOptions, CancellationToken)
Push breaks too, but only once the queue is non-empty: PushOperationAsync dereferences the
Lazy<OfflineOptions> (OperationsQueueManager.cs#L339), which runs the same method. With an empty
queue push returns early at L291 and looks healthy, which is why a page load reports the failure
as a pull failure.
To Reproduce
- Blazor WebAssembly app on .NET 11 (single-threaded, no
WasmEnableThreads), client 10.1.2. - Call
await dbContext.PullAsync(). PlatformNotSupportedException.
new SemaphoreSlim(1).Wait(); in the same app throws the same way, if the one-liner is easier.
What I actually observed is the production stack above from our Blazor WASM app; the push path and
the .NET 10 comparison are read off the sources rather than measured, and I haven't reduced it to a
standalone repro project yet. Happy to build one if that helps.
Expected behavior
Pull and push work as they did on .NET 10.
What platforms?
- Client: Blazor WebAssembly, browser-wasm single-threaded, .NET 11,
CommunityToolkit.Datasync.Client 10.1.2. Browser and host OS make no difference. MAUI, WPF and
Windows heads are unaffected, they have real threads. - Server: not involved, the request is never sent.
Additional context
There's no workaround from the consuming app: the lock is acquired before the
if (OfflineOptions is null) check, BuildDatasyncOfflineOptions is internal, and Wait(0)
throws as well (the zero-timeout fast path only covers the already-empty case).
Looks contained: DisposableLock.cs#L23 is the only blocking wait in the client, and
AcquireLock() has just two call sites, OfflineDbContext.cs#L147 and AsyncLockDictionary.cs#L52.
grpc-dotnet hit exactly this and fixed it on their side: grpc/grpc-dotnet#2756.
One thing that would surface the rest: the client is an OS-neutral library, so CA1416 never checks
it against browser. Adding
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>to the client project makes the analyzer flag these call sites (suggested by @jkotas in
dotnet/runtime#131859).
I realise Blazor WASM isn't in your tested matrix (#174), so treat the priority as you see fit.
I'm glad to send a PR if you'd like one.
Activity
Avoid blocking SemaphoreSlim.Wait in BuildDatasyncOfflineOptions for …
AtakanUk commentedon Oct 4, 2026
I opened a fix in #575.