8000 Occasional NullReferenceException in Microsoft.PowerShell.ProgresPane.Hide() when updating multiple progress bars on PS 7.4.0 · Issue #21021 · PowerShell/PowerShell · GitHub
[go: up one dir, main page]

Skip to content

Occasional NullReferenceException in Microsoft.PowerShell.ProgresPane.Hide() when updating multiple progress bars on PS 7.4.0 #21021

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
5 tasks done
kborowinski opened this issue Jan 8, 2024 · 19 comments · Fixed by #25440
Labels
Issue-Bug Issue has been identified as a bug in the product WG-Reviewed A Working Group has reviewed this and made a recommendation

Comments

@kborowinski
Copy link
Contributor
kborowinski commented Jan 8, 2024

Prerequisites

Steps to reproduce

This is not happening on PS 5.1

Issue was first identified when expanding multiple 7z archives with Start-ThreadJob and Expand-7Zip cmdlet from 7Zip4PowerShell module. It might be also related to this issue with corresponding PR

Issue description:

When using a cmdlet that opens multiple progress panes on PS 7.4.0, a NullReferenceException is thrown occasionally on progress pan completion. Please refer to above links for more details.

Workaround:
Disable progress before calling cmdlet that opens multiple progress panes with $ProgressPreference = 'SilentlyContinue'

Expected behavior

NullReferenceException on ProgressPane completion should not happen

Actual behavior

Occasionally an exception is thrown that "Object reference not set to an instance of an object" at Microsoft.PowerShell.ProgressPane.Hide()

Error details

TargetSite      : Void Hide()
Message         : Object reference not set to an instance of an object.
Data            : {}
InnerException  :
HelpLink        :
Source          : Microsoft.PowerShell.ConsoleHost
Result          : -2147467261
StackTrace      :   at Microsoft.PowerShell.ProgressPane.Hide()
                    at Microsoft.PowerShell.ProgressPane.Show(PendingProgress pendingProgress)
                    at Microsoft.PowerShell.ConsoleHostUserInterface.HandleIncomingProgressRecord(Int64 sourceId, ProgressRecord record)
                    at Microsoft.PowerShell.ConsoleHostUserInterface.WriteProgress(Int64 sourceId, ProgressRecord record)
                    at System.Management.Automation.Internal.Host.InternalHostUserInterface.WriteProgress(Int64 sourceId, ProgressRecord record)
                    at System.Management.Automation.MshCommandRuntime.WriteProgress(Int64 sourceId, ProgressRecord progressRecord, Boolean overrideInquire)
                    at System.Management.Automation.Cmdlet.WriteProgress(ProgressRecord progressRecord) at SevenZip4PowerShell.ThreadedCmdlet.EndProcessing() in C:\Users\---\Documents\Visual Studio 2022\Projects\72ip4PowerShe11\72ip4Powershell\ThreadedCmdlet.cs:line 29

Environment data

Name                           Value
----                           -----
PSVersion                      7.4.0
PSEdition                      Core
GitCommitId                    7.4.0
OS                             Microsoft Windows 10.0.19045
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0, 5.0, 5.1, 6.0, 7.0}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

Visuals

294245576-42cedbfa-b55a-413f-a955-e927ebb39adc


Edit: 2025-01-31

Script to reproduce the NRE's:

  1. Save as Test-ProgressBar.ps1
  2. Dot-source
  3. Execute Test-ProgressBar -Verbose to trigger NREs
  4. Execute Test-ProgressBar no errors
#Requires -Version 7.5 -Modules Microsoft.PowerShell.ThreadJob

