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