PowerShell / PowerShell/PowerShell

Module Assembly Load Context has no ExportedCommands on 2nd import

Đang mở
#20,710 1 bình luận 2 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Needs-Triage WG-Engine WG-NeedsReview
Ngôn ngữ chính
C#
Star
55.5k
Fork
8.5k
Merge trung bình
1 ngày 2 giờ
Pull request đã merge (30 ngày)
88

Mô tả

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

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Trước tiên, hãy chạy reproducer PowerShell được cung cấp, sau đó đọc ImportModuleCommand.cs, đặc biệt là ImportModule_ViaAssembly, LoadBinaryModule và AddModuleToModuleTables. Theo dõi lần import đầu tiên và lần import thứ hai bị buộc thực hiện qua hành vi của bảng module; được xem là hoàn tất khi lần import thứ hai vẫn expose Test-Cmdlet và lệnh chạy thành công.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
csharp, powershell
Lĩnh vực
backend, cli
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Đình trệ
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
35/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.