ProfileAsync.psm1


#region private

function New-BoundPowerShell
{
    <#
        .DESCRIPTION
        Reflection magic!

        Returns an instance of PowerShell with some internal runspace objects set to the objects
        from the current execution context. These objects are not supposed to be shared; race conditions
        may occur.
    #>


    [CmdletBinding()]
    param ()

    # A runspace to run our code asynchronously; pass in $Host to support Write-Host
    $Runspace = [runspacefactory]::CreateRunspace($Host)
    $Powershell = [powershell]::Create($Runspace)
    $Runspace.Open()

    # ArgumentCompleters are set on the ExecutionContext, not the SessionState
    # Note that $ExecutionContext is not an ExecutionContext, it's an EngineIntrinsics
    $Private = [System.Reflection.BindingFlags]'Instance, NonPublic'
    $ContextField = [System.Management.Automation.EngineIntrinsics].GetField('_context', $Private)
    $GlobalContext = $ContextField.GetValue($ExecutionContext)

    # Get the ArgumentCompleters. If null, initialise them.
    $ContextCACProperty = $GlobalContext.GetType().GetProperty('CustomArgumentCompleters', $Private)
    $ContextNACProperty = $GlobalContext.GetType().GetProperty('NativeArgumentCompleters', $Private)
    $CAC = $ContextCACProperty.GetValue($GlobalContext)
    $NAC = $ContextNACProperty.GetValue($GlobalContext)
    if ($null -eq $CAC)
    {
        $CAC = [System.Collections.Generic.Dictionary[string, scriptblock]]::new()
        $ContextCACProperty.SetValue($GlobalContext, $CAC)
    }
    if ($null -eq $NAC)
    {
        $NAC = [System.Collections.Generic.Dictionary[string, scriptblock]]::new()
        $ContextNACProperty.SetValue($GlobalContext, $NAC)
    }

    # Get the AutomationEngine and ExecutionContext of the runspace
    $RSEngineField = $Runspace.GetType().GetField('_engine', $Private)
    $RSEngine = $RSEngineField.GetValue($Runspace)
    $EngineContextField = $RSEngine.GetType().GetFields($Private) | Where-Object {$_.FieldType.Name -eq 'ExecutionContext'}
    $RSContext = $EngineContextField.GetValue($RSEngine)

    # Set the runspace to use the global ArgumentCompleters
    $ContextCACProperty.SetValue($RSContext, $CAC)
    $ContextNACProperty.SetValue($RSContext, $NAC)

    return $Powershell
}

#endregion private

#region public

function Import-ProfileAsync
{
    <#
        .SYNOPSIS
        Load your powershell profile asynchronously, so you can get to the prompt faster.

        .LINK
        https://github.com/fsackur/ProfileAsync

        .DESCRIPTION
        This command executes a scriptblock asynchronously using the current session's
        execution context. In simple terms, this runs code asynchronously in the caller's
        scope.

        This command is not the best tool if you do not need that specific behaviour.

        When used in a powershell profile, it effectively runs in the global scope. Things in the
        scriptblock will be available in the session when the scriptblock completes.

        This includes modules, functions, aliases, variables and argument completers.

        Warning:

        This command uses reflection hacks. PowerShell is designed to avoid async bugs in areas
        that we jam async code into. Your session may crash. Errors may be misleading. Do not use
        in server scripts.

        The risk is minimised in the designed use case:

        - use only in your powershell profile
        - only call this command once
        - call this command at the bottom
        - increase delay if you get errors
        - don't do other async stuff before the async code completes

        .PARAMETER ScriptBlock
        The code to be executed asynchronously.

        .PARAMETER Delay
        Interval, in milliseconds, to wait within the asynchronous runspace before executing the
        scriptblock.

        This is necessary because this command subverts normal runspace initialisation. Without
        this delay, command availability may be unreliable when the command is run at startup.

        This delay can be set to 0 when the command is run in a fully-initialised powershell
        session.

        10ms may be sufficient on a fast machine. 100-200ms should cover most recent machines.

        .PARAMETER PWSH_PROFILE_ASYNC_DISABLE
        Disables the async and scope features. Also accepted as an env var; parameter takes
        precedence.

        Use this to recover a crashing profile. Code will not be run in the global scope.
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, Position = 0)]
        [scriptblock]$ScriptBlock,

        [ValidateRange(0, 5000)]
        [PSDefaultValue(Help = "500ms")]
        [int]$Delay = 500,

        [switch]$PWSH_PROFILE_ASYNC_DISABLE
    )

    if ($PWSH_PROFILE_ASYNC_DISABLE -or $env:PWSH_PROFILE_ASYNC_DISABLE -imatch '^(1|true|yes)$')
    {
        . $ScriptBlock
        return
    }


    $PowerShell = New-BoundPowerShell

    # https://seeminglyscience.github.io/powershell/2017/09/30/invocation-operators-states-and-scopes
    $GlobalState = [psmoduleinfo]::new($false)
    $GlobalState.SessionState = $ExecutionContext.SessionState

    $PowerShell.Runspace.SessionStateProxy.PSVariable.Set('GlobalState', $GlobalState)
    $PowerShell.Runspace.SessionStateProxy.PSVariable.Set('ScriptBlock', $ScriptBlock)
    $PowerShell.Runspace.SessionStateProxy.PSVariable.Set('Delay', $Delay)


    "Starting asynchronous execution" | Write-Verbose
    $Wrapper = {
        [System.Diagnostics.DebuggerHidden()]
        param()

        # Runspace init is unsafe. Stack traces point to PSReadLine; not sure
        Start-Sleep -Milliseconds $Delay

        . $GlobalState {. $args[0]} $ScriptBlock
    }
    $AsyncResult = $Powershell.AddScript($Wrapper).BeginInvoke()


    $SourceIdentifier = "__ProfileAsyncCleanup__" + [guid]::NewGuid()
    $HandlerParams = @{
        MessageData = $AsyncResult
        InputObject = $Powershell
        EventName = "InvocationStateChanged"
        SourceIdentifier = $SourceIdentifier
    }
    $null = Register-ObjectEvent @HandlerParams -Action {
        $AsyncResult = $Event.MessageData
        $Powershell = $Event.Sender
        $SourceIdentifier = $EventSubscriber.SourceIdentifier

        if ($Powershell.Streams.Error)
        {
            $Powershell.Streams.Error | Out-String | Write-Host -ForegroundColor Red
            $Powershell.Streams.Error.Clear()
        }

        if ($Powershell.InvocationStateInfo.State -ge 2)
        {
            try
            {
                $Powershell.EndInvoke($AsyncResult)
            }
            catch
            {
                $_ | Out-String | Write-Host -ForegroundColor Red
            }

            Unregister-Event $SourceIdentifier
            Get-Job $SourceIdentifier | Remove-Job

            "Asynchronous execution complete", "State: $($Powershell.InvocationStateInfo.State)" | Write-Verbose
        }
    }
}

#endregion public