Describe the bug
AssemblyDescriptorResolver caches the assembly behind each avares:// authority in a plain Dictionary<string, IAssemblyDescriptor>, filled on the first miss with no synchronization (AssemblyDescriptorResolver.cs#L19-L45). When several threads open assets of an assembly that is not cached yet, they all miss and insert at once, which corrupts the dictionary.
The corruption is then disguised. StandardAssetLoader.TryLoadAssembly wraps GetAssembly in catch (Exception) { } and returns false (StandardAssetLoader.cs#L255-L268). So AssetLoader.Open throws
System.IO.FileNotFoundException: The resource avares://MyApp/Assets/drive_led_off.png could not be found.
for an asset that is in the assembly. The exceptions actually thrown inside GetAssembly (observed by calling the resolver directly the same way) are:
InvalidOperationException: Operations that change non-concurrent collections must have exclusive access. A concurrent update was performed on this collection and corrupted its state. The collection's state is no longer correct.
IndexOutOfRangeException: Index was outside the bounds of the array.
How we hit it in a real application. Since 12.0.0.13, Svg.Controls.Skia.Avalonia loads the Path of every Svg control on the thread pool as soon as the property is set during XAML construction. Our main window has ~25 SVG menu icons plus an <Image Source="/Assets/….png"/> in its status bar, and nothing touches avares:// before that window's InitializeComponent. So the first resolution of our assembly happens on ~25 pool threads and on the UI thread at once. At random:
- some icons are missing: the Svg control catches the exception and logs it at Warning;
- on a few per cent of launches the application closes at startup: the bitmap is loaded on the UI thread inside the window's constructor, so the
FileNotFoundException is unhandled.
Different files fail each time, and the message sends you looking for a packaging problem that does not exist.
To Reproduce
A console project, no UI needed. Any two files under Assets/ will do (they are never decoded):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.2" />
<AvaloniaResource Include="Assets/**" />
</ItemGroup>
</Project>
Program.cs (the project is named AssetRace; Assets/a.txt and Assets/b.txt contain anything):
using Avalonia.Platform;
StandardAssetLoader.RegisterResUriParsers();
Uri[] assets = [new("avares://AssetRace/Assets/a.txt"), new("avares://AssetRace/Assets/b.txt")];
const int Rounds = 3000, Threads = 16;
int failedRounds = 0;
string firstError = null;
for (int round = 0; round < Rounds; round++)
{
// A new loader has an empty assembly cache: the state of an application at its first
// avares:// access, which is when its first window's XAML is being built.
var loader = new StandardAssetLoader();
using var go = new ManualResetEventSlim();
int failures = 0;
var threads = Enumerable.Range(0, Threads).Select(i => new Thread(() =>
{
go.Wait();
try
{
using var stream = loader.Open(assets[i % assets.Length]);
}
catch (Exception e)
{
Interlocked.Increment(ref failures);
Interlocked.CompareExchange(ref firstError, $"{e.GetType().Name}: {e.Message}", null);
}
})).ToList();
threads.ForEach(t => t.Start());
Thread.Sleep(1);
go.Set();
threads.ForEach(t => t.Join());
if (failures > 0)
failedRounds++;
}
Console.WriteLine($"{failedRounds} of {Rounds} rounds had a failed Open");
Console.WriteLine(firstError ?? "no failures");
dotnet run -c Release, three runs on an 8-core machine:
338 of 3000 rounds had a failed Open
257 of 3000 rounds had a failed Open
477 of 3000 rounds had a failed Open
FileNotFoundException: The resource avares://AssetRace/Assets/b.txt could not be found.
The same opens made from a single thread never fail.
Expected behavior
AssetLoader.Open succeeds for an existing asset whatever thread calls it. Loading images off the UI thread is common, and third-party controls already do it.
Failing that, an unexpected exception inside the resolver should not be reported as "resource not found".
Two possible changes:
- make the cache thread-safe: a
ConcurrentDictionary with GetOrAdd, or a lock around the miss path in GetAssembly;
- narrow the catch in
TryLoadAssembly to the exceptions that really mean "no such assembly" (e.g. FileNotFoundException / FileLoadException from Assembly.Load), so anything else propagates with its real message.
Avalonia version
12.1.2. The code is unchanged on master as of 2026-09-28: same Dictionary, same catch.
OS
Windows
Additional context
Workaround for applications: resolve the assembly once, on the UI thread, before any window is created. After that every lookup is a read, and concurrent reads of a Dictionary are safe:
public override void OnFrameworkInitializationCompleted()
{
AssetLoader.GetAssembly(new Uri("avares://MyApp/"));
// ... create the main window
}
With that line added before each round, the repro above reports 0 failures in 3000 rounds. It only covers the authorities you warm up, and the key is compared case-sensitively.
Describe the bug
AssemblyDescriptorResolvercaches the assembly behind eachavares://authority in a plainDictionary<string, IAssemblyDescriptor>, filled on the first miss with no synchronization (AssemblyDescriptorResolver.cs#L19-L45). When several threads open assets of an assembly that is not cached yet, they all miss and insert at once, which corrupts the dictionary.The corruption is then disguised.
StandardAssetLoader.TryLoadAssemblywrapsGetAssemblyincatch (Exception) { }and returns false (StandardAssetLoader.cs#L255-L268). SoAssetLoader.Openthrowsfor an asset that is in the assembly. The exceptions actually thrown inside
GetAssembly(observed by calling the resolver directly the same way) are:How we hit it in a real application. Since 12.0.0.13, Svg.Controls.Skia.Avalonia loads the
Pathof everySvgcontrol on the thread pool as soon as the property is set during XAML construction. Our main window has ~25 SVG menu icons plus an<Image Source="/Assets/….png"/>in its status bar, and nothing touchesavares://before that window'sInitializeComponent. So the first resolution of our assembly happens on ~25 pool threads and on the UI thread at once. At random:FileNotFoundExceptionis unhandled.Different files fail each time, and the message sends you looking for a packaging problem that does not exist.
To Reproduce
A console project, no UI needed. Any two files under
Assets/will do (they are never decoded):Program.cs(the project is namedAssetRace;Assets/a.txtandAssets/b.txtcontain anything):dotnet run -c Release, three runs on an 8-core machine:The same opens made from a single thread never fail.
Expected behavior
AssetLoader.Opensucceeds for an existing asset whatever thread calls it. Loading images off the UI thread is common, and third-party controls already do it.Failing that, an unexpected exception inside the resolver should not be reported as "resource not found".
Two possible changes:
ConcurrentDictionarywithGetOrAdd, or a lock around the miss path inGetAssembly;TryLoadAssemblyto the exceptions that really mean "no such assembly" (e.g.FileNotFoundException/FileLoadExceptionfromAssembly.Load), so anything else propagates with its real message.Avalonia version
12.1.2. The code is unchanged on
masteras of 2026-09-28: sameDictionary, samecatch.OS
Windows
Additional context
Workaround for applications: resolve the assembly once, on the UI thread, before any window is created. After that every lookup is a read, and concurrent reads of a
Dictionaryare safe:With that line added before each round, the repro above reports 0 failures in 3000 rounds. It only covers the authorities you warm up, and the key is compared case-sensitively.