PowerShell / PowerShell

PowerShell for every system!
https://microsoft.com/PowerShell
MIT License
45.6k stars 7.31k forks source link

Script-scope variables not visible when intermediate caller is external; but script-scope aliases ARE visible #20204

Open jazzdelightsme opened 1 year ago

jazzdelightsme commented 1 year ago

Steps to reproduce

We have a module, which uses script-scope variables ($script:varName) for "private state" (they are not exported from the module).

If a command from our module calls an external script (using the call operator &) ("frame A"), and that external script ("frame B") calls a function in our module ("frame C"), the code in our module is unable to see its script-scope variables (in frame C).

That alone is a problem, but there is also an inconsistency with aliases: although the script-scope variables seem to have disappeared from view in frame C, script-scope aliases in our module are visible (and in fact are visible in frame B, as if they had been exported from the module, though they have not).

I realize this is probably not a SUPER common calling pattern, but this just bit us in real code, so I am interested in a solution.

Here is a simplification of the problem, which we hit in real production code:

del Repro -Recurse -Force -EA Ignore
mkdir Repro
pushd Repro
try
{
    New-ModuleManifest .\ReproMod.psd1 -RootModule .\ReproMod.psm1 `
                                       -FunctionsToExport 'MyPublicFunction1', 'MyPublicFunction2' `
                                       -AliasesToExport ''

    'Set-StrictMode -Version Latest ; . $PSScriptRoot\ReproModImpl.ps1' > .\ReproMod.psm1

    @'
$script:MyPrivateVar = 0

function MyPublicFunction1
{
    [CmdletBinding()]
    param()

    try
    {
        Write-Host "Hi. My private var is: $($script:MyPrivateVar)"
    }
    catch
    {
        Write-Error $_
    }
}

function MyPublicFunction2
{
    [CmdletBinding()]
    param( $ExternalScript )

    try
    {
        & $ExternalScript
    }
    catch
    {
        Write-Error $_
    }
}

Set-Alias -Name MPF1 -Value MyPublicFunction1 -Scope Script
'@ > .\ReproModImpl.ps1

    'MyPublicFunction1' > .\ExternalScript_CallsFunction.ps1
    'MPF1' > .\ExternalScript_CallsAliasThatShouldBePrivate.ps1

    Import-Module .\ReproMod.psd1 -Force

    MyPublicFunction2 ((Resolve-Path .\ExternalScript_CallsFunction.ps1).ProviderPath)

    MyPublicFunction2 ((Resolve-Path .\ExternalScript_CallsAliasThatShouldBePrivate.ps1).ProviderPath)
}
finally
{
    popd
}

Expected Behavior

When running code in our module, it should always be able to see its own script-scope variables. And the external script should not be able to see the script-scope alias (MPF1).

Actual Behavior

When called from an external script, our module cannot see its own script-scope variables:

The variable '$script:MyPrivateVar' cannot be retrieved because it has not been set.

And yet the external script is able to call our script-scope alias (MPF1).

Error details

PS C:\Users\danthom> Get-Error

Exception             :
    Type        : System.Management.Automation.RuntimeException
    ErrorRecord :
        Exception             :
            Type    : System.Management.Automation.ParentContainsErrorRecordException
            Message : The variable '$script:MyPrivateVar' cannot be retrieved because it has not been set.
            HResult : -2146233087
        TargetObject          : script:MyPrivateVar
        CategoryInfo          : InvalidOperation: (script:MyPrivateVar:String) [], ParentContainsErrorRecordException
        FullyQualifiedErrorId : VariableIsUndefined
        InvocationInfo        :
            ScriptLineNumber : 10
            OffsetInLine     : 46
            HistoryId        : 17
            ScriptName       : C:\Users\danthom\Repro\ReproModImpl.ps1
            Line             :         Write-Host "Hi. My private var is: $($script:MyPrivateVar)"

            Statement        : $script:MyPrivateVar
            PositionMessage  : At C:\Users\danthom\Repro\ReproModImpl.ps1:10 char:46
                               +         Write-Host "Hi. My private var is: $($script:MyPrivateVar)"
                               +                                              ~~~~~~~~~~~~~~~~~~~~
            PSScriptRoot     : C:\Users\danthom\Repro
            PSCommandPath    : C:\Users\danthom\Repro\ReproModImpl.ps1
            CommandOrigin    : Internal
        ScriptStackTrace      : at MyPublicFunction1, C:\Users\danthom\Repro\ReproModImpl.ps1: line 10
                                at <ScriptBlock>,
C:\Users\danthom\Repro\ExternalScript_CallsAliasThatShouldBePrivate.ps1: line 1
                                at MyPublicFunction2, C:\Users\danthom\Repro\ReproModImpl.ps1: line 25
                                at <ScriptBlock>, <No file>: line 50
    TargetSite  :
        Name          : CheckActionPreference
        DeclaringType : System.Management.Automation.ExceptionHandlingOps, System.Management.Automation, Version=7.4.0.5,
