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