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 1/2] 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