Culture=neutral, PublicKeyToken=31bf3856ad364e35
        MemberType    : Method
        Module        : System.Management.Automation.dll
    Message     : The variable '$script:MyPrivateVar' cannot be retrieved because it has not been set.
    Data        : System.Collections.ListDictionaryInternal
    Source      : System.Management.Automation
    HResult     : -2146233087
    StackTrace  :
   at System.Management.Automation.ExceptionHandlingOps.CheckActionPreference(FunctionContext funcContext, Exception
exception)
   at System.Management.Automation.Interpreter.ActionCallInstruction`2.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.EnterTryCatchFinallyInstruction.Run(InterpretedFrame frame)
TargetObject          : script:MyPrivateVar
CategoryInfo          : InvalidOperation: (script:MyPrivateVar:String) [], RuntimeException
FullyQualifiedErrorId : VariableIsUndefined
InvocationInfo        :
    ScriptLineNumber : 10
    OffsetInLine     : 46
    HistoryId        : 17
    ScriptName       : C:\Users\danthom\Repro\ReproModImpl.ps1
    Line             :         Write-Host "Hi. My private var is: $($script:MyPrivateVar)"

    Statement        : $script:MyPrivateVar
    PositionMessage  : At C:\Users\danthom\Repro\ReproModImpl.ps1:10 char:46
                       +         Write-Host "Hi. My private var is: $($script:MyPrivateVar)"
                       +                                              ~~~~~~~~~~~~~~~~~~~~
    PSScriptRoot     : C:\Users\danthom\Repro
    PSCommandPath    : C:\Users\danthom\Repro\ReproModImpl.ps1
    CommandOrigin    : Internal
ScriptStackTrace      : at MyPublicFunction1, C:\Users\danthom\Repro\ReproModImpl.ps1: line 10
                        at <ScriptBlock>, C:\Users\danthom\Repro\ExternalScript_CallsAliasThatShouldBePrivate.ps1: line 1
                        at MyPublicFunction2, C:\Users\danthom\Repro\ReproModImpl.ps1: line 25
                        at <ScriptBlock>, <No file>: line 50

Environment data

Name                           Value
----                           -----
PSVersion                      7.4.0-preview.5
PSEdition                      Core
GitCommitId                    7.4.0-preview.5
OS                             Microsoft Windows 10.0.25933
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0
jhoneill commented 1 year ago

If you run the import-module with -verbose you'll see it if the PSD doesn't specify aliases it imports all aliases from the function even if they are declared with scope script.. The workround is use the PSD to manage what's internal and external. But it certainly looks wrong that the scope of the alias is disregarded.

My brain isn't properly switched on yet because I use script: scoped variables and what's failing in your example - and I thought might be strict mode going too far, but I've tried it and it isn't - looks like code that I use most days. At the moment I can't see what's different between the working and non working...

jazzdelightsme commented 1 year ago

(Good point about the -AliasesToExport not being controlled. In our "real world" repro, the aliases to export are specified (and don't include the alias that is getting called). I've amended the repro steps to include -AliasesToExport '', and it still repro's.)

I tried working around the "internal aliases visible to the external script" part of the problem by changing my aliases to actual full functions (which just manually forward on to the original targets), but that didn't help--all private functions are visible to the external script. 😭

This is undesirable, because some of our internal functions (in the real-world case) are proxies for commonly-used built-in functions. For example, we proxy Write-Host--we use the proxy to capture our module's output, but don't want output from other things. So when the external script calls Write-Host, it gets our internal version; our internal version tries to do its thing, which uses a $script:variable; and that fails miserably (per this bug).

This made me realize that this is just how scoping works in general in PowerShell. It is kind of "weird", IMO, that I can see the otherwise-internal state of all of my callers (all script and local variables, that are not created with -Option Private; all functions and aliases, whether exported or not)... but that is the way PowerShell does things.

Which reminded me of a trick I could use to get out of this jam. I've had run-ins with scoping troubles before, and I learned (probably from @lzybkr) that a) modules always "inherit" from the global scope, not the current local scope; and b) you can use the call operator (&) with a module and execute code (a ScriptBlock) in the context of that module. Putting these two things together: if we replace & $ExternalScript in the repro with & (New-Module { }) $ExternalScript, voilà: once the external script is magically running with some other "cousin" (instead of descendant) scope, when calling back in to the ReproMod module, the ReproMod is able to see its $script:variables as usual, so that works; AND the external script is NOT able to see the module's internal aliases or functions.

So that's the workaround that we will use... but I still claim it seems weird that the ReproMod could ever not be able to see its own $script:variables, when accessed via the script: prefix.