function Test-ProgressBar {
    [CmdletBinding()]
    param()

    $Global:ProgressPreference = 'Continue'
    $PSStyle.Progress.MaxWidth = $Host.UI.RawUI.WindowSize.Width

    $splatThreadJob = @{
        ThrottleLimit = [Environment]::ProcessorCount
        StreamingHost = $Host
    }

    $isVerbose = $PSBoundParameters['Verbose'] -eq $true

    $threadJobs = 1..50 | ForEach-Object {
        Start-ThreadJob -Name $_ -ScriptBlock {
            try {
                $VerbosePreference = if ($Using:isVerbose) {'Continue'} else {'SilentlyContinue'}

                $progressBarId = $Using:_
                $progressBar = 'ProgressBar-{0}' -f $progressBarId

                foreach ($n in 0..100) {
                    Write-Progress -Activity $progressBar -Status ('{0}%' -f $n) -PercentComplete $n -Id $progressBarId
                    Start-Sleep -Milliseconds (Get-Random -Minimum 15 -Maximum 45)
                }
                Write-Progress -Activity $progressBar -Completed -Id $progressBarId

                Write-Verbose -Message (
                    'Progress bar completted: "{0}"' -f
                        $progressBar
                )
            } catch {
                # Write-Warning ('[PID: "{0}" | RID: "{1}"]' -f
                #     $PID,
                #     [Management.Automation.Runspaces.Runspace]::DefaultRunspace.Id,
                #     $_.Exception.Message
                # )
                # Wait-Debugger
                throw $_
            }
        } @splatThreadJob
    }

    $null = Wait-Job -Job $threadJobs | Receive-Job
    Remove-Job -Job $threadJobs -Force
}

Visuals:

Image

@kborowinski kborowinski added the Needs-Triage The issue is new and needs to be triaged by a work group. label Jan 8, 2024
@SteveL-MSFT SteveL-MSFT added Issue-Bug Issue has been identified as a bug in the product and removed Needs-Triage The issue is new and needs to be triaged by a work group. labels Jan 8, 2024
@centis
Copy link
centis commented Apr 24, 2024

I see this every time that Invoke-Command is executed on a -Session if the connection is broken during the command. For example, "Invoke-Command -Session $Session -ScriptBlock { shutdown -t 0 -r }". It does waits for the timeout and then throws the null object exception.

This may make it easier to reproduce...

@microsoft-github-policy-service microsoft-github-policy-service bot added the Resolution-No Activity Issue has had no activity for 6 months or more label Oct 22, 2024
@kborowinski
Copy link
Contributor Author

Pinging to keep this issue open

@microsoft-github-policy-service microsoft-github-policy-service bot removed the Resolution-No Activity Issue has had no activity for 6 months or more label Oct 22, 2024
@kborowinski
Copy link
Contributor Author

@SteveL-MSFT Unfortunately this is still happening on PowerShell 7.5.0 GA and PowerShell 7.6.0-preview.2. I had to globally disable the progress bar to make sure that my heavily parallelized scripts do not break.

@iSazonov
Copy link
Collaborator

@kborowinski I see you use Start-ThreadJob so the #17497 issue is the same.
There was a fix #17498 not merged. We did not get understanding a root of the issue.

Can you share a simple script to reproduce the issue?

@kborowinski
Copy link
Contributor Author

@iSazonov Tomorrow I'll try to come up with sensible reproduction script and will let you know.

@kborowinski
Copy link
Contributor Author
kborowinski commented Jan 29, 2025
8000

@SteveL-MSFT @iSazonov

I was able to create a reproducible PowerShell script that consistently triggers the NullReferenceException. What I have found is that NREs are triggered when using Write-Verbose with concurrent progress updates.

  • The script creates test data in C:\_ProgressTest\
  • The test data will occupy approximately 1.5GB:
    • The test data consists of 128 subfolders, each containing a random number of text files (from 128 to 1024) of various sizes.
    • After the test data is created using Test-Setup, you can rerun Test-Compress and Test-Expand multiple times without needing to run Test-Setup again.
  • Running tests without -Verbose works fine, but with -Verbose, errors occur, particularly in Test-Expand

