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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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; } ///