PowerShell / PowerShell/PowerShell
Module Assembly Load Context has no ExportedCommands on 2nd import
Nobody has claimed this yet.
- Dominant language
- C#
- Stars
- 55.5k
- Forks
- 8.5k
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 88
Description
Prerequisites
- Write a descriptive title.
- Make sure you are able to repro it on the latest released version
- Search the existing issues.
- Refer to the FAQ.
- Refer to Differences between Windows PowerShell 5.1 and PowerShell.
Steps to reproduce
I tried to create a simpler reproducer without an ALC but I wasn't able to. I'm unsure if there's some combination of the ALC or how I'm loading it but here is a PowerShell script to create a module that is affected by this problem.
$moduleDir = 'temp:/MyModule'
if (Test-Path -Path $moduleDir) {
Remove-Item -Path $moduleDir -Recurse -Force
}
New-Item -Path $moduleDir -ItemType Directory | Out-Null
Add-Type -TypeDefinition @'
using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;
#nullable enable
namespace MyModule;
public class LoadContext : AssemblyLoadContext
{
private static LoadContext? _instance;
private Assembly _thisAssembly;
private AssemblyName _thisAssemblyName;
private Assembly _moduleAssembly;
private string _assemblyDir;
private LoadContext(string mainModulePathAssemblyPath)
: base(name: "MyModule", isCollectible: false)
{
_assemblyDir = Path.GetDirectoryName(mainModulePathAssemblyPath) ?? "";
_thisAssembly = typeof(LoadContext).Assembly;
_thisAssemblyName = _thisAssembly.GetName();
_moduleAssembly = LoadFromAssemblyPath(mainModulePathAssemblyPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
if (AssemblyName.ReferenceMatchesDefinition(_thisAssemblyName, assemblyName))
{
return _thisAssembly;
}
string asmPath = Path.Join(_assemblyDir, $"{assemblyName.Name}.dll");
if (File.Exists(asmPath))
{
return LoadFromAssemblyPath(asmPath);
}
else
{
return null;
}
}
public static Assembly Initialize()
{
LoadContext? instance = _instance;
if (instance is not null)
{
return instance._moduleAssembly;
}
string assemblyPath = typeof(LoadContext).Assembly.Location;
string modulePath = Path.Combine(
Path.GetDirectoryName(assemblyPath)!,
"MyModule.Module.dll"
);
_instance = new LoadContext(modulePath);
return _instance._moduleAssembly;
}
}
'@ -OutputAssembly "$moduleDir/MyModule.dll"
Add-Type -TypeDefinition @'
using System;
using System.Management.Automation;
namespace MyModule.Module;
[Cmdlet(VerbsDiagnostic.Test, "Cmdlet")]
public class TestCmdlet : PSCmdlet
{
protected override void ProcessRecord()
{
WriteObject("foo");
}
}
'@ -OutputAssembly "$moduleDir/MyModule.Module.dll"
Set-Content -Path "$moduleDir/MyModule.psm1" -Value @'
Add-Type -Path $PSScriptRoot/MyModule.dll
$mainModule = [MyModule.LoadContext]::Initialize()
Import-Module -Assembly $mainModule
'@
Set-Content -Path "$moduleDir/MyModule.psd1" -Value @'
@{
RootModule = 'MyModule.psm1'
ModuleVersion = '0.1.0'
GUID = 'd872c839-4306-49d8-9c89-2916500c0538'
Author = 'Author'
CompanyName = 'Company'
Copyright = '(c) 2023 Author. All rights reserved.'
Description = 'Repro for nested assembly problem'
PowerShellVersion = '7.2'
CmdletsToExport = @('Test-Cmdlet')
PrivateData = @{
PSData = @{}
}
}
'@
Import-Module -Name $moduleDir -Force -PassThru
Test-Cmdlet
Import-Module -Name $moduleDir -Force -PassThru
Test-Cmdlet
The module will add the MyModule.dll which contains the ALC loading code returning the ALC'd assembly. That assembly is then imported inside the module scope which is subsequently a nested module of MyModule. The first call works just fine but if Import-Module ... -Force is called a 2nd time on the module there is no longer any exported commands.
I tried to figure out what the cause behind this was and while I think I know roughly what the problem is I don't know the fix. On the first load of the module the ImportModule_ViaAssembly method will eventually call LoadBinaryModule and AddModuleToModuleTables. The LoadBinaryModule method will set the cmdlets inside the assembly to the context session scope and AddModuleToModuleTables will add the ALC assembly as a nested module to the main module.
On the 2nd import all this is skipped as the ALC assembly is stored in the Context.Modules.ModuleTable so it is no longer loaded, the cmdlets are not added to the session state, and it's not added as a nested module. This means the parent module will no longer find these cmdlets in the session state and will not see it as a cmdlet to export.
A workaround is to do the following in the psm1
$isReload = $true
if (-not ('MyModule.LoadContext' -as [type])) {
$isReload = $false
$moduleName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)
Add-Type -Path $PSScriptRoot/MyModule.dll
}
$mainModule = [MyModule.LoadContext]::Initialize()
$alcModule = Import-Module -Assembly $mainModule -PassThru
if ($isReload) {
$addExportedCmdlet = [System.Management.Automation.PSModuleInfo].GetMethod(
'AddExportedCmdlet',
[System.Reflection.BindingFlags]'Instance, NonPublic'
)
foreach ($cmd in $alcModule.ExportedCommands.Values) {
$addExportedCmdlet.Invoke($ExecutionContext.SessionState.Module, @(, $cmd))
}
}
This unfortunately relies on an internal method to re-add the cmdlets inside the ALC assembly to the current module instance manually. Would be nice if pwsh handled this normally.
Expected behavior
PS /home/jborean> Import-Module -Name $moduleDir -Force -PassThru
ModuleType Version PreRelease Name ExportedCommands
---------- ------- ---------- ---- ----------------
Script 0.1.0 MyModule Test-Cmdlet
PS /home/jborean> Test-Cmdlet
foo
PS /home/jborean> Import-Module -Name $moduleDir -Force -PassThru
ModuleType Version PreRelease Name ExportedCommands
---------- ------- ---------- ---- ----------------
Script 0.1.0 MyModule Test-Cmdlet
PS /home/jborean> Test-Cmdlet
foo
Actual behavior
PS /home/jborean> Import-Module -Name $moduleDir -Force -PassThru
ModuleType Version PreRelease Name ExportedCommands
---------- ------- ---------- ---- ----------------
Script 0.1.0 MyModule Test-Cmdlet
PS /home/jborean> Test-Cmdlet
foo
PS /home/jborean> Import-Module -Name $moduleDir -Force -PassThru
ModuleType Version PreRelease Name ExportedCommands
---------- ------- ---------- ---- ----------------
Script 0.1.0 MyModule
PS /home/jborean> Test-Cmdlet
Test-Cmdlet: The term 'Test-Cmdlet' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
Error details
Exception :
Type : System.Management.Automation.CommandNotFoundException
ErrorRecord :
Exception :
Type : System.Management.Automation.ParentContainsErrorRecordException
Message : The term 'Test-Cmdlet' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
HResult : -2146233087
TargetObject : Test-Cmdlet
CategoryInfo : ObjectNotFound: (Test-Cmdlet:String) [], ParentContainsErrorRecordException
FullyQualifiedErrorId : CommandNotFoundException
InvocationInfo :
ScriptLineNumber : 1
OffsetInLine : 1
HistoryId : 15
Line : Test-Cmdlet
PositionMessage : At line:1 char:1
+ Test-Cmdlet
+ ~~~~~~~~~~~
InvocationName : Test-Cmdlet
CommandOrigin : Internal
ScriptStackTrace : at <ScriptBlock>, <No file>: line 1
CommandName : Test-Cmdlet
TargetSite :
Name : LookupCommandInfo
DeclaringType : System.Management.Automation.CommandDiscovery, System.Management.Automation, Version=7.3.9.500, Culture=neutral, PublicKeyToken=31bf3856ad364e35
MemberType : Method
Module : System.Management.Automation.dll
Message : The term 'Test-Cmdlet' is not recognized as a name of a cmdlet, function, script file, or executable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
Data : System.Collections.ListDictionaryInternal
Source : System.Management.Automation
HResult : -2146233087
StackTrace :
at System.Management.Automation.CommandDiscovery.LookupCommandInfo(String commandName, CommandTypes commandTypes, SearchResolutionOptions searchResolutionOptions, CommandOrigin
commandOrigin, ExecutionContext context)
at System.Management.Automation.ExecutionContext.CreateCommand(String command, Boolean dotSource)
at System.Management.Automation.PipelineOps.AddCommand(PipelineProcessor pipe, CommandParameterInternal[] commandElements, CommandBaseAst commandBaseAst, CommandRedirection[]
redirections, ExecutionContext context)
at System.Management.Automation.PipelineOps.InvokePipeline(Object input, Boolean ignoreInput, CommandParameterInternal[][] pipeElements, CommandBaseAst[] pipeElementAsts,
CommandRedirection[][] commandRedirections, FunctionContext funcContext)
at System.Management.Automation.Interpreter.ActionCallInstruction`6.Run(InterpretedFrame frame)
at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
TargetObject : Test-Cmdlet
CategoryInfo : ObjectNotFound: (Test-Cmdlet:String) [], CommandNotFoundException
FullyQualifiedErrorId : CommandNotFoundException
InvocationInfo :
ScriptLineNumber : 1
OffsetInLine : 1
HistoryId : 15
Line : Test-Cmdlet
PositionMessage : At line:1 char:1
+ Test-Cmdlet
+ ~~~~~~~~~~~
InvocationName : Test-Cmdlet
CommandOrigin : Internal
ScriptStackTrace : at <ScriptBlock>, <No file>: line 1
Environment data
Name Value
---- -----
PSVersion 7.3.9
PSEdition Core
GitCommitId 7.3.9
OS Linux 6.6.1-arch1-1 #1 SMP PREEMPT_DYNAMIC Wed, 08 Nov 2023 16:05:38 +0000
Platform Unix
PSCompatibleVersions {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion 2.3
SerializationVersion 1.1.0.1
WSManStackVersion 3.0
Also tested on 7.4.0.
Visuals
No response
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Run the provided PowerShell reproducer first, then read ImportModuleCommand.cs, especially ImportModule_ViaAssembly, LoadBinaryModule, and AddModuleToModuleTables. Trace the first and forced second imports through the module table behavior; done means the second import still exposes Test-Cmdlet and the command runs successfully.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, powershell
- Domain
- backend, cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100