-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Description
Description
I'm trying to load an assembly from memory, using AssemblyLoadContext.Default.LoadFromStream. However, it seems that if APP_PATHS is set (e.g. by a custom native host), the runtime prefers loading assemblies from the specified folders, and will just ignore the memory stream you wanted to load. Specifically, if there is an assembly somewhere in one of the folders listed in APP_PATHS, that has the same simple name as the assembly you're trying to load from memory, the runtime will load that assembly file from disk, instead of from the memory stream. This only happens with the default assembly load context, and doesn't happen with e.g. Assembly.Load(byte[]), which creates a new ACL. If the assembly can't be found in APP_PATHS, it is correctly loaded from memory. As far as I can tell the TPA list is completely ignored for this.
I think this is extremely surprising and undesirable behaviour - if you wanted to try to load the assembly from disk, you would just call one of the many other methods that exist to do just that. Also, if the on-disk assembly is too different from the in-memory assembly, the runtime might just throw a FileLoadException, which is obviously extremely confusing, coming from a method that looks like it's not supposed to be doing any I/O.
Reproduction Steps
For the sake of this repro, the assembly loaded from memory is not generated in memory, but read from disk and then loaded.
There's two relevant folders:
stream_load_folder, with the dll that is loaded from memoryapp_paths_folder, whichAPP_PATHSis configured to point to
Each of these folders has a lib.dll file, the exact contents of this assembly doesn't matter. Even just an unmodified dotnet new classlib -o lib works. You can try putting slightly modified versions of the lib into each folder and observe that it throws an exception, or use identical versions and observe that the load succeeds from disk, instead of from memory.
Managed code to load assembly from memory
using System.Runtime.Loader;
using System.Runtime.InteropServices;
using System.Reflection;
static class Program {
[UnmanagedCallersOnly]
static void Entrypoint() {
LoadFile();
}
static void LoadFile() {
var path = "./stream_load_folder/lib.dll";
var stream = File.OpenRead(path);
var bytes = new MemoryStream();
stream.CopyTo(bytes);
// Workaround for a probably unrelated maybe-bug (`LoadFromStream` fails if you don't reset `Position`)
bytes.Position = 0;
var assembly = AssemblyLoadContext.Default.LoadFromStream(bytes);
// Loading it like this creates a new ACL which seems to always work (also, this still works even if you don't reset `Position`)
// var assembly = Assembly.Load(bytes.ToArray());
// If it was actually loaded from memory then `Location` should be `null`
Console.WriteLine($"Loaded '{assembly}' from location '{assembly.Location}'");
}
}Native host code to set APP_PATHS
Using Rust and the netcorehost crate.
use std::fs::canonicalize;
use netcorehost::*;
use netcorehost::pdcstring::PdCString;
use netcorehost::nethost::load_hostfxr;
fn main() {
let hostfxr = load_hostfxr().unwrap();
let mut context = hostfxr
.initialize_for_runtime_config(
pdcstr!("program.runtimeconfig.json")
)
.unwrap();
let app_paths = canonicalize("app_paths_folder").unwrap();
context
.set_runtime_property_value(
pdcstr!("APP_PATHS"),
PdCString::from_os_str(&app_paths).unwrap(),
)
.unwrap();
context
.load_assembly_from_path(PdCString::from_os_str(canonicalize("program.dll").unwrap()).unwrap())
.unwrap();
let delegate_loader = context.get_delegate_loader().unwrap();
let entrypoint = delegate_loader
.get_function_with_unmanaged_callers_only::<fn()>(
pdcstr!("Program, Program"),
pdcstr!("Entrypoint"),
)
.unwrap();
entrypoint();
}Expected behavior
For the above code, the console output should resemble Loaded 'lib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' from location ''. (The blank Location means it was loaded from memory, as it should be)
Actual behavior
Depending on the specific contents of the two different lib.dll files, and how similar or different they are, I've observed three ways this can fail:
Loaded 'lib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' from location '/home/ts/dev/csharp/testy/run/app_paths_folder/lib.dll'(Loaded from disk instead of from memory)System.IO.FileLoadException: Assembly with same name is already loaded(The assembly in question is, in fact, not already loaded)System.IO.FileLoadException: Could not load file or assembly 'lib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'.
Regression?
No response
Known Workarounds
- Don't set
APP_PATHS - Ensure that any assemblies you want to load from memory have distinct names from any that exist on-disk
- Arrange the assembly files so that the on-disk version is in a folder that isn't specified in
APP_PATHS - Use
Assembly.Loadto load into a new ACL instead of the default - Implement custom assembly loading logic using the "last chance"
AssemblyLoadContext.Resolving/AppDomain.AssemblyResolveevents
For my use case, none of these options are really possible. To elaborate: There is a folder in a location that I don't control, the contents of which I don't control, that contains assemblies that are statically referenced, some of which I need to apply runtime patches to.
- Setting
APP_PATHSis necessary so the static assembly references can resolve correctly - I need to ensure that static references to the assemblies I am patching resolve to the patched version, so the names need to be the same
- That would mean sorting the assemblies in the folder into two folders, filtered by whether or not I am patching them. Not possible because I don't control the folder or its contents
- I was previously using a solution with a custom ACL, but it was plagued with issues from it being almost impossible to ensure that everything actually gets loaded into the custom ACL. Some stuff always, invariably "leaked out" into the default ACL and caused issues.
- I haven't tried doing this yet, so it may work in combination with not setting
APP_PATHS. I would prefer to be able to leverageAPP_PATHSthough, if at all possible.
Configuration
Pop!_OS 22.04 LTS (x86_64)
dotnet --list-sdks
8.0.405 [/usr/share/dotnet/sdk]
dotnet --list-runtimes
Microsoft.AspNetCore.App 8.0.12 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.NETCore.App 8.0.12 [/usr/share/dotnet/shared/Microsoft.NETCore.App
Other information
I believe the offending code in the runtime is somewhere in these functions:
AssemblyNative::LoadFromPEImageDefaultAssemblyBinder::BindUsingPEImageAssemblyBinderCommon::BindUsingPEImageAssemblyBinderCommon::BindByName
Note that although I'm running on .NET 8, the code in these functions looks (at least to me) identical between the linked tag and current main, so I'm confident this bug also exists on later versions.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status