Note: The NREs are happening also when using Write-Host or Write-Information, when streaming to the $Host in general


Save the following script as ProgressBarTests.ps1:

#Requires -Version 7.5 -Modules Microsoft.PowerShell.ThreadJob, 7Zip4Powershell

$Global:ProgressPreference = 'Continue'
# $PSStyle.Progress.MaxWidth = 240

$numberOfSubfolders = 128
$maximumNumberOfFilesPerSubfolder = 1KB

# Define the main directory path
$rootFolder = 'C:\_ProgressTest'
$sourceFolders = Join-Path -Path $rootFolder -ChildPath 'SourceFolders'
$compressedFolders = Join-Path -Path $rootFolder -ChildPath 'CompressedFolders'

# Create folder structure
$null = New-Item -Path $rootFolder -ItemType Directory -Force
$null = New-Item -Path $sourceFolders -ItemType Directory -Force
$null = New-Item -Path $compressedFolders -ItemType Directory -Force

$splatThreadJob = @{
    ThrottleLimit = [Environment]::ProcessorCount
    StreamingHost = $Host
}

function Test-Setup {
    [CmdletBinding()]
    param()

    # Remove pre-generated folders if any
    Remove-Item -Path "$sourceFolders\*" -Recurse -Force -ErrorAction Stop
    # Remove archives
    Remove-Item -Path "$compressedFolders\*" -Recurse -Force -ErrorAction Stop

    # Create subdirectories and files in parallel
    $threadJobs = 1..$numberOfSubfolders | ForEach-Object {
        Start-ThreadJob -Name $_ -ScriptBlock {
            function Get-RandomNumber {Get-Random -Minimum 128 -Maximum $Using:maximumNumberOfFilesPerSubfolder}

            # Subfolder path
            $subfolderPath = Join-Path -Path $Using:sourceFolders -ChildPath "Subfolder$Using:_"

            $fileCount = Get-RandomNumber

            Write-Host ('Creating "{0}" with {1} files' -f $subfolderPath, $fileCount)

            # Create the subfolder
            New-Item -Path $subfolderPath -ItemType Directory -Force

            # Create files with random content
            1..$fileCount | ForEach-Object {
                $filePath = Join-Path -Path $subfolderPath -ChildPath "file$_.txt"
                $fileContent = [Guid]::NewGuid().Guid * (Get-RandomNumber)
                Set-Content -Path $filePath -Value $fileContent -Force
            }
        } @splatThreadJob
    }

    $null = Wait-Job -Job $threadJobs
    Remove-Job -Job $threadJobs -Force
}

function Test-Compress {
    [CmdletBinding()]
    param()

    $isVerbose = $PSBoundParameters['Verbose'] -eq $true

    $threadJobs = Get-ChildItem -Path $sourceFolders -Directory | ForEach-Object {
        Start-ThreadJob -Name $_ -ScriptBlock {
            try {
                $VerbosePreference = if ($Using:isVerbose) {'Continue'} else {'SilentlyContinue'}

                $subfolderPath = $Using:_
                $archivePath = Join-Path -Path $Using:compressedFolders -ChildPath ('{0}.7z' -f $subfolderPath.BaseName)

                #Check if target archive exists and remove it
                if (Test-Path -Path $archivePath -PathType Leaf) {
                    Remove-Item -Path $archivePath -Force
                }

                # Define the parameters for Compress-7Zip cmdlet
                $splatCompress = @{
                    ArchiveFileName         = $archivePath
                    Path                    = $subfolderPath
                    PreserveDirectoryRoot   = $true
                    Verbose                 = $false
                    ErrorAction             = 'Stop'
                }
                Compress-7Zip @splatCompress
                Write-Verbose -Message (
                    'Compression completted: "{0}"' -f
                        $archivePath
                )
            } catch {
                # Write-Warning ('[PID: "{0}" | RID: "{1}"]' -f
                #     $PID,
                #     [Management.Automation.Runspaces.Runspace]::DefaultRunspace.Id,
                #     $_.Exception.Message
                # )
                # Wait-Debugger
                throw $_
            }
        } @splatThreadJob
    }

    if (-not $threadJobs) {return}
    $null = Wait-Job -Job $threadJobs | Receive-Job
    Remove-Job -Job $threadJobs -Force
}

