@@ -14,6 +14,13 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer
1414 /// </summary>
1515 internal class CommandInfoCache : IDisposable
1616 {
17+ /// <summary>
18+ /// Number of times a command lookup is attempted before giving up.
19+ /// Command lookups can fail transiently because the PowerShell engine is not thread safe,
20+ /// see https://github.com/PowerShell/PowerShell/issues/4003
21+ /// </summary>
22+ private const int MaxLookupAttempts = 3 ;
23+
1724 private readonly ConcurrentDictionary < CommandLookupKey , Lazy < CommandInfo > > _commandInfoCache ;
1825 private readonly RunspacePool _runspacePool ;
1926 private bool disposed = false ;
@@ -70,7 +77,19 @@ public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes
7077 return GetCommandInfoInternal ( commandName , commandTypes ) ;
7178 }
7279 // Atomically either use PowerShell to query a command info object, or fetch it from the cache
73- return _commandInfoCache . GetOrAdd ( key , new Lazy < CommandInfo > ( ( ) => GetCommandInfoInternal ( commandName , commandTypes ) ) ) . Value ;
80+ var lazyCommandInfo = _commandInfoCache . GetOrAdd ( key , new Lazy < CommandInfo > ( ( ) => GetCommandInfoInternal ( commandName , commandTypes ) ) ) ;
81+ try
82+ {
83+ return lazyCommandInfo . Value ;
84+ }
85+ catch
86+ {
87+ // Lazy<T> caches exceptions forever, which would make every subsequent lookup of this
88+ // command fail for the lifetime of the process. Evict the entry so that the next lookup
89+ // can try again.
90+ _commandInfoCache . TryRemove ( key , out _ ) ;
91+ throw ;
92+ }
7493 }
7594
7695
@@ -99,26 +118,46 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command
99118 // For more details see https://github.com/PowerShell/PowerShell/issues/9308
100119 actualCmdName = WildcardPattern . Escape ( actualCmdName ) ;
101120
102- using ( var ps = System . Management . Automation . PowerShell . Create ( ) )
121+ for ( int attempt = 1 ; ; attempt ++ )
103122 {
104- ps . RunspacePool = _runspacePool ;
105-
106- ps . AddCommand ( "Get-Command" )
107- . AddParameter ( "Name" , actualCmdName )
108- . AddParameter ( "ErrorAction" , "SilentlyContinue" ) ;
109-
110- if ( commandType != null )
111- {
112- ps . AddParameter ( "CommandType" , commandType ) ;
113- }
114-
115- if ( ! string . IsNullOrEmpty ( moduleName ) )
123+ using ( var ps = System . Management . Automation . PowerShell . Create ( ) )
116124 {
117- ps . AddParameter ( "Module" , moduleName ) ;
125+ ps . RunspacePool = _runspacePool ;
126+
127+ ps . AddCommand ( "Get-Command" )
128+ . AddParameter ( "Name" , actualCmdName )
129+ . AddParameter ( "ErrorAction" , "SilentlyContinue" ) ;
130+
131+ if ( commandType != null )
132+ {
133+ ps . AddParameter ( "CommandType" , commandType ) ;
134+ }
135+
136+ if ( ! string . IsNullOrEmpty ( moduleName ) )
137+ {
138+ ps . AddParameter ( "Module" , moduleName ) ;
139+ }
140+
141+ try
142+ {
143+ return ps . Invoke < CommandInfo > ( )
144+ . FirstOrDefault ( ) ;
145+ }
146+ // 'Get-Command' is invoked with 'SilentlyContinue', so a CommandNotFoundException can only
147+ // mean that the engine failed to resolve 'Get-Command' itself in the pooled runspace.
148+ // That happens intermittently because the PowerShell engine is not thread safe, see
149+ // https://github.com/PowerShell/PowerShell/issues/4003 and
150+ // https://github.com/PowerShell/PSScriptAnalyzer/issues/2205
151+ // Retrying usually succeeds, but rather than failing the whole analysis when it does not,
152+ // treat the command as unresolvable.
153+ catch ( CommandNotFoundException )
154+ {
155+ if ( attempt >= MaxLookupAttempts )
156+ {
157+ return null ;
158+ }
159+ }
118160 }
119-
120- return ps . Invoke < CommandInfo > ( )
121- . FirstOrDefault ( ) ;
122161 }
123162 }
124163
0 commit comments