@@ -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 }
0 commit comments