function Test-Expand {
    [CmdletBinding()]
    param()

    $isVerbose = $PSBoundParameters['Verbose'] -eq $true

    $threadJobs = Get-ChildItem -Path $compressedFolders -File | ForEach-Object {
        Start-ThreadJob -Name $_ -ScriptBlock {
            try {
                $VerbosePreference = if ($Using:isVerbose) {'Continue'} else {'SilentlyContinue'}

                $subfolder = $Using:_
                $subfolderPath = Join-Path -Path $Using:sourceFolders -ChildPath $subfolder.BaseName

                # Check if the target directory already exists and remove it.
                if (Test-Path -Path $subfolderPath -PathType Container) {
                    Remove-Item -Path $subfolderPath -Recurse -Force
                }

                # Expand the archive to the target directory.
                $splatExpand = @{
                    ArchiveFileName = $subfolder
                    TargetPath      = $Using:sourceFolders
                    Verbose         = $false
                    ErrorAction     = 'Stop'
                }
                Expand-7Zip @splatExpand
                Write-Verbose -Message (
                    'Expansion completted: "{0}"' -f
                        $subfolder
                )
            } catch {
                # Write-Warning ('[PID: "{0}" | RID: "{1}"]' -f
                #     $PID,
                #     [Management.Automation.Runspaces.Runspace]::DefaultRunspace.Id,
                #     $_.Exception.Message
                # )
                # Wait-Debugger
                throw $_
            }
        } @splatThreadJob
    }

    if (-not $threadJobs) {return}
    $null = Wait-Job -Job $threadJobs | Receive-Job
    Remove-Job -Job $threadJobs -Force
}

How to Use the Script

Setup:

  1. Ensure PowerShell 7.5.0+ is installed.
  2. Install required modules (if missing):
    Install-Module -Name Microsoft.PowerShell.ThreadJob, 7Zip4Powershell -Force
  3. Dot-source the script:
    . .\ProgressBarTests.ps1

Steps to Run Tests

  1. Set up the test environment (creates test files & folders in C:\_ProgressTest\):
    Test-Setup

Image

  1. Run compression (works fine without -Verbose):
    Test-Compress

Image

  1. Run expansion (also works fine without -Verbose):
    Test-Expand

Image

  1. Run with -Verbose to trigger the error:
    Test-Compress -Verbose  # May fail occasionally

Image

Test-Expand -Verbose  # Fails more frequently

Image

Error Report

