Skip to content

Unexpected coupling between ordering of parsing .ps1 files with classes/enums and executing them #25613

@jazzdelightsme

Description

@jazzdelightsme

Prerequisites

Steps to reproduce

Save the following script as repro.ps1, and run with pwsh -NoProfile -Command .\repro.ps1. Description of the problem in the comments:

<#
.SYNOPSIS
    This script demonstrates an unexpected coupling between the order in which you parse a
    script with custom types (enums or classes) and execute the resulting Ast's
    ScriptBlock: if you parse some other script before executing the type-declaring
    script's code, things appear to succeed (no errors generated when executing the parsed
    scripts), but attempting to use the custom types fails.

    Run with: pwsh.exe -NoProfile -Command ".\repro.ps1"

    (Or, for the "No Repro" case: pwsh.exe -NoProfile -Command ".\repro.ps1 -InOrder")

    This script generates three files:

        Types.ps1:        Defines an enum ("MyEnum").
        Code.ps1:         Defines a function ("Foo") that uses the enum.
        ReproModule.psm1: Parses and executes the .ps1 files.

    In the repro case, when the module is loaded, the sequence of events is:

        1. parse Types.ps1
        2. parse Code.ps1
        3. execute Types.ps1's Ast.GetScriptBlock()
        4. execute Code.ps1's Ast.GetScriptBlock()

    Finally, once the ReproModule is loaded, this script attempts to run "Foo -?".

    The Expected Result is that we should see a nice help message about how to call Foo.

    The actual result is an error, complaining that it can't find the MyEnum type:

        InvalidOperation: C:\Users\danthom\OutOfOrderParseProblem\Code.ps1:1
        Line |
           1 |  function Foo { [CmdletBinding()] param( [MyEnum] $ME ) $ME }
             |                                          ~~~~~~~~
             | Unable to find type [MyEnum].

    The -InOrder parameter changes the order of the steps performed by the
    ReproModule.psm1:

        1. parse Types.ps1
        2. execute Types.ps1's Ast.GetScriptBlock()
        3. parse Code.ps1
        4. execute Code.ps1's Ast.GetScriptBlock()

    When performed in this order, "Foo -?" behaves as expected.

    Q: Why is this interesting / important?

    Parsing a script into an AST should have *no* dependency on any kind of context.
    (This Issue demonstrates that unfortunately, there is some kind of hidden dependency.)
    If this hidden dependency did not exist, it would allow multi-threaded processing of
    many scripts, for significant perf gains (both for first-time loading, as well as
    being able to save the ASTs, to be used in other runspaces).

    Example C#:

        public static void ParseScripts( IList<ParsePsWorkItem> parseWorkItems )
        {
            Parallel.ForEach(parseWorkItems, parseWorkItem =>
            {
                parseWorkItem.ParseTask = File.ReadAllTextAsync(parseWorkItem.Path).ContinueWith( t =>
                {
                    Token[] tokens ;
                    ParseError[] errors;

                    var ast = Parser.ParseInput( t.Result, parseWorkItem.Path, out tokens, out errors );
                    return new PsParseResult( errors, ast );
                } );
            });
        }

    And corresponding code in a .psm1:

        foreach ($pwi in $parseWorkItems)
        {
            $parseResult = $pwi.ParseTask.GetAwaiter().GetResult()
            if( $parseResult.Errors -and $parseResult.Errors.Count )
            {
                throw "Parse error for $($pwi.Path): $($parseResult.Errors[ 0 ])"
            }

            $ExecutionContext.InvokeCommand.InvokeScript( $false,    # useLocalScope,
                                                          $parseResult.Ast.GetScriptBlock(),
                                                          @(),      # input
                                                          @() )     # args
        }

    This code will *appear* to work (no errors when loading up)... but things will fail at
    runtime, if your scripts use enums/classes. :'(
#>
[CmdletBinding()]
param(
    [switch] $InOrder
)

'enum MyEnum { None = 0; One = 1 }' > Types.ps1

'function Foo { [CmdletBinding()] param( [MyEnum] $ME ) $ME }' > Code.ps1

# The main .psm1:
@'
$parseWorkItems = @()

foreach( $file in 'Types.ps1', 'Code.ps1' )
{
    $parseWorkItems += [PSCustomObject] @{
        Path = (Resolve-Path $file).ProviderPath
        Errors = $null
        Ast = $null
    }
}

