Skip to content

Commit 476e1a3

Browse files
Serialize command info lookups on a single dedicated runspace
Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com>
1 parent 9734b62 commit 476e1a3

2 files changed

Lines changed: 121 additions & 33 deletions

File tree

Engine/CommandInfoCache.cs

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,17 @@ internal class CommandInfoCache : IDisposable
2323
private const int MaxLookupAttempts = 3;
2424

2525
private readonly ConcurrentDictionary<CommandLookupKey, Lazy<CommandInfo>> _commandInfoCache;
26-
private readonly RunspacePool _runspacePool;
26+
27+
/// <summary>
28+
/// Guards all access to <see cref="_runspace"/> so that only one thread at a time drives the
29+
/// PowerShell engine. The engine is not thread safe, so concurrent lookups can fail transiently,
30+
/// see https://github.com/PowerShell/PowerShell/issues/4003.
31+
/// A monitor is used rather than a semaphore because it is re-entrant, which avoids a deadlock
32+
/// should a lookup ever end up calling back into the cache on the same thread.
33+
/// </summary>
34+
private readonly object _runspaceLock = new object();
35+
36+
private readonly Runspace _runspace;
2737
private bool disposed = false;
2838

2939
/// <summary>
@@ -32,11 +42,13 @@ internal class CommandInfoCache : IDisposable
3242
public CommandInfoCache()
3343
{
3444
_commandInfoCache = new ConcurrentDictionary<CommandLookupKey, Lazy<CommandInfo>>();
35-
_runspacePool = RunspaceFactory.CreateRunspacePool(1, 10);
36-
_runspacePool.Open();
45+
// A single runspace rather than a pool: all lookups are serialized on it, so that the
46+
// PowerShell engine is never driven concurrently.
47+
_runspace = RunspaceFactory.CreateRunspace();
48+
_runspace.Open();
3749
}
3850

