PowerShell / PowerShell/PowerShell

Module Assembly Load Context has no ExportedCommands on 2nd import

Open
#20,710 1 comment 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Needs-Triage WG-Engine WG-NeedsReview
Dominant language
C#
Stars
55.5k
Forks
8.5k
Avg merge
1d 2h
Merged PRs (30d)
88

Description

Prerequisites
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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.