From 944cc453622a07d4b3bb8b1ba524fb649607741d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:54:32 +0000 Subject: [PATCH 01/32] Add issue 2205 regression test Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Tests/Rules/AvoidUsingAlias.tests.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 00db269a0..18365ff55 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -50,6 +50,15 @@ gci -Path C:\ $noViolations.Count | Should -Be 0 } + It "does not fail looking up commands on Linux" -Skip:(-not $IsLinux) { + $scriptDefinition = 'Write-Output "No alias for me"' + + { + $diagnostics = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName -ErrorAction Stop + $diagnostics.Count | Should -Be 0 + } | Should -Not -Throw + } + It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { $target = @' Configuration MyDscConfiguration { From d7d278ce85bbf6cd0fbfa2f6109fd24233f855af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:55:38 +0000 Subject: [PATCH 02/32] Correct issue 2205 regression assertion Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Tests/Rules/AvoidUsingAlias.tests.ps1 | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 18365ff55..384720b7d 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -53,10 +53,8 @@ gci -Path C:\ It "does not fail looking up commands on Linux" -Skip:(-not $IsLinux) { $scriptDefinition = 'Write-Output "No alias for me"' - { - $diagnostics = Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName -ErrorAction Stop - $diagnostics.Count | Should -Be 0 - } | Should -Not -Throw + $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName -ErrorAction Stop) + $diagnostics.Count | Should -Be 0 } It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { From b8e741046ee47c3802bd0c995105f368ee94876e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:03:02 +0000 Subject: [PATCH 03/32] Add recursive issue 2205 regression scenario Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Tests/Rules/AvoidUsingAlias.tests.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 384720b7d..209340125 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -51,9 +51,12 @@ gci -Path C:\ } It "does not fail looking up commands on Linux" -Skip:(-not $IsLinux) { - $scriptDefinition = 'Write-Output "No alias for me"' + $scriptPath = Join-Path $TestDrive 'nested/GetCommand.ps1' + $scriptDirectory = Split-Path -Parent $scriptPath + New-Item -ItemType Directory -Path $scriptDirectory | Out-Null + Set-Content -Path $scriptPath -Value 'Get-Command' - $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition $scriptDefinition -IncludeRule $violationName -ErrorAction Stop) + $diagnostics = @(Invoke-ScriptAnalyzer -Path $TestDrive -Recurse -IncludeRule $violationName -ErrorAction Stop) $diagnostics.Count | Should -Be 0 } From 3d791b2d3762b685af42c15a29e17b9d86f1a109 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:04:08 +0000 Subject: [PATCH 04/32] Isolate issue 2205 regression fixture Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Tests/Rules/AvoidUsingAlias.tests.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 209340125..807ae0c17 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -51,12 +51,13 @@ gci -Path C:\ } It "does not fail looking up commands on Linux" -Skip:(-not $IsLinux) { - $scriptPath = Join-Path $TestDrive 'nested/GetCommand.ps1' + $testDirectory = Join-Path $TestDrive 'Issue2205' + $scriptPath = Join-Path $testDirectory 'nested/GetCommand.ps1' $scriptDirectory = Split-Path -Parent $scriptPath New-Item -ItemType Directory -Path $scriptDirectory | Out-Null Set-Content -Path $scriptPath -Value 'Get-Command' - $diagnostics = @(Invoke-ScriptAnalyzer -Path $TestDrive -Recurse -IncludeRule $violationName -ErrorAction Stop) + $diagnostics = @(Invoke-ScriptAnalyzer -Path $testDirectory -Recurse -IncludeRule $violationName -ErrorAction Stop) $diagnostics.Count | Should -Be 0 } From 33262699c7d55ffb6a17cc56f3c3485f995735cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:18:58 +0000 Subject: [PATCH 05/32] Add issue 2205 failure regression Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Tests/Rules/AvoidUsingAlias.tests.ps1 | 12 ++--- .../Issue2205/PSScriptAnalyzerSettings.psd1 | 53 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 Tests/Rules/Issue2205/PSScriptAnalyzerSettings.psd1 diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 807ae0c17..286512bf5 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -51,14 +51,10 @@ gci -Path C:\ } It "does not fail looking up commands on Linux" -Skip:(-not $IsLinux) { - $testDirectory = Join-Path $TestDrive 'Issue2205' - $scriptPath = Join-Path $testDirectory 'nested/GetCommand.ps1' - $scriptDirectory = Split-Path -Parent $scriptPath - New-Item -ItemType Directory -Path $scriptDirectory | Out-Null - Set-Content -Path $scriptPath -Value 'Get-Command' - - $diagnostics = @(Invoke-ScriptAnalyzer -Path $testDirectory -Recurse -IncludeRule $violationName -ErrorAction Stop) - $diagnostics.Count | Should -Be 0 + $settingsPath = Join-Path $PSScriptRoot 'Issue2205/PSScriptAnalyzerSettings.psd1' + $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + + Invoke-ScriptAnalyzer -Path $repositoryRoot -Recurse -Settings $settingsPath -ErrorAction Stop | Out-Null } It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { diff --git a/Tests/Rules/Issue2205/PSScriptAnalyzerSettings.psd1 b/Tests/Rules/Issue2205/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 000000000..1849a35d3 --- /dev/null +++ b/Tests/Rules/Issue2205/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,53 @@ +@{ + Severity = @('Error', 'Warning', 'Information') + IncludeRules = @( + 'PSAvoidUsingCmdletAliases', 'PSAvoidDefaultValueForMandatoryParameter', + 'PSAvoidDefaultValueSwitchParameter', 'PSAvoidGlobalAliases', + 'PSAvoidGlobalFunctions', 'PSAvoidGlobalVars', 'PSAvoidInvokingEmptyMembers', + 'PSAvoidNullOrEmptyHelpMessageAttribute', 'PSAvoidShouldContinueWithoutForce', + 'PSAvoidUsingComputerNameHardcoded', 'PSAvoidUsingConvertToSecureStringWithPlainText', + 'PSAvoidUsingDeprecatedManifestFields', 'PSAvoidUsingEmptyCatchBlock', + 'PSAvoidUsingInvokeExpression', 'PSAvoidUsingPlainTextForPassword', + 'PSAvoidUsingPositionalParameters', 'PSAvoidUsingUsernameAndPasswordParams', + 'PSAvoidUsingWMICmdlet', 'PSAvoidUsingWriteHost', 'PSMisleadingBacktick', + 'PSMissingModuleManifestField', 'PSPossibleIncorrectComparisonWithNull', + 'PSPossibleIncorrectUsageOfAssignmentOperator', 'PSPossibleIncorrectUsageOfRedirectionOperator', + 'PSProvideCommentHelp', 'PSReservedCmdletChar', 'PSReservedParams', + 'PSUseApprovedVerbs', 'PSUseBOMForUnicodeEncodedFile', 'PSUseCmdletCorrectly', + 'PSUseConsistentIndentation', 'PSUseConsistentWhitespace', 'PSUseCorrectCasing', + 'PSUseDeclaredVarsMoreThanAssignments', 'PSUseLiteralInitializerForHashtable', + 'PSUseOutputTypeCorrectly', 'PSUsePSCredentialType', 'PSUseSingularNouns', + 'PSUseToExportFieldsInManifest', 'PSUseUTF8EncodingForHelpFile' + ) + ExcludeRules = @( + 'PSAvoidUsingWriteHost', 'PSAvoidUsingPositionalParameters', 'PSUseApprovedVerbs', + 'PSProvideCommentHelp', 'PSAvoidGlobalVars', 'PSAvoidGlobalFunctions', + 'PSUseSingularNouns', 'PSUseOutputTypeCorrectly' + ) + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 4 + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + Kind = 'space' + } + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + CheckOpenBrace = $true + CheckOpenParen = $true + CheckOperator = $true + CheckPipe = $true + CheckPipeForRedundantWhitespace = $false + CheckSeparator = $true + CheckParameter = $false + IgnoreAssignmentOperatorInsideHashTable = $true + } + PSUseCompatibleCmdlets = @{ Enable = $false } + PSUseCorrectCasing = @{ Enable = $true } + PSAvoidUsingCmdletAliases = @{ Enable = $true; allowlist = @() } + PSAlignAssignmentStatement = @{ Enable = $false; CheckHashtable = $false } + PSPlaceOpenBrace = @{ Enable = $true; OnSameLine = $true; NewLineAfter = $true; IgnoreOneLineBlock = $true } + PSPlaceCloseBrace = @{ Enable = $true; NewLineAfter = $true; IgnoreOneLineBlock = $true; NoEmptyLineBefore = $false } + } +} From c51a89220cff9803f7f188fef2de00d4352fbe93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:19:53 +0000 Subject: [PATCH 06/32] Isolate issue 2205 failing regression Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Tests/Rules/AvoidUsingAlias.tests.ps1 | 7 ------- Tests/Rules/Issue2205.tests.ps1 | 11 +++++++++++ 2 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 Tests/Rules/Issue2205.tests.ps1 diff --git a/Tests/Rules/AvoidUsingAlias.tests.ps1 b/Tests/Rules/AvoidUsingAlias.tests.ps1 index 286512bf5..00db269a0 100644 --- a/Tests/Rules/AvoidUsingAlias.tests.ps1 +++ b/Tests/Rules/AvoidUsingAlias.tests.ps1 @@ -50,13 +50,6 @@ gci -Path C:\ $noViolations.Count | Should -Be 0 } - It "does not fail looking up commands on Linux" -Skip:(-not $IsLinux) { - $settingsPath = Join-Path $PSScriptRoot 'Issue2205/PSScriptAnalyzerSettings.psd1' - $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - - Invoke-ScriptAnalyzer -Path $repositoryRoot -Recurse -Settings $settingsPath -ErrorAction Stop | Out-Null - } - It "should return no violation for assignment statement-like command in dsc configuration" -skip:($IsLinux -or $IsMacOS) { $target = @' Configuration MyDscConfiguration { diff --git a/Tests/Rules/Issue2205.tests.ps1 b/Tests/Rules/Issue2205.tests.ps1 new file mode 100644 index 000000000..6ab8411ce --- /dev/null +++ b/Tests/Rules/Issue2205.tests.ps1 @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe 'Issue 2205' { + It "reproduces the Linux recursive analysis failure" -Skip:(-not $IsLinux) { + $settingsPath = Join-Path $PSScriptRoot 'Issue2205/PSScriptAnalyzerSettings.psd1' + $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + + Invoke-ScriptAnalyzer -Path $repositoryRoot -Recurse -Settings $settingsPath -ErrorAction Stop | Out-Null + } +} From 39b535625106deaf48061f0d86ed15a3934fd5cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:08:05 +0000 Subject: [PATCH 07/32] Do not fail analysis on transient command lookup failures (issue 2205) Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Engine/CommandInfoCache.cs | 75 +++++++++++++++++++++++++-------- Rules/UseCorrectCasing.cs | 29 +++++++++++-- Tests/Rules/Issue2205.tests.ps1 | 2 +- 3 files changed, 84 insertions(+), 22 deletions(-) diff --git a/Engine/CommandInfoCache.cs b/Engine/CommandInfoCache.cs index aa9d725f3..f2263f937 100644 --- a/Engine/CommandInfoCache.cs +++ b/Engine/CommandInfoCache.cs @@ -14,6 +14,13 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer /// internal class CommandInfoCache : IDisposable { + /// + /// Number of times a command lookup is attempted before giving up. + /// Command lookups can fail transiently because the PowerShell engine is not thread safe, + /// see https://github.com/PowerShell/PowerShell/issues/4003 + /// + private const int MaxLookupAttempts = 3; + private readonly ConcurrentDictionary> _commandInfoCache; private readonly RunspacePool _runspacePool; private bool disposed = false; @@ -70,7 +77,19 @@ public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes return GetCommandInfoInternal(commandName, commandTypes); } // Atomically either use PowerShell to query a command info object, or fetch it from the cache - return _commandInfoCache.GetOrAdd(key, new Lazy(() => GetCommandInfoInternal(commandName, commandTypes))).Value; + var lazyCommandInfo = _commandInfoCache.GetOrAdd(key, new Lazy(() => GetCommandInfoInternal(commandName, commandTypes))); + try + { + return lazyCommandInfo.Value; + } + catch + { + // Lazy caches exceptions forever, which would make every subsequent lookup of this + // command fail for the lifetime of the process. Evict the entry so that the next lookup + // can try again. + _commandInfoCache.TryRemove(key, out _); + throw; + } } @@ -99,26 +118,46 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command // For more details see https://github.com/PowerShell/PowerShell/issues/9308 actualCmdName = WildcardPattern.Escape(actualCmdName); - using (var ps = System.Management.Automation.PowerShell.Create()) + for (int attempt = 1; ; attempt++) { - ps.RunspacePool = _runspacePool; - - ps.AddCommand("Get-Command") - .AddParameter("Name", actualCmdName) - .AddParameter("ErrorAction", "SilentlyContinue"); - - if (commandType != null) - { - ps.AddParameter("CommandType", commandType); - } - - if (!string.IsNullOrEmpty(moduleName)) + using (var ps = System.Management.Automation.PowerShell.Create()) { - ps.AddParameter("Module", moduleName); + ps.RunspacePool = _runspacePool; + + ps.AddCommand("Get-Command") + .AddParameter("Name", actualCmdName) + .AddParameter("ErrorAction", "SilentlyContinue"); + + if (commandType != null) + { + ps.AddParameter("CommandType", commandType); + } + + if (!string.IsNullOrEmpty(moduleName)) + { + ps.AddParameter("Module", moduleName); + } + + try + { + return ps.Invoke() + .FirstOrDefault(); + } + // 'Get-Command' is invoked with 'SilentlyContinue', so a CommandNotFoundException can only + // mean that the engine failed to resolve 'Get-Command' itself in the pooled runspace. + // That happens intermittently because the PowerShell engine is not thread safe, see + // https://github.com/PowerShell/PowerShell/issues/4003 and + // https://github.com/PowerShell/PSScriptAnalyzer/issues/2205 + // Retrying usually succeeds, but rather than failing the whole analysis when it does not, + // treat the command as unresolvable. + catch (CommandNotFoundException) + { + if (attempt >= MaxLookupAttempts) + { + return null; + } + } } - - return ps.Invoke() - .FirstOrDefault(); } } diff --git a/Rules/UseCorrectCasing.cs b/Rules/UseCorrectCasing.cs index f4f2c40b7..de9e2acd6 100644 --- a/Rules/UseCorrectCasing.cs +++ b/Rules/UseCorrectCasing.cs @@ -128,10 +128,17 @@ public override IEnumerable AnalyzeScript(Ast ast, string file // It's a known issue that objects from PowerShell can have a runspace affinity, // therefore if that happens, we query a fresh object instead of using the cache. // https://github.com/PowerShell/PowerShell/issues/4003 - catch (InvalidOperationException) + // The affinity problem surfaces as an InvalidOperationException or as a + // NullReferenceException, see https://github.com/PowerShell/PSScriptAnalyzer/issues/1708 + catch (Exception exception) when (exception is InvalidOperationException || exception is NullReferenceException) { - commandInfo = Helper.Instance.GetCommandInfo(commandName, bypassCache: true); - availableParameters = commandInfo.Parameters; + availableParameters = GetParametersFromFreshCommandInfo(commandName); + } + if (availableParameters is null) + { + // The parameters of this command cannot be determined reliably, + // so skip the parameter casing check instead of failing the analysis. + continue; } foreach (var commandParameterAst in commandParameterAsts) { @@ -161,6 +168,22 @@ public override IEnumerable AnalyzeScript(Ast ast, string file } } + /// + /// Queries a fresh object to work around the runspace affinity problem + /// of the PowerShell engine and returns its parameters, or null if they cannot be determined. + /// + private Dictionary GetParametersFromFreshCommandInfo(string commandName) + { + try + { + return Helper.Instance.GetCommandInfo(commandName, bypassCache: true)?.Parameters; + } + catch (Exception exception) when (exception is InvalidOperationException || exception is NullReferenceException) + { + return null; + } + } + /// /// For a command like "gci -path c:", returns the extent of "gci" in the command /// diff --git a/Tests/Rules/Issue2205.tests.ps1 b/Tests/Rules/Issue2205.tests.ps1 index 6ab8411ce..b181c3879 100644 --- a/Tests/Rules/Issue2205.tests.ps1 +++ b/Tests/Rules/Issue2205.tests.ps1 @@ -2,7 +2,7 @@ # Licensed under the MIT License. Describe 'Issue 2205' { - It "reproduces the Linux recursive analysis failure" -Skip:(-not $IsLinux) { + It "does not fail the analysis when a command lookup hits the runspace affinity problem" -Skip:(-not $IsLinux) { $settingsPath = Join-Path $PSScriptRoot 'Issue2205/PSScriptAnalyzerSettings.psd1' $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) From bc604c9f6ee2e8081d2e996b9df4976608335989 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:15:27 +0000 Subject: [PATCH 08/32] Address code review: evict only the faulted cache entry, clarify test path Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Engine/CommandInfoCache.cs | 7 +++++-- Tests/Rules/Issue2205.tests.ps1 | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Engine/CommandInfoCache.cs b/Engine/CommandInfoCache.cs index f2263f937..da43d0ee1 100644 --- a/Engine/CommandInfoCache.cs +++ b/Engine/CommandInfoCache.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Management.Automation; using System.Linq; using System.Management.Automation.Runspaces; @@ -86,8 +87,10 @@ public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes { // Lazy caches exceptions forever, which would make every subsequent lookup of this // command fail for the lifetime of the process. Evict the entry so that the next lookup - // can try again. - _commandInfoCache.TryRemove(key, out _); + // can try again. Only remove the faulted instance so that a replacement that another + // thread may already have added is left alone. + ((ICollection>>)_commandInfoCache) + .Remove(new KeyValuePair>(key, lazyCommandInfo)); throw; } } diff --git a/Tests/Rules/Issue2205.tests.ps1 b/Tests/Rules/Issue2205.tests.ps1 index b181c3879..4e1cf945b 100644 --- a/Tests/Rules/Issue2205.tests.ps1 +++ b/Tests/Rules/Issue2205.tests.ps1 @@ -4,7 +4,8 @@ Describe 'Issue 2205' { It "does not fail the analysis when a command lookup hits the runspace affinity problem" -Skip:(-not $IsLinux) { $settingsPath = Join-Path $PSScriptRoot 'Issue2205/PSScriptAnalyzerSettings.psd1' - $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + # $PSScriptRoot is /Tests/Rules, so two levels up is the repository root. + $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..')).Path Invoke-ScriptAnalyzer -Path $repositoryRoot -Recurse -Settings $settingsPath -ErrorAction Stop | Out-Null } From 476e1a31e4a799fdbb379ef129bde408ac43f113 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:33:59 +0000 Subject: [PATCH 09/32] Serialize command info lookups on a single dedicated runspace Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Engine/CommandInfoCache.cs | 95 ++++++++++++------- .../CommandInfoCacheConcurrency.tests.ps1 | 59 ++++++++++++ 2 files changed, 121 insertions(+), 33 deletions(-) create mode 100644 Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 diff --git a/Engine/CommandInfoCache.cs b/Engine/CommandInfoCache.cs index da43d0ee1..66ef96f4b 100644 --- a/Engine/CommandInfoCache.cs +++ b/Engine/CommandInfoCache.cs @@ -23,7 +23,17 @@ internal class CommandInfoCache : IDisposable private const int MaxLookupAttempts = 3; private readonly ConcurrentDictionary> _commandInfoCache; - private readonly RunspacePool _runspacePool; + + /// + /// Guards all access to so that only one thread at a time drives the + /// PowerShell engine. The engine is not thread safe, so concurrent lookups can fail transiently, + /// see https://github.com/PowerShell/PowerShell/issues/4003. + /// A monitor is used rather than a semaphore because it is re-entrant, which avoids a deadlock + /// should a lookup ever end up calling back into the cache on the same thread. + /// + private readonly object _runspaceLock = new object(); + + private readonly Runspace _runspace; private bool disposed = false; /// @@ -32,11 +42,13 @@ internal class CommandInfoCache : IDisposable public CommandInfoCache() { _commandInfoCache = new ConcurrentDictionary>(); - _runspacePool = RunspaceFactory.CreateRunspacePool(1, 10); - _runspacePool.Open(); + // A single runspace rather than a pool: all lookups are serialized on it, so that the + // PowerShell engine is never driven concurrently. + _runspace = RunspaceFactory.CreateRunspace(); + _runspace.Open(); } - /// Dispose the runspace pool + /// Dispose the runspace public void Dispose() { Dispose(true); @@ -52,7 +64,14 @@ protected virtual void Dispose(bool disposing) if ( disposing ) { - _runspacePool.Dispose(); + // Take the lock so that the runspace is not disposed while a lookup is in flight. + lock (_runspaceLock) + { + disposed = true; + _runspace.Dispose(); + } + + return; } disposed = true; @@ -123,41 +142,51 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command for (int attempt = 1; ; attempt++) { - using (var ps = System.Management.Automation.PowerShell.Create()) + // Serialize all use of the PowerShell engine. Only cache misses reach this point; + // lookups that are already cached are served without taking the lock. + lock (_runspaceLock) { - ps.RunspacePool = _runspacePool; - - ps.AddCommand("Get-Command") - .AddParameter("Name", actualCmdName) - .AddParameter("ErrorAction", "SilentlyContinue"); - - if (commandType != null) + if (disposed) { - ps.AddParameter("CommandType", commandType); + return null; } - if (!string.IsNullOrEmpty(moduleName)) + using (var ps = System.Management.Automation.PowerShell.Create()) { - ps.AddParameter("Module", moduleName); - } + ps.Runspace = _runspace; - try - { - return ps.Invoke() - .FirstOrDefault(); - } - // 'Get-Command' is invoked with 'SilentlyContinue', so a CommandNotFoundException can only - // mean that the engine failed to resolve 'Get-Command' itself in the pooled runspace. - // That happens intermittently because the PowerShell engine is not thread safe, see - // https://github.com/PowerShell/PowerShell/issues/4003 and - // https://github.com/PowerShell/PSScriptAnalyzer/issues/2205 - // Retrying usually succeeds, but rather than failing the whole analysis when it does not, - // treat the command as unresolvable. - catch (CommandNotFoundException) - { - if (attempt >= MaxLookupAttempts) + ps.AddCommand("Get-Command") + .AddParameter("Name", actualCmdName) + .AddParameter("ErrorAction", "SilentlyContinue"); + + if (commandType != null) + { + ps.AddParameter("CommandType", commandType); + } + + if (!string.IsNullOrEmpty(moduleName)) + { + ps.AddParameter("Module", moduleName); + } + + try + { + return ps.Invoke() + .FirstOrDefault(); + } + // 'Get-Command' is invoked with 'SilentlyContinue', so a CommandNotFoundException can only + // mean that the engine failed to resolve 'Get-Command' itself in the runspace. + // That happened intermittently when lookups ran concurrently because the PowerShell engine + // is not thread safe, see https://github.com/PowerShell/PowerShell/issues/4003 and + // https://github.com/PowerShell/PSScriptAnalyzer/issues/2205 + // Lookups are serialized now, so this should no longer occur, but the retry is kept as a + // safety net for hosts that drive the engine from other threads at the same time. + catch (CommandNotFoundException) { - return null; + if (attempt >= MaxLookupAttempts) + { + return null; + } } } } diff --git a/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 b/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 new file mode 100644 index 000000000..f26b75984 --- /dev/null +++ b/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 @@ -0,0 +1,59 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe "Concurrent command lookups" { + BeforeAll { + # The concurrency driver is written in C# so that the lookups really do run on separate + # threads. Invoking a PowerShell script block on a thread pool thread would introduce + # runspace affinity problems of its own and would not test the command info cache. + $analyzerAssembly = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper].Assembly.Location + Add-Type -IgnoreWarnings -WarningAction SilentlyContinue -ReferencedAssemblies $analyzerAssembly, ([System.Management.Automation.PSObject].Assembly.Location) -TypeDefinition @' +using System.Threading.Tasks; +using Microsoft.Windows.PowerShell.ScriptAnalyzer; + +public static class ConcurrentCommandLookup +{ + public static string[] Lookup(string[] commandNames) + { + var helper = Helper.Instance; + var tasks = new Task[commandNames.Length]; + for (int i = 0; i < commandNames.Length; i++) + { + string name = commandNames[i]; + tasks[i] = Task.Run(() => + { + var commandInfo = helper.GetCommandInfo(name); + return commandInfo == null ? null : commandInfo.Name; + }); + } + + Task.WaitAll(tasks); + + var results = new string[tasks.Length]; + for (int i = 0; i < tasks.Length; i++) + { + results[i] = tasks[i].Result; + } + + return results; + } +} +'@ + } + + It "resolves commands from several threads without failing" { + $commandNames = @( + 'Get-ChildItem', 'Where-Object', 'ForEach-Object', 'Get-Content', 'Write-Output', + 'Test-Path', 'Get-Command', 'Select-Object', 'Sort-Object', 'Measure-Object' + ) * 4 + + # A lookup that hits the thread safety problem throws, which fails the test. + $results = [ConcurrentCommandLookup]::Lookup($commandNames) + + $results.Count | Should -Be $commandNames.Count + # A failed lookup returns null, so every entry must name the command that was requested. + for ($i = 0; $i -lt $commandNames.Count; $i++) { + $results[$i] | Should -BeExactly $commandNames[$i] + } + } +} From c4321d8d0d1e9dddeea6011e3290fbd6dcd90c8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:53:07 +0000 Subject: [PATCH 10/32] Make concurrency test safe against Helper singleton initialization order Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 b/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 index f26b75984..10f9c1047 100644 --- a/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 +++ b/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 @@ -3,6 +3,11 @@ Describe "Concurrent command lookups" { BeforeAll { + # Run the analyzer once so that the singleton Helper is created by the cmdlet. Touching + # Helper.Instance before that would install a helper without a command invocation context, + # which breaks every later analysis in this process. + $null = Invoke-ScriptAnalyzer -ScriptDefinition 'Get-Item -Path .' + # The concurrency driver is written in C# so that the lookups really do run on separate # threads. Invoking a PowerShell script block on a thread pool thread would introduce # runspace affinity problems of its own and would not test the command info cache. From ae52d7cb8599e1da0677a22713a800d8ad3b61ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:54:17 +0000 Subject: [PATCH 11/32] Address review: take the runspace lock on both dispose paths Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Engine/CommandInfoCache.cs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Engine/CommandInfoCache.cs b/Engine/CommandInfoCache.cs index 66ef96f4b..dad365f99 100644 --- a/Engine/CommandInfoCache.cs +++ b/Engine/CommandInfoCache.cs @@ -57,24 +57,23 @@ public void Dispose() protected virtual void Dispose(bool disposing) { - if ( disposed ) + // Always take the lock, also on the finalizer path, so that 'disposed' is never + // published without the runspace being disposed along with it and so that the runspace + // cannot be disposed while a lookup is in flight. + lock (_runspaceLock) { - return; - } + if ( disposed ) + { + return; + } - if ( disposing ) - { - // Take the lock so that the runspace is not disposed while a lookup is in flight. - lock (_runspaceLock) + disposed = true; + + if ( disposing ) { - disposed = true; _runspace.Dispose(); } - - return; } - - disposed = true; } /// From af4cb17dc124f19b36a345b1cb2da7320fc78807 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:57:58 +0000 Subject: [PATCH 12/32] Add isolated cold and warm ScriptAnalyzer benchmark workflow Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- .github/workflows/performance.yml | 232 ++++++++++++++++++++ tools/Measure-ScriptAnalyzerPerformance.ps1 | 55 +++++ 2 files changed, 287 insertions(+) create mode 100644 .github/workflows/performance.yml create mode 100644 tools/Measure-ScriptAnalyzerPerformance.ps1 diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 000000000..e49118973 --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,232 @@ +name: Compare ScriptAnalyzer performance + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + workload: + name: Prepare shared benchmark input + runs-on: windows-latest + timeout-minutes: 10 + outputs: + revision: ${{ steps.revision.outputs.sha }} + steps: + - name: Checkout benchmark script once for both builds + uses: actions/checkout@v4 + with: + repository: PowerShell/PowerShell + ref: master + path: workload + sparse-checkout: build.psm1 + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Record input revision + id: revision + shell: pwsh + run: | + $revision = git -C "$env:GITHUB_WORKSPACE/workload" rev-parse HEAD + if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve workload revision.' } + "sha=$revision" >> $env:GITHUB_OUTPUT + + - name: Share the exact input file + uses: actions/upload-artifact@v4 + with: + name: performance-workload + path: workload/build.psm1 + if-no-files-found: error + + benchmark: + name: Benchmark ${{ matrix.source }} + needs: workload + runs-on: windows-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - source: upstream + repository: PowerShell/PSScriptAnalyzer + - source: fork + repository: jessehouwing/PSScriptAnalyzer + defaults: + run: + shell: pwsh + env: + DOTNET_NOLOGO: true + DOTNET_GENERATE_ASPNET_CERTIFICATE: false + BENCHMARK_SOURCE: ${{ matrix.source }} + BENCHMARK_REPOSITORY: ${{ matrix.repository }} + WORKLOAD_REVISION: ${{ needs.workload.outputs.revision }} + steps: + - name: Checkout benchmark harness + uses: actions/checkout@v4 + with: + path: harness + persist-credentials: false + + - name: Checkout analyzer main + uses: actions/checkout@v4 + with: + repository: ${{ matrix.repository }} + ref: main + path: analyzer + persist-credentials: false + + - name: Download shared input + uses: actions/download-artifact@v4 + with: + name: performance-workload + path: workload + + - name: Install SDK + uses: actions/setup-dotnet@v4 + with: + global-json-file: analyzer/global.json + + - name: Install build resources + run: '& "$env:GITHUB_WORKSPACE/harness/tools/installPSResources.ps1"' + + - name: Build (Release, PowerShell 7) + working-directory: analyzer + run: '& "$env:GITHUB_WORKSPACE/analyzer/build.ps1" -Configuration Release -PSVersion 7 -Verbose' + + - name: Measure cold and warm analysis in a fresh shell + run: | + $ErrorActionPreference = 'Stop' + $workspace = $env:GITHUB_WORKSPACE + $resultDirectory = Join-Path $workspace 'results' + $null = New-Item -ItemType Directory -Path $resultDirectory -Force + $pwsh = (Get-Process -Id $PID).Path + $scriptPath = Join-Path $workspace 'workload/build.psm1' + $measureScript = Join-Path $workspace 'harness/tools/Measure-ScriptAnalyzerPerformance.ps1' + $sourcePath = Join-Path $workspace 'analyzer' + $manifests = @(Get-ChildItem -Path "$sourcePath/out/PSScriptAnalyzer" -Filter PSScriptAnalyzer.psd1 -Recurse) + if ($manifests.Count -ne 1) { + throw "Expected exactly one built manifest, found $($manifests.Count)." + } + $resultPath = Join-Path $resultDirectory "$env:BENCHMARK_SOURCE.json" + & $pwsh -NoLogo -NoProfile -NonInteractive -File $measureScript ` + -ModulePath $manifests[0].FullName -ScriptPath $scriptPath -ResultPath $resultPath + if ($LASTEXITCODE -ne 0) { + throw "Benchmark failed (exit code $LASTEXITCODE)." + } + $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + $revision = git -C $sourcePath rev-parse HEAD + if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve analyzer revision.' } + $metadata = @{ + Source = $env:BENCHMARK_SOURCE + Repository = $env:BENCHMARK_REPOSITORY + Revision = $revision + WorkloadRevision = $env:WORKLOAD_REVISION + RunnerOS = $env:RUNNER_OS + RunnerImage = $env:ImageOS + RunnerImageVersion = $env:ImageVersion + ProcessorCount = [Environment]::ProcessorCount + } + $result | Add-Member -NotePropertyMembers $metadata + $result | ConvertTo-Json | Set-Content -LiteralPath $resultPath -Encoding utf8 + $result | Format-List + + - name: Upload raw benchmark results + if: always() + uses: actions/upload-artifact@v4 + with: + name: scriptanalyzer-performance-${{ matrix.source }} + path: results/*.json + if-no-files-found: warn + + compare: + name: Validate findings and report comparison + needs: benchmark + runs-on: windows-latest + timeout-minutes: 10 + steps: + - name: Download benchmark results + uses: actions/download-artifact@v4 + with: + pattern: scriptanalyzer-performance-* + path: results + merge-multiple: true + + - name: Validate and summarize + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $resultDirectory = Join-Path $env:GITHUB_WORKSPACE 'results' + $baseline = Get-Content -LiteralPath "$resultDirectory/upstream.json" -Raw | ConvertFrom-Json + $candidate = Get-Content -LiteralPath "$resultDirectory/fork.json" -Raw | ConvertFrom-Json + if ($baseline.ScriptSHA256 -ne $candidate.ScriptSHA256 -or + $baseline.WorkloadRevision -ne $candidate.WorkloadRevision -or + $baseline.PowerShellVersion -ne $candidate.PowerShellVersion) { + throw 'The benchmark input or PowerShell versions differ; results are not comparable.' + } + $findingsMatch = $baseline.ColdDiagnosticCount -gt 0 -and + $baseline.ColdDiagnosticCount -eq $candidate.ColdDiagnosticCount -and + $baseline.ColdDiagnosticCount -eq $baseline.WarmDiagnosticCount -and + $baseline.ColdDiagnosticCount -eq $candidate.WarmDiagnosticCount + $report = [ordered]@{ + WorkloadRepository = 'PowerShell/PowerShell' + WorkloadRevision = $baseline.WorkloadRevision + ExpectedDiagnosticCount = $baseline.ColdDiagnosticCount + FindingsVerified = $findingsMatch + Measurements = @($baseline, $candidate) + } + $report | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath "$resultDirectory/comparison.json" -Encoding utf8 + + $summary = @( + '## Invoke-ScriptAnalyzer performance' + '' + 'Release builds run in separate Windows jobs with the same PowerShell version, default rules and identical input bytes. No analyzer modules or sessions are shared between jobs.' + 'Each build gets a fresh pwsh -NoProfile process: cold is its first analysis, warm is the immediately following analysis in that process.' + 'Shell startup, module import, checkout and build time are excluded. Cold does not mean an empty OS filesystem cache.' + '' + "| Source | Commit | Cold (s) | Warm (s) | Diagnostics (cold / warm) |" + "| --- | --- | ---: | ---: | ---: |" + "| PowerShell/PSScriptAnalyzer main | $($baseline.Revision) | $($baseline.ColdSeconds.ToString('F3')) | $($baseline.WarmSeconds.ToString('F3')) | $($baseline.ColdDiagnosticCount) / $($baseline.WarmDiagnosticCount) |" + "| jessehouwing/PSScriptAnalyzer main | $($candidate.Revision) | $($candidate.ColdSeconds.ToString('F3')) | $($candidate.WarmSeconds.ToString('F3')) | $($candidate.ColdDiagnosticCount) / $($candidate.WarmDiagnosticCount) |" + '' + ) + foreach ($run in 'Cold', 'Warm') { + $property = "${run}Seconds" + if ($baseline.$property -gt 0) { + $change = 100 * ($candidate.$property / $baseline.$property - 1) + $summary += "$run time change (fork vs upstream): $($change.ToString('F2'))% (positive is slower)." + } + } + $summary += @( + '' + "Input: PowerShell/PowerShell build.psm1 at $($baseline.WorkloadRevision)" + "Input SHA256: $($baseline.ScriptSHA256)" + "PowerShell: $($baseline.PowerShellVersion)" + "Upstream runner: $($baseline.RunnerImage) $($baseline.RunnerImageVersion); logical processors: $($baseline.ProcessorCount)" + "Fork runner: $($candidate.RunnerImage) $($candidate.RunnerImageVersion); logical processors: $($candidate.ProcessorCount)" + '' + "Verified upstream module: $($baseline.ModuleManifest) (version $($baseline.ModuleVersion))" + "Verified upstream cmdlet assembly: $($baseline.AnalyzerAssemblyPath)" + "Verified fork module: $($candidate.ModuleManifest) (version $($candidate.ModuleVersion))" + "Verified fork cmdlet assembly: $($candidate.AnalyzerAssemblyPath)" + '' + 'These are single cold/warm samples on separate hosts, not a statistical regression test. Rerun the workflow to assess runner and hardware noise.' + ) + if (-not $findingsMatch) { + $summary += '**FAILED:** Expected a nonzero upstream cold finding count and the same count in all four runs. Timing results must not be treated as a valid comparison.' + } else { + $summary += "Finding count verified: all four runs returned $($baseline.ColdDiagnosticCount) findings, matching the upstream cold reference." + } + $summary | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + $summary | Write-Output + if (-not $findingsMatch) { + throw 'Finding count validation failed. See the job summary and raw benchmark results.' + } + + - name: Upload comparison + if: always() + uses: actions/upload-artifact@v4 + with: + name: scriptanalyzer-performance-comparison + path: results/comparison.json + if-no-files-found: warn diff --git a/tools/Measure-ScriptAnalyzerPerformance.ps1 b/tools/Measure-ScriptAnalyzerPerformance.ps1 new file mode 100644 index 000000000..96ccd0feb --- /dev/null +++ b/tools/Measure-ScriptAnalyzerPerformance.ps1 @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$ModulePath, + + [Parameter(Mandatory)] + [string]$ScriptPath, + + [Parameter(Mandatory)] + [string]$ResultPath +) + +$ErrorActionPreference = 'Stop' +$ModulePath = (Resolve-Path -LiteralPath $ModulePath).Path +$ScriptPath = (Resolve-Path -LiteralPath $ScriptPath).Path + +# Run this script in a new -NoProfile shell for each build. Import and shell +# startup are excluded; cold means the first analysis in this process. +if (Get-Module PSScriptAnalyzer) { + throw 'PSScriptAnalyzer is already loaded; run this benchmark in a fresh shell.' +} +$module = Import-Module -Name $ModulePath -PassThru +$expectedModuleBase = Split-Path -Parent $ModulePath +$expectedAssemblyPath = (Resolve-Path -LiteralPath ( + Join-Path $expectedModuleBase "PSv$($PSVersionTable.PSVersion.Major)/Microsoft.Windows.PowerShell.ScriptAnalyzer.dll" +)).Path +$command = Get-Command PSScriptAnalyzer\Invoke-ScriptAnalyzer -CommandType Cmdlet +if ($module.Name -ne 'PSScriptAnalyzer' -or + $module.ModuleBase -ne $expectedModuleBase -or + $command.Module.ModuleBase -ne $expectedModuleBase -or + $command.ImplementingType.Assembly.Location -ne $expectedAssemblyPath) { + throw "The loaded module or Invoke-ScriptAnalyzer assembly does not match the requested build at '$ModulePath'." +} +$results = [ordered]@{ + PowerShellVersion = $PSVersionTable.PSVersion.ToString() + ModuleVersion = $module.Version.ToString() + ModuleManifest = $ModulePath + ModulePath = $module.Path + AnalyzerAssemblyPath = $command.ImplementingType.Assembly.Location + ScriptPath = $ScriptPath + ScriptSHA256 = (Get-FileHash -LiteralPath $ScriptPath -Algorithm SHA256).Hash +} + +foreach ($run in 'Cold', 'Warm') { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $diagnostics = @(& $command -Path $ScriptPath -ErrorAction Stop) + $stopwatch.Stop() + $results["${run}Seconds"] = $stopwatch.Elapsed.TotalSeconds + $results["${run}DiagnosticCount"] = $diagnostics.Count +} + +[pscustomobject]$results | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding utf8 From acd3f00200304d9cded99a902e89cf27ffe979c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:02:01 +0000 Subject: [PATCH 13/32] Expand performance comparison to Linux and semver repository workload Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- .github/workflows/performance.yml | 97 ++++++++++++++++----- tools/Measure-ScriptAnalyzerPerformance.ps1 | 41 ++++++++- 2 files changed, 114 insertions(+), 24 deletions(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index e49118973..9ea483dbe 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -8,11 +8,12 @@ permissions: jobs: workload: - name: Prepare shared benchmark input + name: Prepare shared benchmark inputs runs-on: windows-latest timeout-minutes: 10 outputs: - revision: ${{ steps.revision.outputs.sha }} + powershell: ${{ steps.revision.outputs.sha }} + semver: ${{ steps.semver-revision.outputs.sha }} steps: - name: Checkout benchmark script once for both builds uses: actions/checkout@v4 @@ -35,18 +36,46 @@ jobs: - name: Share the exact input file uses: actions/upload-artifact@v4 with: - name: performance-workload + name: performance-workload-powershell path: workload/build.psm1 if-no-files-found: error + - name: Checkout actions-semver-checker + uses: actions/checkout@v4 + with: + repository: jessehouwing/actions-semver-checker + ref: main + path: semver + persist-credentials: false + + - name: Archive actions-semver-checker and record revision + id: semver-revision + shell: pwsh + run: | + $revision = git -C "$env:GITHUB_WORKSPACE/semver" rev-parse HEAD + if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve semver workload revision.' } + "sha=$revision" >> $env:GITHUB_OUTPUT + git -C "$env:GITHUB_WORKSPACE/semver" archive --format=zip --output="$env:GITHUB_WORKSPACE/semver.zip" HEAD + if ($LASTEXITCODE -ne 0) { throw 'Cannot archive semver workload.' } + + - name: Share the exact semver source tree + uses: actions/upload-artifact@v4 + with: + name: performance-workload-semver + path: semver.zip + if-no-files-found: error + benchmark: - name: Benchmark ${{ matrix.source }} + name: Benchmark ${{ matrix.source }} / ${{ matrix.workload }} / ${{ matrix.os }} needs: workload - runs-on: windows-latest + runs-on: ${{ matrix.os }} timeout-minutes: 45 strategy: fail-fast: false matrix: + os: [ubuntu-latest, windows-latest] + workload: [powershell, semver] + source: [upstream, fork] include: - source: upstream repository: PowerShell/PSScriptAnalyzer @@ -60,7 +89,8 @@ jobs: DOTNET_GENERATE_ASPNET_CERTIFICATE: false BENCHMARK_SOURCE: ${{ matrix.source }} BENCHMARK_REPOSITORY: ${{ matrix.repository }} - WORKLOAD_REVISION: ${{ needs.workload.outputs.revision }} + BENCHMARK_WORKLOAD: ${{ matrix.workload }} + WORKLOAD_REVISION: ${{ needs.workload.outputs[matrix.workload] }} steps: - name: Checkout benchmark harness uses: actions/checkout@v4 @@ -79,9 +109,13 @@ jobs: - name: Download shared input uses: actions/download-artifact@v4 with: - name: performance-workload + name: performance-workload-${{ matrix.workload }} path: workload + - name: Extract semver source tree + if: matrix.workload == 'semver' + run: Expand-Archive -LiteralPath "$env:GITHUB_WORKSPACE/workload/semver.zip" -DestinationPath "$env:GITHUB_WORKSPACE/workload/source" + - name: Install SDK uses: actions/setup-dotnet@v4 with: @@ -102,6 +136,13 @@ jobs: $null = New-Item -ItemType Directory -Path $resultDirectory -Force $pwsh = (Get-Process -Id $PID).Path $scriptPath = Join-Path $workspace 'workload/build.psm1' + $workloadRepository = 'PowerShell/PowerShell' + $analysisArguments = @() + if ($env:BENCHMARK_WORKLOAD -eq 'semver') { + $scriptPath = Join-Path $workspace 'workload/source' + $workloadRepository = 'jessehouwing/actions-semver-checker' + $analysisArguments = @('-Recurse', '-SettingsPath', (Join-Path $scriptPath 'PSScriptAnalyzerSettings.psd1')) + } $measureScript = Join-Path $workspace 'harness/tools/Measure-ScriptAnalyzerPerformance.ps1' $sourcePath = Join-Path $workspace 'analyzer' $manifests = @(Get-ChildItem -Path "$sourcePath/out/PSScriptAnalyzer" -Filter PSScriptAnalyzer.psd1 -Recurse) @@ -110,7 +151,7 @@ jobs: } $resultPath = Join-Path $resultDirectory "$env:BENCHMARK_SOURCE.json" & $pwsh -NoLogo -NoProfile -NonInteractive -File $measureScript ` - -ModulePath $manifests[0].FullName -ScriptPath $scriptPath -ResultPath $resultPath + -ModulePath $manifests[0].FullName -ScriptPath $scriptPath -ResultPath $resultPath @analysisArguments if ($LASTEXITCODE -ne 0) { throw "Benchmark failed (exit code $LASTEXITCODE)." } @@ -121,6 +162,8 @@ jobs: Source = $env:BENCHMARK_SOURCE Repository = $env:BENCHMARK_REPOSITORY Revision = $revision + Workload = $env:BENCHMARK_WORKLOAD + WorkloadRepository = $workloadRepository WorkloadRevision = $env:WORKLOAD_REVISION RunnerOS = $env:RUNNER_OS RunnerImage = $env:ImageOS @@ -135,20 +178,26 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: scriptanalyzer-performance-${{ matrix.source }} + name: scriptanalyzer-performance-${{ matrix.os }}-${{ matrix.workload }}-${{ matrix.source }} path: results/*.json if-no-files-found: warn compare: - name: Validate findings and report comparison + name: Compare ${{ matrix.workload }} / ${{ matrix.os }} needs: benchmark - runs-on: windows-latest + if: ${{ !cancelled() }} + runs-on: ${{ matrix.os }} timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + workload: [powershell, semver] steps: - name: Download benchmark results uses: actions/download-artifact@v4 with: - pattern: scriptanalyzer-performance-* + pattern: scriptanalyzer-performance-${{ matrix.os }}-${{ matrix.workload }}-* path: results merge-multiple: true @@ -159,17 +208,21 @@ jobs: $resultDirectory = Join-Path $env:GITHUB_WORKSPACE 'results' $baseline = Get-Content -LiteralPath "$resultDirectory/upstream.json" -Raw | ConvertFrom-Json $candidate = Get-Content -LiteralPath "$resultDirectory/fork.json" -Raw | ConvertFrom-Json - if ($baseline.ScriptSHA256 -ne $candidate.ScriptSHA256 -or + if ($baseline.InputSHA256 -ne $candidate.InputSHA256 -or + $baseline.InputFileCount -ne $candidate.InputFileCount -or + $baseline.SettingsSHA256 -ne $candidate.SettingsSHA256 -or + $baseline.Recurse -ne $candidate.Recurse -or $baseline.WorkloadRevision -ne $candidate.WorkloadRevision -or - $baseline.PowerShellVersion -ne $candidate.PowerShellVersion) { - throw 'The benchmark input or PowerShell versions differ; results are not comparable.' + $baseline.PowerShellVersion -ne $candidate.PowerShellVersion -or + $baseline.RunnerOS -ne $candidate.RunnerOS) { + throw 'The benchmark input, settings or runtime differ; results are not comparable.' } $findingsMatch = $baseline.ColdDiagnosticCount -gt 0 -and $baseline.ColdDiagnosticCount -eq $candidate.ColdDiagnosticCount -and $baseline.ColdDiagnosticCount -eq $baseline.WarmDiagnosticCount -and $baseline.ColdDiagnosticCount -eq $candidate.WarmDiagnosticCount $report = [ordered]@{ - WorkloadRepository = 'PowerShell/PowerShell' + WorkloadRepository = $baseline.WorkloadRepository WorkloadRevision = $baseline.WorkloadRevision ExpectedDiagnosticCount = $baseline.ColdDiagnosticCount FindingsVerified = $findingsMatch @@ -178,9 +231,10 @@ jobs: $report | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath "$resultDirectory/comparison.json" -Encoding utf8 $summary = @( - '## Invoke-ScriptAnalyzer performance' + "## Invoke-ScriptAnalyzer performance: $($baseline.Workload) / $($baseline.RunnerOS)" '' - 'Release builds run in separate Windows jobs with the same PowerShell version, default rules and identical input bytes. No analyzer modules or sessions are shared between jobs.' + 'Release builds run in separate jobs with the same OS and PowerShell version and identical input bytes. No analyzer modules or sessions are shared between jobs.' + 'The PowerShell workload analyzes only build.psm1 with default rules. The semver workload analyzes the repository recursively using its PSScriptAnalyzerSettings.psd1, without filtering findings.' 'Each build gets a fresh pwsh -NoProfile process: cold is its first analysis, warm is the immediately following analysis in that process.' 'Shell startup, module import, checkout and build time are excluded. Cold does not mean an empty OS filesystem cache.' '' @@ -199,8 +253,9 @@ jobs: } $summary += @( '' - "Input: PowerShell/PowerShell build.psm1 at $($baseline.WorkloadRevision)" - "Input SHA256: $($baseline.ScriptSHA256)" + "Input: $($baseline.WorkloadRepository) at $($baseline.WorkloadRevision)" + "Input SHA256: $($baseline.InputSHA256); PowerShell files: $($baseline.InputFileCount); recurse: $($baseline.Recurse)" + "Settings SHA256 (empty for default rules): $($baseline.SettingsSHA256)" "PowerShell: $($baseline.PowerShellVersion)" "Upstream runner: $($baseline.RunnerImage) $($baseline.RunnerImageVersion); logical processors: $($baseline.ProcessorCount)" "Fork runner: $($candidate.RunnerImage) $($candidate.RunnerImageVersion); logical processors: $($candidate.ProcessorCount)" @@ -227,6 +282,6 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: scriptanalyzer-performance-comparison + name: scriptanalyzer-performance-${{ matrix.os }}-${{ matrix.workload }}-comparison path: results/comparison.json if-no-files-found: warn diff --git a/tools/Measure-ScriptAnalyzerPerformance.ps1 b/tools/Measure-ScriptAnalyzerPerformance.ps1 index 96ccd0feb..3a160950d 100644 --- a/tools/Measure-ScriptAnalyzerPerformance.ps1 +++ b/tools/Measure-ScriptAnalyzerPerformance.ps1 @@ -10,12 +10,25 @@ param( [string]$ScriptPath, [Parameter(Mandatory)] - [string]$ResultPath + [string]$ResultPath, + + [switch]$Recurse, + + [string]$SettingsPath ) $ErrorActionPreference = 'Stop' $ModulePath = (Resolve-Path -LiteralPath $ModulePath).Path $ScriptPath = (Resolve-Path -LiteralPath $ScriptPath).Path +$analyzerArguments = @{ + Path = $ScriptPath + Recurse = $Recurse + ErrorAction = 'Stop' +} +if ($SettingsPath) { + $SettingsPath = (Resolve-Path -LiteralPath $SettingsPath).Path + $analyzerArguments.Settings = $SettingsPath +} # Run this script in a new -NoProfile shell for each build. Import and shell # startup are excluded; cold means the first analysis in this process. @@ -34,6 +47,24 @@ if ($module.Name -ne 'PSScriptAnalyzer' -or $command.ImplementingType.Assembly.Location -ne $expectedAssemblyPath) { throw "The loaded module or Invoke-ScriptAnalyzer assembly does not match the requested build at '$ModulePath'." } +$inputItem = Get-Item -LiteralPath $ScriptPath +if ($inputItem.PSIsContainer) { + $inputFiles = @(Get-ChildItem -LiteralPath $ScriptPath -File -Recurse:$Recurse | + Where-Object Extension -In '.ps1', '.psm1', '.psd1' | + Sort-Object FullName) + if ($inputFiles.Count -eq 0) { + throw "No PowerShell files found at '$ScriptPath'." + } + $fileHashes = foreach ($file in $inputFiles) { + $relativePath = [IO.Path]::GetRelativePath($ScriptPath, $file.FullName).Replace('\', '/') + "$relativePath`:$((Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash)" + } + $inputHash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes(($fileHashes -join "`n")))) +} else { + $inputFiles = @($inputItem) + $inputHash = (Get-FileHash -LiteralPath $ScriptPath -Algorithm SHA256).Hash +} $results = [ordered]@{ PowerShellVersion = $PSVersionTable.PSVersion.ToString() ModuleVersion = $module.Version.ToString() @@ -41,12 +72,16 @@ $results = [ordered]@{ ModulePath = $module.Path AnalyzerAssemblyPath = $command.ImplementingType.Assembly.Location ScriptPath = $ScriptPath - ScriptSHA256 = (Get-FileHash -LiteralPath $ScriptPath -Algorithm SHA256).Hash + InputSHA256 = $inputHash + InputFileCount = $inputFiles.Count + Recurse = [bool]$Recurse + SettingsPath = $SettingsPath + SettingsSHA256 = if ($SettingsPath) { (Get-FileHash -LiteralPath $SettingsPath -Algorithm SHA256).Hash } else { $null } } foreach ($run in 'Cold', 'Warm') { $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() - $diagnostics = @(& $command -Path $ScriptPath -ErrorAction Stop) + $diagnostics = @(& $command @analyzerArguments) $stopwatch.Stop() $results["${run}Seconds"] = $stopwatch.Elapsed.TotalSeconds $results["${run}DiagnosticCount"] = $diagnostics.Count From 7a233e438466613b92b5d523f094c0b4d478caa0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:07:49 +0000 Subject: [PATCH 14/32] Preserve full PowerShell source tree in performance benchmarks Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- .github/workflows/performance.yml | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 9ea483dbe..23128f218 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -15,29 +15,29 @@ jobs: powershell: ${{ steps.revision.outputs.sha }} semver: ${{ steps.semver-revision.outputs.sha }} steps: - - name: Checkout benchmark script once for both builds + - name: Checkout PowerShell source tree once for both builds uses: actions/checkout@v4 with: repository: PowerShell/PowerShell ref: master path: workload - sparse-checkout: build.psm1 - sparse-checkout-cone-mode: false persist-credentials: false - - name: Record input revision + - name: Archive PowerShell source tree and record revision id: revision shell: pwsh run: | $revision = git -C "$env:GITHUB_WORKSPACE/workload" rev-parse HEAD if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve workload revision.' } "sha=$revision" >> $env:GITHUB_OUTPUT + git -C "$env:GITHUB_WORKSPACE/workload" archive --format=zip --output="$env:GITHUB_WORKSPACE/powershell.zip" HEAD + if ($LASTEXITCODE -ne 0) { throw 'Cannot archive PowerShell workload.' } - - name: Share the exact input file + - name: Share the exact PowerShell source tree uses: actions/upload-artifact@v4 with: name: performance-workload-powershell - path: workload/build.psm1 + path: powershell.zip if-no-files-found: error - name: Checkout actions-semver-checker @@ -112,9 +112,8 @@ jobs: name: performance-workload-${{ matrix.workload }} path: workload - - name: Extract semver source tree - if: matrix.workload == 'semver' - run: Expand-Archive -LiteralPath "$env:GITHUB_WORKSPACE/workload/semver.zip" -DestinationPath "$env:GITHUB_WORKSPACE/workload/source" + - name: Extract workload source tree + run: Expand-Archive -LiteralPath "$env:GITHUB_WORKSPACE/workload/$env:BENCHMARK_WORKLOAD.zip" -DestinationPath "$env:GITHUB_WORKSPACE/workload/source" - name: Install SDK uses: actions/setup-dotnet@v4 @@ -135,7 +134,7 @@ jobs: $resultDirectory = Join-Path $workspace 'results' $null = New-Item -ItemType Directory -Path $resultDirectory -Force $pwsh = (Get-Process -Id $PID).Path - $scriptPath = Join-Path $workspace 'workload/build.psm1' + $scriptPath = Join-Path $workspace 'workload/source/build.psm1' $workloadRepository = 'PowerShell/PowerShell' $analysisArguments = @() if ($env:BENCHMARK_WORKLOAD -eq 'semver') { From ac6dab8cd06770c3ef225fd8f4ea2e51f3abd549 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:09:43 +0000 Subject: [PATCH 15/32] Pin download-artifact to patched v4.3.0 commit Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- .github/workflows/performance.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 23128f218..127a70f19 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -107,7 +107,7 @@ jobs: persist-credentials: false - name: Download shared input - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: performance-workload-${{ matrix.workload }} path: workload @@ -194,7 +194,7 @@ jobs: workload: [powershell, semver] steps: - name: Download benchmark results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: scriptanalyzer-performance-${{ matrix.os }}-${{ matrix.workload }}-* path: results From ada251bfa90f8fcadc3afe5bb4b89d253421d187 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:17:11 +0000 Subject: [PATCH 16/32] Initial plan From 36f9aefbff333eae66c6ff8fcbe9c573e2564ed7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:24:27 +0000 Subject: [PATCH 17/32] Harden command lookup against Get-Command resolution failures Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Engine/CommandInfoCache.cs | 40 ++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/Engine/CommandInfoCache.cs b/Engine/CommandInfoCache.cs index dad365f99..af1f9584f 100644 --- a/Engine/CommandInfoCache.cs +++ b/Engine/CommandInfoCache.cs @@ -21,6 +21,7 @@ internal class CommandInfoCache : IDisposable /// see https://github.com/PowerShell/PowerShell/issues/4003 /// private const int MaxLookupAttempts = 3; + private const string GetCommandName = "Microsoft.PowerShell.Core\\Get-Command"; private readonly ConcurrentDictionary> _commandInfoCache; @@ -154,7 +155,7 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command { ps.Runspace = _runspace; - ps.AddCommand("Get-Command") + ps.AddCommand(GetCommandName) .AddParameter("Name", actualCmdName) .AddParameter("ErrorAction", "SilentlyContinue"); @@ -170,8 +171,18 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command try { - return ps.Invoke() - .FirstOrDefault(); + var result = ps.Invoke(); + if (ps.HadErrors && ps.Streams.Error.All(IsGetCommandResolutionError)) + { + if (attempt >= MaxLookupAttempts) + { + return null; + } + + continue; + } + + return result.FirstOrDefault(); } // 'Get-Command' is invoked with 'SilentlyContinue', so a CommandNotFoundException can only // mean that the engine failed to resolve 'Get-Command' itself in the runspace. @@ -180,18 +191,39 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command // https://github.com/PowerShell/PSScriptAnalyzer/issues/2205 // Lookups are serialized now, so this should no longer occur, but the retry is kept as a // safety net for hosts that drive the engine from other threads at the same time. - catch (CommandNotFoundException) + catch (RuntimeException exception) when (IsGetCommandResolutionException(exception)) { if (attempt >= MaxLookupAttempts) { return null; } } + } } } } + private static bool IsGetCommandResolutionError(ErrorRecord errorRecord) + { + return IsGetCommandResolutionException(errorRecord?.Exception); + } + + private static bool IsGetCommandResolutionException(Exception exception) + { + if (exception is CommandNotFoundException) + { + return true; + } + + if (exception is ParentContainsErrorRecordException parentContainsErrorRecordException) + { + return IsGetCommandResolutionException(parentContainsErrorRecordException.InnerException); + } + + return false; + } + private struct CommandLookupKey : IEquatable { private readonly string Name; From b13bae10970fbc3dda78f1c217504b9e2217c5a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:28:48 +0000 Subject: [PATCH 18/32] Avoid runspace races when resolving exported function parameters Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- Engine/Helper.cs | 24 ++++++++++-- .../CommandInfoCacheConcurrency.tests.ps1 | 39 ++++++++++++++++++- Tests/Engine/Helper.tests.ps1 | 25 ++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/Engine/Helper.cs b/Engine/Helper.cs index f36d17433..47a34e939 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -404,14 +404,17 @@ public HashSet GetExportedFunction(Ast ast) CommandInfo exportMM = Helper.Instance.GetCommandInfo("export-modulemember", CommandTypes.Cmdlet); - // switch parameters - IEnumerable switchParams = (exportMM != null) ? exportMM.Parameters.Values.Where(pm => pm.SwitchParameter) : Enumerable.Empty(); - if (exportMM == null) { return exportedFunctions; } + // Export-ModuleMember has no dynamic parameters. Resolve names from its static + // metadata instead of ResolveParameter(), which re-enters the cached command's + // runspace and races with command lookups and metadata queries on other rule threads. + var parameters = exportMM.Parameters; + IEnumerable switchParams = parameters.Values.Where(pm => pm.SwitchParameter); + foreach (CommandAst cmdAst in cmdAsts) { if (cmdAst.CommandElements == null || cmdAst.CommandElements.Count < 2) @@ -429,7 +432,20 @@ public HashSet GetExportedFunction(Ast ast) if (ceAst is CommandParameterAst) { var paramAst = ceAst as CommandParameterAst; - var param = exportMM.ResolveParameter(paramAst.ParameterName); + ParameterMetadata param; + if (!parameters.TryGetValue(paramAst.ParameterName, out param)) + { + param = parameters.Values.FirstOrDefault(pm => + pm.Aliases.Contains(paramAst.ParameterName, StringComparer.OrdinalIgnoreCase)); + if (param == null) + { + var matches = parameters.Values.Where(pm => + pm.Name.StartsWith(paramAst.ParameterName, StringComparison.OrdinalIgnoreCase) + || pm.Aliases.Any(alias => alias.StartsWith(paramAst.ParameterName, StringComparison.OrdinalIgnoreCase))) + .Take(2).ToArray(); + param = matches.Length == 1 ? matches[0] : null; + } + } if (param == null) { diff --git a/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 b/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 index 10f9c1047..885d48fd7 100644 --- a/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 +++ b/Tests/Engine/CommandInfoCacheConcurrency.tests.ps1 @@ -12,12 +12,44 @@ Describe "Concurrent command lookups" { # threads. Invoking a PowerShell script block on a thread pool thread would introduce # runspace affinity problems of its own and would not test the command info cache. $analyzerAssembly = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper].Assembly.Location - Add-Type -IgnoreWarnings -WarningAction SilentlyContinue -ReferencedAssemblies $analyzerAssembly, ([System.Management.Automation.PSObject].Assembly.Location) -TypeDefinition @' + $references = @($analyzerAssembly, ([System.Management.Automation.PSObject].Assembly.Location)) + if ($PSVersionTable.PSEdition -eq 'Core') { + $references += Join-Path $PSHOME 'ref/System.Collections.dll' + } + Add-Type -IgnoreWarnings -WarningAction SilentlyContinue -ReferencedAssemblies $references -TypeDefinition @' using System.Threading.Tasks; +using System.Management.Automation.Language; using Microsoft.Windows.PowerShell.ScriptAnalyzer; public static class ConcurrentCommandLookup { + public static void ResolveExports() + { + Token[] tokens; + ParseError[] errors; + var ast = Parser.ParseInput("Export-ModuleMember -Function Test-Example", out tokens, out errors); + var helper = Helper.Instance; + var tasks = new Task[8]; + for (int i = 0; i < tasks.Length; i++) + { + tasks[i] = Task.Run(() => + { + for (int j = 0; j < 100; j++) + { + helper.GetCommandInfo("Get-Command", bypassCache: true); + var parameters = helper.GetCommandInfo("Get-Item").Parameters; + var exports = helper.GetExportedFunction(ast); + if (!exports.SetEquals(new[] { "Test-Example" })) + { + throw new System.InvalidOperationException("Exported function was not resolved."); + } + } + }); + } + + Task.WaitAll(tasks); + } + public static string[] Lookup(string[] commandNames) { var helper = Helper.Instance; @@ -60,5 +92,10 @@ public static class ConcurrentCommandLookup for ($i = 0; $i -lt $commandNames.Count; $i++) { $results[$i] | Should -BeExactly $commandNames[$i] } + + } + + It "resolves exported functions while command lookups run concurrently" { + [ConcurrentCommandLookup]::ResolveExports() } } diff --git a/Tests/Engine/Helper.tests.ps1 b/Tests/Engine/Helper.tests.ps1 index 3d53e71f1..a6ccd70b2 100644 --- a/Tests/Engine/Helper.tests.ps1 +++ b/Tests/Engine/Helper.tests.ps1 @@ -39,3 +39,28 @@ Describe "Test Directed Graph" { } } } + +Describe "Exported function parameter resolution" { + BeforeAll { + $null = Invoke-ScriptAnalyzer -ScriptDefinition 'Get-Item -Path .' + } + + It "resolves exports from