if( $env:_ParseAndExecInOrder -ne 0 )
{
    # THIS IS THE "NO-REPRO" CASE; LOOK AT THE 'ELSE' BLOCK FIRST

    Write-Host "We are going to parse and then immediately load each script, in order."

    foreach( $pwi in $parseWorkItems )
    {
        $tokens = @()
        $errors = @()

        Write-Host "Parsing: $($pwi.Path)"
        $ast = [System.Management.Automation.Language.Parser]::ParseInput( (gc -Raw $pwi.Path), $pwi.Path, ([ref] $tokens), ([ref] $errors) )

        if( $errors -and $errors.Count )
        {
            throw "Parse error in $($pwi.Path): $($errors[ 0 ])"
        }

        Write-Host "Loading: $($pwi.Path)"
        $ExecutionContext.InvokeCommand.InvokeScript( $false,    # useLocalScope,
                                                      $ast.GetScriptBlock(),
                                                      @(),      # input
                                                      @() )     # args
    }
}
else
{
    Write-Host "We are going to parse all scripts first, then go back and execute the results (`"out of order`")."

    foreach( $pwi in $parseWorkItems )
    {
        $tokens = @()
        $errors = @()

        Write-Host "Parsing: $($pwi.Path)"
        $ast = [System.Management.Automation.Language.Parser]::ParseInput( (gc -Raw $pwi.Path), $pwi.Path, ([ref] $tokens), ([ref] $errors) )

        $pwi.Errors = $errors
        $pwi.Ast = $ast
    }

    foreach( $pwi in $parseWorkItems )
    {
        Write-Host "Loading: $($pwi.Path)"

        if( $pwi.Errors -and $pwi.Errors.Count )
        {
            throw "Parse error in $($pwi.Path): $($pwi.Errors[ 0 ])"
        }

        $ExecutionContext.InvokeCommand.InvokeScript( $false,    # useLocalScope,
                                                      $pwi.Ast.GetScriptBlock(),
                                                      @(),      # input
                                                      @() )     # args
    }
}

'@ > ReproModule.psm1

if( $InOrder )
{
    $env:_ParseAndExecInOrder = 1
}
else
{
    $env:_ParseAndExecInOrder = 0
}

# Now we load the module and attempt to get the help for the Foo command.
ipmo .\ReproModule.psm1

Foo -?
# Expected result: We should see a nice help message about how to call Foo.
#
# Actual result (when !$InOrder):
#
#   Parsing: C:\Users\danthom\OutOfOrderParseProblem\Types.ps1
#   Parsing: C:\Users\danthom\OutOfOrderParseProblem\Code.ps1
#   Loading: C:\Users\danthom\OutOfOrderParseProblem\Types.ps1
#   Loading: C:\Users\danthom\OutOfOrderParseProblem\Code.ps1
#   InvalidOperation: C:\Users\danthom\OutOfOrderParseProblem\Code.ps1:1
#   Line |
#      1 |  function Foo { [CmdletBinding()] param( [MyEnum] $ME ) $ME }
#        |                                          ~~~~~~~~
#        | Unable to find type [MyEnum].

Expected behavior

NAME
    Foo

SYNTAX
    Foo [[-ME] {None | One}] [<CommonParameters>]


ALIASES
    None


REMARKS
    None

Actual behavior

InvalidOperation: C:\Users\danthom\OutOfOrderParseProblem\Code.ps1:1
Line |
   1 |  function Foo { [CmdletBinding()] param( [MyEnum] $ME ) $ME }
     |                                          ~~~~~~~~
     | Unable to find type [MyEnum].

Error details

Exception             :
    Type        : System.Management.Automation.RuntimeException
    ErrorRecord :
        Exception             :
            Type    : System.Management.Automation.ParentContainsErrorRecordException
            Message : Unable to find type [MyEnum].
            HResult : -2146233087
        TargetObject          : MyEnum
        CategoryInfo          : InvalidOperation: (MyEnum:TypeName) [], ParentContainsErrorRecordException
        FullyQualifiedErrorId : TypeNotFound
        InvocationInfo        :
            ScriptLineNumber : 1
            OffsetInLine     : 41
            HistoryId        : 1
            ScriptName       : C:\Users\danthom\OutOfOrderParseProblem\Code.ps1
            Line             : function Foo { [CmdletBinding()] param( [MyEnum] $ME ) $ME }

            Statement        : [MyEnum]
            PositionMessage  : At C:\Users\danthom\OutOfOrderParseProblem\Code.ps1:1 char:41
                               + function Foo { [CmdletBinding()] param( [MyEnum] $ME ) $ME }
                               +                                         ~~~~~~~~
            PSScriptRoot     : C:\Users\danthom\OutOfOrderParseProblem
            PSCommandPath    : C:\Users\danthom\OutOfOrderParseProblem\Code.ps1
            CommandOrigin    : Internal
        ScriptStackTrace      : at <ScriptBlock>, C:\Users\danthom\OutOfOrderParseProblem\repro.ps1: line 184
                                at <ScriptBlock>, <No file>: line 1
    TargetSite  :
        Name          : ResolveTypeName
        DeclaringType : [System.Management.Automation.TypeOps]
        MemberType    : Method
        Module        : System.Management.Automation.dll
    Message     : Unable to find type [MyEnum].
    Data        : System.Collections.ListDictionaryInternal
    Source      : System.Management.Automation
    HResult     : -2146233087
    StackTrace  :
   at System.Management.Automation.TypeOps.ResolveTypeName(ITypeName typeName, IScriptExtent errorPos)
   at System.Management.Automation.Language.Compiler.GetAttribute(TypeConstraintAst typeConstraintAst)
   at System.Management.Automation.Language.Compiler.GetRuntimeDefinedParameter(ParameterAst parameterAst, Boolean& customParameterSet, Boolean&
usesCmdletBinding)
   at System.Management.Automation.Language.Compiler.GetParameterMetaData(ReadOnlyCollection`1 parameters, Boolean automaticPositions, Boolean&
usesCmdletBinding)
   at System.Management.Automation.CompiledScriptBlockData.InitializeMetadata()
   at System.Management.Automation.CompiledScriptBlockData.Compile(Boolean optimized)
   at System.Management.Automation.PSScriptCmdlet..ctor(ScriptBlock scriptBlock, Boolean useNewScope, Boolean fromScriptFile, ExecutionContext context)
   at System.Management.Automation.CommandProcessor.Init(IScriptCommandInfo scriptCommandInfo)
   at System.Management.Automation.CommandDiscovery.GetScriptAsCmdletProcessor(IScriptCommandInfo scriptCommandInfo, ExecutionContext context, Boolean
useNewScope, Boolean fromScriptFile, SessionStateInternal sessionState)
   at System.Management.Automation.CommandDiscovery.CreateCommandProcessorForScript(FunctionInfo functionInfo, ExecutionContext context, Boolean
useNewScope, SessionStateInternal sessionState)
   at System.Management.Automation.CommandDiscovery.LookupCommandProcessor(CommandInfo commandInfo, CommandOrigin commandOrigin, Nullable`1 useLocalScope,
SessionStateInternal sessionState)
   at System.Management.Automation.CommandDiscovery.LookupCommandProcessor(String commandName, CommandOrigin commandOrigin, Nullable`1 useLocalScope)
   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          : MyEnum
