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.
Describe the bug
On .NET 11,
PullAsyncthrowsPlatformNotSupportedExceptionon the first call in a BlazorWebAssembly app. The same code works on .NET 10.
The blocking lock in
BuildDatasyncOfflineOptionsis the cause:.NET 11 makes every blocking
SemaphoreSlim.Wait(...)throw on single-threaded browser-wasm, evenwhen the semaphore is free.
SemaphoreSlim.Waithas been[UnsupportedOSPlatform("browser")]forseveral 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:
Push breaks too, but only once the queue is non-empty:
PushOperationAsyncdereferences theLazy<OfflineOptions>(OperationsQueueManager.cs#L339), which runs the same method. With an emptyqueue push returns early at L291 and looks healthy, which is why a page load reports the failure
as a pull failure.
To Reproduce
WasmEnableThreads), client 10.1.2.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?
CommunityToolkit.Datasync.Client 10.1.2. Browser and host OS make no difference. MAUI, WPF and
Windows heads are unaffected, they have real threads.
Additional context
There's no workaround from the consuming app: the lock is acquired before the
if (OfflineOptions is null)check,BuildDatasyncOfflineOptionsis internal, andWait(0)throws as well (the zero-timeout fast path only covers the already-empty case).
Looks contained:
DisposableLock.cs#L23is the only blocking wait in the client, andAcquireLock()has just two call sites,OfflineDbContext.cs#L147andAsyncLockDictionary.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
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.