Skip to content

Commit 469fd0a

Browse files
authored
Merge pull request #2 from jessehouwing/copilot/marshal-concurrent-calls-to-sequential
Serialize CommandInfo lookups onto a single dedicated runspace
2 parents 9734b62 + ae52d7c commit 469fd0a

2 files changed

Lines changed: 132 additions & 40 deletions

File tree

Engine/CommandInfoCache.cs

Lines changed: 68 additions & 40 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);
@@ -45,17 +57,23 @@ public void Dispose()
4557

4658
protected virtual void Dispose(bool disposing)
4759
{
48-
if ( disposed )
60+
// Always take the lock, also on the finalizer path, so that 'disposed' is never
61+
// published without the runspace being disposed along with it and so that the runspace
62+
// cannot be disposed while a lookup is in flight.
63+
lock (_runspaceLock)
4964
{
50-
return;
51-
}
65+
if ( disposed )
66+
{
67+
return;
68+
}
5269

53-
if ( disposing )
54-
{
55-
_runspacePool.Dispose();
56-
}
70+
disposed = true;
5771

58-
disposed = true;
72+
if ( disposing )
73+
{
74+
_runspace.Dispose();
75+
}
76+
}
5977
}
6078

6179
/// <summary>
@@ -123,41 +141,51 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command
123141

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

139-
if (!string.IsNullOrEmpty(moduleName))
153+
using (var ps = System.Management.Automation.PowerShell.Create())
140154
{
141-
ps.AddParameter("Module", moduleName);
142-
}
155+
ps.Runspace = _runspace;
143156

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

0 commit comments

Comments
 (0)