Exception             : 
    Type                        : System.Management.Automation.RuntimeException
    ErrorRecord                 : 
        Exception             : 
            Type       : System.NullReferenceException
            TargetSite : 
                Name          : Hide
                DeclaringType : [Microsoft.PowerShell.ProgressPane]
                MemberType    : Method
                Module        : Microsoft.PowerShell.ConsoleHost.dll
            Message    : Object reference not set to an instance of an object.
            Source     : Microsoft.PowerShell.ConsoleHost
            HResult    : -2147467261
            StackTrace : 
   at Microsoft.PowerShell.ProgressPane.Hide()
   at Microsoft.PowerShell.ConsoleHostUserInterface.WriteImpl(String value, Boolean newLine)
   at System.Management.Automation.Internal.Host.InternalHostUserInterface.WriteVerboseRecord(VerboseRecord record)
   at System.Management.Automation.MshCommandRuntime.WriteVerbose(VerboseRecord record, Boolean overrideInquire)
   at Microsoft.PowerShell.Commands.WriteVerboseCommand.ProcessRecord()
   at System.Management.Automation.CommandProcessor.ProcessRecord()
        CategoryInfo          : NotSpecified: (:) [Write-Verbose], NullReferenceException
        FullyQualifiedErrorId : System.NullReferenceException,Microsoft.PowerShell.Commands.WriteVerboseCommand
        InvocationInfo        : 
            MyCommand        : Write-Verbose
            ScriptLineNumber : 22
            OffsetInLine     : 17
            HistoryId        : 1
            Line             : Write-Verbose -Message (
                               
            Statement        : Write-Verbose -Message (
                               'Compression completted: "{0}"' -f
                               $archivePath
                               )
            PositionMessage  : At line:22 char:17
                               +                 Write-Verbose -Message (
                               +                 ~~~~~~~~~~~~~~~~~~~~~~~~
            InvocationName   : Write-Verbose
            CommandOrigin    : Internal
        ScriptStackTrace      : at <ScriptBlock>, <No file>: line 22
    WasThrownFromThrowStatement : True
    TargetSite                  : 
        Name          : CheckActionPreference
        DeclaringType : [System.Management.Automation.ExceptionHandlingOps]
        MemberType    : Method
        Module        : System.Management.Automation.dll
    Message                     : Object reference not set to an instance of an object.
    Data                        : System.Collections.ListDictionaryInternal
    InnerException              : 
        Type       : System.NullReferenceException
        TargetSite : 
            Name          : Hide
            DeclaringType : [Microsoft.PowerShell.ProgressPane]
            MemberType    : Method
            Module        : Microsoft.PowerShell.ConsoleHost.dll
        Message    : Object reference not set to an instance of an object.
        Source     : Microsoft.PowerShell.ConsoleHost
        HResult    : -2147467261
        StackTrace : 
   at Microsoft.PowerShell.ProgressPane.Hide()
   at Microsoft.PowerShell.ConsoleHostUserInterface.WriteImpl(String value, Boolean newLine)
   at System.Management.Automation.Internal.Host.InternalHostUserInterface.WriteVerboseRecord(VerboseRecord record)
   at System.Management.Automation.MshCommandRuntime.WriteVerbose(VerboseRecord record, Boolean overrideInquire)
   at Microsoft.PowerShell.Commands.WriteVerboseCommand.ProcessRecord()
   at System.Management.Automation.CommandProcessor.ProcessRecord()
    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)
   at System.Management.Automation.Interpreter.Interpreter.Run(InterpretedFrame frame)
   at System.Management.Automation.Interpreter.LightLambda.RunVoid1[T0](T0 arg0)
   at System.Management.Automation.DlrScriptCommandProcessor.RunClause(Action`1 clause, Object dollarUnderbar, Object inputToProcess)
--- End of stack trace from previous location ---
   at System.Management.Automation.Internal.PipelineProcessor.SynchronousExecuteEnumerate(Object input)
   at System.Management.Automation.Runspaces.LocalPipeline.InvokeHelper()
   at System.Management.Automation.Runspaces.LocalPipeline.InvokeThreadProc()
CategoryInfo          : InvalidResult: (:) [], RuntimeException
FullyQualifiedErrorId : JobStateFailed

@kborowinski
Copy link
Contributor Author

Here is a version of the same script that uses ForEach-Object -Parallel. It does not trigger NREs. So it looks like this is Microsoft.PowerShell.ThreadJob issue?

#Requires -Version 7.5 -Modules 7Zip4Powershell

$Global:ProgressPreference = 'Continue'
# $PSStyle.Progress.MaxWidth = 240

$numberOfSubfolders = 128
$maximumNumberOfFilesPerSubfolder = 1KB

# Define the main directory path
$rootFolder = 'C:\_ProgressTest'
$sourceFolders = Join-Path -Path $rootFolder -ChildPath 'SourceFolders'
$compressedFolders = Join-Path -Path $rootFolder -ChildPath 'CompressedFolders'

# Create folder structure
$null = New-Item -Path $rootFolder -ItemType Directory -Force
$null = New-Item -Path $sourceFolders -ItemType Directory -Force
$null = New-Item -Path $compressedFolders -ItemType Directory -Force

$splatForEachParallel = @{
    ThrottleLimit = [Environment]::ProcessorCount
}

function Test-Setup {
    [CmdletBinding()]
    param()

    # Remove pre-generated folders if any
    Remove-Item -Path "$sourceFolders\*" -Recurse -Force -ErrorAction Stop
    # Remove archives
    Remove-Item -Path "$compressedFolders\*" -Recurse -Force -ErrorAction Stop

    # Create subdirectories and files in parallel
    1..$numberOfSubfolders | ForEach-Object -Parallel {
        function Get-RandomNumber {Get-Random -Minimum 128 -Maximum $Using:maximumNumberOfFilesPerSubfolder}

        # Subfolder path
        $subfolderPath = Join-Path -Path $Using:sourceFolders -ChildPath "Subfolder$_"

        $fileCount = Get-RandomNumber

        Write-Host ('Creating "{0}" with {1} files' -f $subfolderPath, $fileCount)

        # Create the subfolder
        $null = New-Item -Path $subfolderPath -ItemType Directory -Force

        # Create files with random content
        1..$fileCount | ForEach-Object {
            $filePath = Join-Path -Path $subfolderPath -ChildPath "file$_.txt"
            $fileContent = [Guid]::NewGuid().Guid * (Get-RandomNumber)
            Set-Content -Path $filePath -Value $fileContent -Force
        }

    } @splatForEachParallel
}

function Test-Compress {
    [CmdletBinding()]
    param()

    $isVerbose = $PSBoundParameters['Verbose'] -eq $true

    Get-ChildItem -Path $sourceFolders -Directory | ForEach-Object -Parallel {
        try {
            $VerbosePreference = if ($Using:isVerbose) {'Continue'} else {'SilentlyContinue'}

            $subfolderPath = $_
            $archivePath = Join-Path -Path $Using:compressedFolders -ChildPath ('{0}.7z' -f $subfolderPath.BaseName)

            #Check if target archive exists and remove it
            if (Test-Path -Path $archivePath -PathType Leaf) {
                Remove-Item -Path $archivePath -Force
            }

            # Define the parameters for Compress-7Zip cmdlet
            $splatCompress = @{
                ArchiveFileName         = $archivePath
                Path                    = $subfolderPath
                PreserveDirectoryRoot   = $true
                Verbose                 = $false
                ErrorAction             = 'Stop'
            }
            Compress-7Zip @splatCompress
            Write-Verbose -Message (
                'Compression completted: "{0}"' -f
                    $archivePath
            )
        } catch {
            # Write-Warning ('[PID: "{0}" | RID: "{1}"]' -f
            #     $PID,
            #     [Management.Automation.Runspaces.Runspace]::DefaultRunspace.Id,
            #     $_.Exception.Message
            # )
            # Wait-Debugger
            throw $_
        }
    } @splatForEachParallel
}

function Test-Expand {
    [CmdletBinding()]
    param()

    $isVerbose = $PSBoundParameters['Verbose'] -eq $true

    Get-ChildItem -Path $compressedFolders -File | ForEach-Object -Parallel {
        try {
            $VerbosePreference = if ($Using:isVerbose) {'Continue'} else {'SilentlyContinue'}

            $subfolder = $_
            $subfolderPath = Join-Path -Path $Using:sourceFolders -ChildPath $subfolder.BaseName

            # Check if the target directory already exists and remove it.
            if (Test-Path -Path $subfolderPath -PathType Container) {
                Remove-Item -Path $subfolderPath -Recurse -Force
            }

            # Expand the archive to the target directory.
            $splatExpand = @{
                ArchiveFileName = $subfolder
                TargetPath      = $Using:sourceFolders
                Verbose         = $false
                ErrorAction     = 'Stop'
            }
            Expand-7Zip @splatExpand
            Write-Verbose -Message (
                'Expansion completted: "{0}"' -f
                    $subfolder
            )
        } catch {
            # Write-Warning ('[PID: "{0}" | RID: "{1}"]' -f
            #     $PID,
            #     [Management.Automation.Runspaces.Runspace]::DefaultRunspace.Id,
            #     $_.Exception.Message
            # )
            # Wait-Debugger
            throw $_
        }
    } @splatForEachParallel
}

@iSazonov
Copy link
Collaborator

@kborowinski Thanks for great repro!

Here is a version of the same script that uses ForEach-Object -Parallel. It does not trigger NREs. So it looks like this is Microsoft.PowerShell.ThreadJob issue?

I don't think so. Root of the issue is most likely in conhost. From the demo I see NREs throw after verboses push progress bars out of the window. Perhaps this will allow us to make even simpler repro based on two "threads" or even one.

@kborowinski
Copy link
Contributor Author
kborowinski commented Jan 31, 2025

@iSazonov I came up with much simpler version that reproduces the same behavior. No need for initial test setup, however it still requires ~50 threads to reproduce reliably. I will also post it at the top.

  1. Save as Test-ProgressBar.ps1
  2. Dot-source
  3. Execute Test-ProgressBar -Verbose to trigger NREs
#Requires -Version 7.5 -Modules Microsoft.PowerShell.ThreadJob

function Test-ProgressBar {
    [CmdletBinding()]
    param()

    $Global:ProgressPreference = 'Continue'
    $PSStyle.Progress.MaxWidth = $Host.UI.RawUI.WindowSize.Width

    $splatThreadJob = @{
        ThrottleLimit = [Environment]::ProcessorCount
        StreamingHost = $Host
    }

    $isVerbose = $PSBoundParameters['Verbose'] -eq $true

    $threadJobs = 1..50 | ForEach-Object {
        Start-ThreadJob -Name $_ -ScriptBlock {
            try {
                $VerbosePreference = if ($Using:isVerbose) {'Continue'} else {'SilentlyContinue'}

                $progressBarId = $Using:_
                $progressBar = 'ProgressBar-{0}' -f $progressBarId

                foreach ($n in 0..100) {
                    Write-Progress -Activity $progressBar -Status ('{0}%' -f $n) -PercentComplete $n -Id $progressBarId
                    Start-Sleep -Milliseconds (Get-Random -Minimum 15 -Maximum 45)
                }
                Write-Progress -Activity $progressBar -Completed -Id $progressBarId

                Write-Verbose -Message (
                    'Progress bar completted: "{0}"' -f
                        $progressBar
                )
            } catch {
                # Write-Warning ('[PID: "{0}" | RID: "{1}"]' -f
                #     $PID,
                #     [Management.Automation.Runspaces.Runspace]::DefaultRunspace.Id,
                #     $_.Exception.Message
                # )
                # Wait-Debugger
                throw $_
            }
        } @splatThreadJob
    }

    $null = Wait-Job -Job $threadJobs | Receive-Job
    Remove-Job -Job $threadJobs -Force
}

Visuals:

Image

@iSazonov
Copy link
Collaborator

@kborowinski Thanks! We get the error more fast if console windows height will be 4-5 line.

Could you please suppress virtual terminal support (set env variable TERM=dumb ) and try run your test again?

@kborowinski
Copy link
Contributor Author

@iSazonov Yeah, suppressing virtual terminal support helps:

Image

@iSazonov
Copy link
Collaborator

@kborowinski Thanks for confirmation!

PSAnsiRendering was introduced in #13758 (#15864)
Example for verbose method (there are others):

if (SupportsVirtualTerminal)
{
WriteLine(GetFormatStyleString(FormatStyle.Verbose) + StringUtil.Format(ConsoleHostUserInterfaceStrings.VerboseFormatString, message) + PSStyle.Instance.Reset);
}
else
{
WriteLine(
VerboseForegroundColor,
VerboseBackgroundColor,
StringUtil.Format(ConsoleHostUserInterfaceStrings.VerboseFormatString, message));
}

New WriteLine() method directly call WriteToConsole() method (WriteImpl()->ConsoleTextWriter.Write()->WriteToConsole()),

public override void Write(string value)
{
WriteImpl(value, newLine: false);
}

but old one calls the method using wrapper with global lock _instanceLock

public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value)
{
Write(foregroundColor, backgroundColor, value, newLine: true);
}
private void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value, bool newLine)
{
// Sync access so that we don't conflict on color settings if called from multiple threads.
lock (_instanceLock)
{
ConsoleColor fg = RawUI.ForegroundColor;
ConsoleColor bg = RawUI.BackgroundColor;
RawUI.ForegroundColor = foregroundColor;
RawUI.BackgroundColor = backgroundColor;
try
{
this.WriteImpl(value, newLine);
}

No global lock _instanceLock in new Write*() methods is a root of the issue. Most likely it was a flaw.

@iSazonov iSazonov added the WG-Engine core PowerShell engine, interpreter, and runtime label Jan 31, 2025
@kborowinski
Copy link
Contributor Author

@iSazonov This is a great find. I will try to re-introduce the locks on my local fork to see if it solves the problem.

@iSazonov
Copy link
Collaborator

Perhaps @SteveL-MSFT remembers whether the lack of the lock is intentional or accidental.

@kborowinski
Copy link
Contributor Author

@iSazonov Yep, _instanceLock does the trick, at least on my local fork 😉

Thanks for solving it! 🚀

I will make a draft pull request to address this issue, but since I do not know the impact on the performance, I will wait for your review and guidance if that's OK.

@iSazonov
Copy link
Collaborator
iSazonov commented Feb 1, 2025

but since I do not know the impact on the performance, I will wait for your review and guidance if that's OK.

Old code (coming from Windows PowerShell) has the lock for years. So we don't lost performance more than before. Early we merge some PRs to improve progress bar performance and I think we can not do more in current code design. In general, it is bad idea to write to console from many threads - script writers should understand that writing to console slow down their scripts.

@kborowinski
Copy link
Contributor Author

In general, it is bad idea to write to console from many threads - script writers should understand that writing to console slow down their scripts.

I know that it's better to avoid progress bars, as they can impact performance. The funny thing is that I had a check in my profile that on PS 7.5.0 preview was disabling it globally. The bug reappeared when I moved to the stable release and my check was no longer valid. Then I was hit by a progress bar bug coming from Remove-Item running in a thread, since it now has progress bar as well.

However, I find it particularly useful to have a method for visually indicating that a long-running thread task has been completed, such as using verbose output.

@microsoft-github-policy-service microsoft-github-policy-service bot added the In-PR Indicates that a PR is out for the issue label Feb 2, 2025
@powercode
Copy link
Collaborator

WG-Engine reviewed this and agrees that it's a bug, and are glad to see a PR in progress.

@powercode powercode added WG-Reviewed A Working Group has reviewed this and made a recommendation and removed WG-Engine core PowerShell engine, interpreter, and runtime labels Mar 10, 2025
Copy link
Contributor
microsoft-github-policy-service bot commented Apr 25, 2025

📣 Hey @@kborowinski, how did we do? We would love to hear your feedback with the link below! 🗣️

🔗 https://aka.ms/PSRepoFeedback

@daxian-dbw daxian-dbw removed the In-PR Indicates that a PR is out for the issue label May 14, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Issue-Bug Issue has been identified as a bug in the product WG-Reviewed A Working Group has reviewed this and made a recommendation
Projects
None yet
6 participants
0