In our particular case, we have a custom shell around ConsoleShell
that upon start up loads Microsoft.WindowsAzure.Storage.dll
into the current app domain. Once the shell is up, we couldn't import for example Az.Accounts
module because it depends on a different version of Microsoft.WindowsAzure.Storage.dll
. Since this is a more general problem with PowerShell Core, I posted the issue here instead of over at Azure PowerShell. Effectively, I'm looking for a workaround for issue https://github.com/PowerShell/PowerShell/issues/2083.
custom assembly resolve
using Microsoft.PowerShell;
using System;
using System.Diagnostics;
using System.Reflection;
namespace Test
{
class Program
{
static void Main(string[] args)
{
AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve;
ConsoleShell.Start("Hello", "", new string[0] { });
}
private static Assembly OnAssemblyResolve(object sender, ResolveEventArgs args)
{
Debugger.Launch();
return null;
}
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.PowerShell.SDK" Version="7.0.0-rc.1" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.PowerShell.ConsoleHost">
<HintPath>$(NuGetPackageRoot)\microsoft.powershell.consolehost\7.0.0-rc.1\runtimes\win\lib\netcoreapp3.1\Microsoft.PowerShell.ConsoleHost.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
md5-e33f5639fa2307db3c29821766866232
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.WindowsAzure.Storage" publicKeyToken="31bf3856ad364e35" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-9.3.3.0" newVersion="9.3.3.0" />
</dependentAssembly>
</assemblyBinding>
Run into the same error above
run any external commands with Invoke-Command -Session, this basically out-sourced the command on a different app domain and avoid the version collision.
This seems to be the only workaround I can come up with, but it's really inconvenient to jump back and forth PS sessions.
Any better workaround?
/cc @daxian-dbw for information.
PowerShellEditorServices had some similar issues, @rjmholt recently worked around that with AssemblyLoadContext
's.
into the current app domain
There's only one AppDomain in .NET Core, but as it happens this is much easier to make work in .NET Core than in .NET Framework (i.e. if you have a need to do this in .NET Framework you need inter-AppDomain serialisation and a whole lot of ManagedByRefObject
work).
@rjmholt recently worked around that with AssemblyLoadContext's
Yeah, if you control the assembly load of your dependency, you can load it into a new assembly load context. In PSES we accomplish this as follows:
This assembly resolve setup means you don't need to use reflection anywhere, you just redirect loading through a new ALC at runtime. But it requires some layers:
We've discussed getting this into PowerShell itself for modules, but it's quite complex and not planned currently, so unlikely to happen within the next 12 months at least.
I should say that if you're rehosting PowerShell, you can condense the layers I describe above; you'll just have:
@rjmholt first and foremost, thanks a ton for your details write-up, it's extremely helpful!
Some clarification:
Follow-up questions:
- This will load the top level assembly through the custom ALC into the default ALC (so it's loaded in both), but in such a way that its dependencies are only loaded into the custom ALC.
return null
to let the default ALC to resolve?AssemblyLoadContext
?$customContext = [System.Runtime.Loader.AssemblyLoadContext]::new("Test")
$onAssemblyResolve = {
param($sender, $asmName)
Write-Host "Resolving '$($asmName.Name)'"
return $customContext.LoadFromAssemblyPath("$PSScriptRoot\bin\$($asmName.Name).dll")
}
[System.Runtime.Loader.AssemblyLoadContext]::Default.add_Resolving($onAssemblyResolve)
I should say that if you're rehosting PowerShell, you can condense the layers I describe above; you'll just have:
I want to point out that it won't work well to load PowerShell into a custom ALC. PowerShell depends on Assembly.LoadFrom
heavily especially in module loading, but Assembly.LoadFrom
will load an assembly to the default ALC, so it will result in conflicts.
- Dummy question: the following doesn't seem to work for dynamically-loaded dependencies, why would I need a subclass of
AssemblyLoadContext
?$customContext = [System.Runtime.Loader.AssemblyLoadContext]::new("Test") $onAssemblyResolve = { param($sender, $asmName) Write-Host "Resolving '$($asmName.Name)'" return $customContext.LoadFromAssemblyPath("$PSScriptRoot\bin\$($asmName.Name).dll") } [System.Runtime.Loader.AssemblyLoadContext]::Default.add_Resolving($onAssemblyResolve)
That's probably a scoping issue. By the time your resolving handler is invoked, $customContext
is no longer a variable. You'd have to do something like [System.Runtime.Loader.AssemblyLoadContext]::Default.add_Resolving($onAssemblyResolve.GetNewClosure())
but really you're better off doing it from C#.
I want to point out that it won't work well to load PowerShell into a custom ALC.
Oh sorry! To be clear, hosting PowerShell you should host it in the default ALC, but load the dependency into a custom ALC.
Anyway, it sounds like you really just want a module here.
Why do we need the top level assembly loaded in both default ALC and the custom ALC?
So you don't really need this, but if you don't then the only way to use the dependency APIs you need is by using reflection, which is slower, more error prone and generally less convenient. But you need some unique way to call the API from one assembly and not the other. The two ways to do this are reflection or implicit runtime redirection through a facade API, since just calling the API the normal way in code running in the default ALC will result in you calling the other dependency API.
Once our own module is loaded, if we intend to import Az modules and use it, it's going to be loaded to the default ALC. If so, how to avoid the assembly resolve event handler from kicking in? Do we simply return null to let the default ALC to resolve?
PowerShell has its own assembly resolve event that fires before yours. When you load the Az module, that loads its immediate assembly. That assembly then needs its dependencies, which aren't found in $PSHOME (.NET's app root here), so PowerShell's resolve event handler gets called, which looks in the directory where that first assembly is loaded.
So for example:
OurModule\
OurModule.psd1
OurModule.dll
isolatedDeps\
FacadeApi.dll
SharedDep.dll
AzModule\
AzModule.psd1
AzModule.dll
SharedDep.dll
When you load OurModule
, you load OurModule.dll
which creates the custom ALC and adds a resolve event that checks if FacadeApi
is the requested assembly and if so looks in isolatedDeps
to load FacadeApi.dll
in the custom ALC and take the result of that and return it in the resolve event handler for the default ALC. The custom ALC just looks for anything its asked for in isolatedDeps
and that's how it finds the SharedDep.dll
in the isolatedDeps
directory. When OurModule
is loaded, PowerShell fires its resolve handler, looks in OurModule\
and finds nothing, so we go to your resolve event, which only looks for the FacadeApi.dll
.
When you load AzModule
, whether before or after, PowerShell loads the module which directly loads AzModule.dll
. That assembly depends on another particular version of SharedDep.dll
(or could be the same one, doesn't matter). That's not loaded into the default ALC at any time, so always fires the resolve event, which PowerShell handles, looks for SharedDep.dll
in AzModule\
, finds it, and successfully loads. If some assembly doesn't exist by does in the isolatedDeps
dir and you fall back to your resolve event handler, you ensure you don't load that by having the resolve there only look for FacadeApi.dll
and letting the custom ALC (which you can only get into through that DLL) handle any other dependencies.
So the two never clash. But it depends on the FacadeApi.dll
existing and being unique to your module. It's the veil between the two contexts.
The other way to do it is to load your SharedDep.dll
ahead of time in the custom ALC, but then you're forced to use reflection to call into its APIs.
@rjmholt I think I'm getting closer with your extremely useful explanation above 馃檱
Using the PSES as an example, if I understood correctly from the code, here is what happens:
Microsoft.PowerShell.EditorServices
using the custom ALCLoad
method).If I understood correctly, when I applies the same logic in my code, the overwritten Load
was not invoked, any clue how that could happen?
Alternatively, I played with following approach instead:
testContext = new AssemblyLoadContext("Test");
binDirectory = @"C:\Test\bin";
AssemblyLoadContext.Default.Resolving += (AssemblyLoadContext defaultLoadContext, AssemblyName asmName) =>
{
Console.WriteLine($"Resolving {asmName.Name}.dll");
return testContext.LoadFromAssemblyPath(Path.Combine(binDirectory, $"{asmName.Name}.dll"));
};
This seems to be working, and here is what I assumed happening:
testContext
Az
module, PS will resolve all its dependencies to default ALC and no conflict occurredThe problem with the approach above is, (at least what seems to be), if I import Az
module first and say it loads a dependency dep.1.dll
which my module has a different version of, say dep.2.dll
. When dep.2.dll
was later loaded, it seems it's loading dep.1.dll
instead of going through my custom ALC's Load
method, hence jeopardize the separation. Why wouldn't the custom ALC's Load
method not invoked in this case?
So the important details are:
For example:
s_customLoadContext = new MyLoadContext(s_isolatedBinDir)
AssemblyLoadContext.Default.Resolving += (AssemblyLoadContext defaultLoadContext, AssemblyName asmName) =>
{
Console.WriteLine($"Resolving {asmName.Name}.dll");
if (asmName.Name != s_facadeAsmName
|| asmName.Version != s_facadeAsmVersion)
{
return null;
}
// Possibly s_customLoadContext.LoadFromAssemblyName(asmName) would work here too
// and deduplicate knowledge of the bin dir path
return s_customLoadContext.LoadFromAssemblyPath(Path.Combine(s_isolatedBinDir, $"{s_facadeAsmName}.dll"));
};
internal class MyLoadContext : AssemblyLoadContext
{
private readonly string _dependencyDirPath;
public MyLoadContext(string binDirPath)
{
_dependencyDirPath = binDirPath;
}
protected override Assembly Load(AssemblyName assemblyName)
{
string asmPath = Path.Join(_dependencyDirPath, $"{assemblyName.Name}.dll");
if (File.Exists(asmPath))
{
return LoadFromAssemblyPath(asmPath);
}
return null;
}
}
Also, I'm not entirely sure why your custom ALC's load isn't being called, but the way I approached it was to log things everywhere to trace the logic
the overwritten Load was not invoked, any clue how that could happen?
One thought here: if an assembly is loaded into a context and you try to load another assembly of the same name into that context, it won't try to load the new one and there won't be any resolve event either. So it may be that you've already loaded the assembly you want into the default ALC, meaning no resolve event, meaning no load call on the custom ALC.
This issue has been marked as answered and has not had any activity for 1 day. It has been closed for housekeeping purposes.
Most helpful comment
That's probably a scoping issue. By the time your resolving handler is invoked,
$customContext
is no longer a variable. You'd have to do something like[System.Runtime.Loader.AssemblyLoadContext]::Default.add_Resolving($onAssemblyResolve.GetNewClosure())
but really you're better off doing it from C#.