39-
/// <summary>Dispose the runspace pool</summary>
51+
/// <summary>Dispose the runspace</summary>
4052
public void Dispose()
4153
{
4254
Dispose(true);
@@ -52,7 +64,14 @@ protected virtual void Dispose(bool disposing)
5264

5365
if ( disposing )
5466
{
55-
_runspacePool.Dispose();
67+
// Take the lock so that the runspace is not disposed while a lookup is in flight.
68+
lock (_runspaceLock)
69+
{
70+
disposed = true;
71+
_runspace.Dispose();
72+
}
73+
74+
return;
5675
}
5776

5877
disposed = true;
@@ -123,41 +142,51 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command
123142

124143
for (int attempt = 1; ; attempt++)
125144
{
126-
using (var ps = System.Management.Automation.PowerShell.Create())
145+
// Serialize all use of the PowerShell engine. Only cache misses reach this point;
146+
// lookups that are already cached are served without taking the lock.
147+
lock (_runspaceLock)
127148
{
128-
ps.RunspacePool = _runspacePool;
129-
130-
ps.AddCommand("Get-Command")
131-
.AddParameter("Name", actualCmdName)
132-
.AddParameter("ErrorAction", "SilentlyContinue");
133-
134-
if (commandType != null)
149+
if (disposed)
135150
{
136-
ps.AddParameter("CommandType", commandType);
151+
return null;
137152
}
138153

139-
if (!string.IsNullOrEmpty(moduleName))
154+
using (var ps = System.Management.Automation.PowerShell.Create())
140155
{
141-
ps.AddParameter("Module", moduleName);
142-
}
156+
ps.Runspace = _runspace;
143157

144-
try
145-
{
146-
return ps.Invoke<CommandInfo>()
147-
.FirstOrDefault();
148-
}
149-
// 'Get-Command' is invoked with 'SilentlyContinue', so a CommandNotFoundException can only
150-
// mean that the engine failed to resolve 'Get-Command' itself in the pooled runspace.
151-
// That happens intermittently because the PowerShell engine is not thread safe, see
152-
// https://github.com/PowerShell/PowerShell/issues/4003 and
153-
// https://github.com/PowerShell/PSScriptAnalyzer/issues/2205
154-
// Retrying usually succeeds, but rather than failing the whole analysis when it does not,
155-
// treat the command as unresolvable.
156-
catch (CommandNotFoundException)
157-
{
158-
if (attempt >= MaxLookupAttempts)
158+
ps.AddCommand("Get-Command")
159+
.AddParameter("Name", actualCmdName)
160+
.AddParameter("ErrorAction", "SilentlyContinue");
161+
162+
if (commandType != null)
163+
{
164+
ps.AddParameter("CommandType", commandType);
165+
}
166+
167+
if (!string.IsNullOrEmpty(moduleName))
168+
{
169+
ps.AddParameter("Module", moduleName);
170+
}
171+
172+
try
173+
{
174+
return ps.Invoke<CommandInfo>()
175+
.FirstOrDefault();
176+
}
177+
// 'Get-Command' is invoked with 'SilentlyContinue', so a CommandNotFoundException can only
178+
// mean that the engine failed to resolve 'Get-Command' itself in the runspace.
179+
// That happened intermittently when lookups ran concurrently because the PowerShell engine
180+
// is not thread safe, see https://github.com/PowerShell/PowerShell/issues/4003 and
181+
// https://github.com/PowerShell/PSScriptAnalyzer/issues/2205
182+
// Lookups are serialized now, so this should no longer occur, but the retry is kept as a
183+
// safety net for hosts that drive the engine from other threads at the same time.
184+
catch (CommandNotFoundException)
159185
{
160-
return null;
186+
if (attempt >= MaxLookupAttempts)
187+
{
188+
return null;
189+
}
161190
}
162191
}
163192
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
Describe "Concurrent command lookups" {
5+
BeforeAll {
6+
# The concurrency driver is written in C# so that the lookups really do run on separate
7+
# threads. Invoking a PowerShell script block on a thread pool thread would introduce
8+
# runspace affinity problems of its own and would not test the command info cache.
9+
$analyzerAssembly = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper].Assembly.Location
10+
Add-Type -IgnoreWarnings -WarningAction SilentlyContinue -ReferencedAssemblies $analyzerAssembly, ([System.Management.Automation.PSObject].Assembly.Location) -TypeDefinition @'
11+
using System.Threading.Tasks;
12+
using Microsoft.Windows.PowerShell.ScriptAnalyzer;
13+
14+
public static class ConcurrentCommandLookup
15+
{
16+
public static string[] Lookup(string[] commandNames)
17+
{
18+
var helper = Helper.Instance;
19+
var tasks = new Task<string>[commandNames.Length];
20+
for (int i = 0; i < commandNames.Length; i++)
21+
{
22+
string name = commandNames[i];
23+
tasks[i] = Task.Run(() =>
24+
{
25+
var commandInfo = helper.GetCommandInfo(name);
26+
return commandInfo == null ? null : commandInfo.Name;
27+
});
28+
}
29+
30+
Task.WaitAll(tasks);
31+
32+
var results = new string[tasks.Length];
33+
for (int i = 0; i < tasks.Length; i++)
34+
{
35+
results[i] = tasks[i].Result;
36+
}
37+
38+
return results;
39+
}
40+
}
41+
'@
42+
}
43+
44+
It "resolves commands from several threads without failing" {
45+
$commandNames = @(
46+
'Get-ChildItem', 'Where-Object', 'ForEach-Object', 'Get-Content', 'Write-Output',
47+
'Test-Path', 'Get-Command', 'Select-Object', 'Sort-Object', 'Measure-Object'
48+
) * 4
49+
50+
# A lookup that hits the thread safety problem throws, which fails the test.
51+
$results = [ConcurrentCommandLookup]::Lookup($commandNames)
52+
53+
$results.Count | Should -Be $commandNames.Count
54+
# A failed lookup returns null, so every entry must name the command that was requested.
55+
for ($i = 0; $i -lt $commandNames.Count; $i++) {
56+
$results[$i] | Should -BeExactly $commandNames[$i]
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)