CategoryInfo          : InvalidOperation: (MyEnum:TypeName) [], RuntimeException
FullyQualifiedErrorId : TypeNotFound
InvocationInfo        :
    ScriptLineNumber : 1
    OffsetInLine     : 41
    HistoryId        : 1
    ScriptName       : C:\Users\danthom\OutOfOrderParseProblem\Code.ps1
    Line             : function Foo { [CmdletBinding()] param( [MyEnum] $ME ) $ME }

    Statement        : [MyEnum]
    PositionMessage  : At C:\Users\danthom\OutOfOrderParseProblem\Code.ps1:1 char:41
                       + function Foo { [CmdletBinding()] param( [MyEnum] $ME ) $ME }
                       +                                         ~~~~~~~~
    PSScriptRoot     : C:\Users\danthom\OutOfOrderParseProblem
    PSCommandPath    : C:\Users\danthom\OutOfOrderParseProblem\Code.ps1
    CommandOrigin    : Internal
ScriptStackTrace      : at <ScriptBlock>, C:\Users\danthom\OutOfOrderParseProblem\repro.ps1: line 184
                        at <ScriptBlock>, <No file>: line 1

Environment data

Name                           Value
----                           -----
PSVersion                      7.5.1
PSEdition                      Core
GitCommitId                    7.5.1
OS                             Microsoft Windows 10.0.26422
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

Visuals

No response

Metadata

Metadata

Assignees

Labels

Resolution-Won't FixThe issue won't be fixed, possibly due to compatibility reason.WG-Enginecore PowerShell engine, interpreter, and runtimeWG-ReviewedA Working Group has reviewed this and made a recommendation

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions