77using System . IO ;
88using System . Linq ;
99using System . Reflection ;
10+ using System . Threading ;
1011
1112using Microsoft . VisualStudio . TestPlatform . ObjectModel ;
1213using Microsoft . VisualStudio . TestPlatform . PlatformAbstractions ;
@@ -31,6 +32,7 @@ internal class AssemblyResolver : IDisposable
3132 /// Specifies whether the resolver is disposed or not
3233 /// </summary>
3334 private bool _isDisposed ;
35+ private Stack < string > ? _currentlyResolvingResources ;
3436
3537 /// <summary>
3638 /// Assembly resolver for platform
@@ -120,7 +122,71 @@ internal void AddSearchDirectories(IEnumerable<string> directories)
120122
121123 TPDebug . Assert ( requestedName != null && ! requestedName . Name . IsNullOrEmpty ( ) , "AssemblyResolver.OnResolve: requested is null or name is empty!" ) ;
122124
123- foreach ( var dir in _searchDirectories )
125+ // Workaround: adding expected folder for the satellite assembly related to the current CurrentThread.CurrentUICulture relative to the current assembly location.
126+ // After the move to the net461 the runtime doesn't resolve anymore the satellite assembly correctly.
127+ // The expected workflow should be https://learn.microsoft.com/en-us/dotnet/core/extensions/package-and-deploy-resources#net-framework-resource-fallback-process
128+ // But the resolution never fallback to the CultureInfo.Parent folder and fusion log return a failure like:
129+ // ...
130+ // LOG: The same bind was seen before, and was failed with hr = 0x80070002.
131+ // ERR: Unrecoverable error occurred during pre - download check(hr = 0x80070002).
132+ // ...
133+ // The bizarre thing is that as a result we're failing caller task like discovery and when for reporting reason
134+ // we're accessing again to the resource it works.
135+ // Looks like a loading timing issue but we're not in control of the assembly loader order.
136+ var isResource = requestedName . Name . EndsWith ( ".resources" ) ;
137+ string [ ] ? satelliteLocation = null ;
138+
139+ // We help to resolve only test platform resources to be less invasive as possible with the default/expected behavior
140+ if ( isResource && requestedName . Name . StartsWith ( "Microsoft.VisualStudio.TestPlatform" ) )
141+ {
142+ try
143+ {
144+ string ? currentAssemblyLocation = null ;
145+ try
146+ {
147+ currentAssemblyLocation = Assembly . GetExecutingAssembly ( ) . Location ;
148+ // In .NET 5 and later versions, for bundled assemblies, the value returned is an empty string.
149+ currentAssemblyLocation = currentAssemblyLocation == string . Empty ? null : Path . GetDirectoryName ( currentAssemblyLocation ) ;
150+ }
151+ catch ( NotSupportedException )
152+ {
153+ // https://learn.microsoft.com/en-us/dotnet/api/system.reflection.assembly.location
154+ }
155+
156+ if ( currentAssemblyLocation is not null )
157+ {
158+ List < string > satelliteLocations = new ( ) ;
159+
160+ // We mimic the satellite workflow and we add CurrentUICulture and CurrentUICulture.Parent folder in order
161+ string ? currentUICulture = Thread . CurrentThread . CurrentUICulture ? . Name ;
162+ if ( currentUICulture is not null )
163+ {
164+ satelliteLocations . Add ( Path . Combine ( currentAssemblyLocation , currentUICulture ) ) ;
165+ }
166+
167+ // CurrentUICulture.Parent
168+ string ? parentCultureInfo = Thread . CurrentThread . CurrentUICulture ? . Parent ? . Name ;
169+ if ( parentCultureInfo is not null )
170+ {
171+ satelliteLocations . Add ( Path . Combine ( currentAssemblyLocation , parentCultureInfo ) ) ;
172+ }
173+
174+ if ( satelliteLocations . Count > 0 )
175+ {
176+ satelliteLocation = satelliteLocations . ToArray ( ) ;
177+ }
178+ }
179+ }
180+ catch ( Exception ex )
181+ {
182+ // We catch here because this is a workaround, we're trying to substitute the expected workflow of the runtime
183+ // and this shouldn't be needed, but if we fail we want to log what's happened and give a chance to the in place
184+ // resolution workflow
185+ EqtTrace . Error ( $ "AssemblyResolver.OnResolve: Exception during the custom satellite resolution\n { ex } ") ;
186+ }
187+ }
188+
189+ foreach ( var dir in ( satelliteLocation is not null ) ? _searchDirectories . Union ( satelliteLocation ) : _searchDirectories )
124190 {
125191 if ( dir . IsNullOrEmpty ( ) )
126192 {
@@ -134,29 +200,61 @@ internal void AddSearchDirectories(IEnumerable<string> directories)
134200 var assemblyPath = Path . Combine ( dir , requestedName . Name + extension ) ;
135201 try
136202 {
137- if ( ! File . Exists ( assemblyPath ) )
203+ bool pushed = false ;
204+ try
138205 {
139- EqtTrace . Info ( "AssemblyResolver.OnResolve: {0}: Assembly path does not exist: '{1}', returning." , args . Name , assemblyPath ) ;
206+ if ( isResource )
207+ {
208+ // Check for recursive resource lookup.
209+ // This can happen when we are on non-english locale, and we try to load mscorlib.resources
210+ // (or potentially some other resources). This will trigger a new Resolve and call the method
211+ // we are currently in. If then some code in this Resolve method (like File.Exists) will again
212+ // try to access mscorlib.resources it will end up recursing forever.
140213
141- continue ;
142- }
214+ if ( _currentlyResolvingResources != null && _currentlyResolvingResources . Count > 0 && _currentlyResolvingResources . Contains ( assemblyPath ) )
215+ {
216+ EqtTrace . Info ( "AssemblyResolver.OnResolve: {0}: Assembly is searching for itself recursively: '{1}', returning as not found." , args . Name , assemblyPath ) ;
217+ _resolvedAssemblies [ args . Name ] = null ;
218+ return null ;
219+ }
143220
144- AssemblyName foundName = _platformAssemblyLoadContext . GetAssemblyNameFromPath ( assemblyPath ) ;
221+ _currentlyResolvingResources ??= new Stack < string > ( 4 ) ;
222+ _currentlyResolvingResources . Push ( assemblyPath ) ;
223+ pushed = true ;
224+ }
145225
146- if ( ! RequestedAssemblyNameMatchesFound ( requestedName , foundName ) )
147- {
148- EqtTrace . Info ( "AssemblyResolver.OnResolve: {0}: File exists but version/public key is wrong. Try next extension." , args . Name ) ;
149- continue ; // File exists but version/public key is wrong. Try next extension.
150- }
226+ if ( ! File . Exists ( assemblyPath ) )
227+ {
228+ EqtTrace . Info ( "AssemblyResolver.OnResolve: {0}: Assembly path does not exist: '{1}', returning." , args . Name , assemblyPath ) ;
229+
230+ continue ;
231+ }
232+
233+ AssemblyName foundName = _platformAssemblyLoadContext . GetAssemblyNameFromPath ( assemblyPath ) ;
234+
235+ if ( ! RequestedAssemblyNameMatchesFound ( requestedName , foundName ) )
236+ {
237+ EqtTrace . Info ( "AssemblyResolver.OnResolve: {0}: File exists but version/public key is wrong. Try next extension." , args . Name ) ;
238+ continue ; // File exists but version/public key is wrong. Try next extension.
239+ }
151240
152- EqtTrace . Info ( "AssemblyResolver.OnResolve: {0}: Loading assembly '{1}'." , args . Name , assemblyPath ) ;
241+ EqtTrace . Info ( "AssemblyResolver.OnResolve: {0}: Loading assembly '{1}'." , args . Name , assemblyPath ) ;
153242
154- assembly = _platformAssemblyLoadContext . LoadAssemblyFromPath ( assemblyPath ) ;
155- _resolvedAssemblies [ args . Name ] = assembly ;
243+ assembly = _platformAssemblyLoadContext . LoadAssemblyFromPath ( assemblyPath ) ;
244+ _resolvedAssemblies [ args . Name ] = assembly ;
156245
157- EqtTrace . Info ( "AssemblyResolver.OnResolve: Resolved assembly: {0}, from path: {1}" , args . Name , assemblyPath ) ;
246+ EqtTrace . Info ( "AssemblyResolver.OnResolve: Resolved assembly: {0}, from path: {1}" , args . Name , assemblyPath ) ;
158247
159- return assembly ;
248+ return assembly ;
249+ }
250+ finally
251+ {
252+ if ( isResource && pushed )
253+ {
254+ _currentlyResolvingResources ? . Pop ( ) ;
255+ }
256+
257+ }
160258 }
161259 catch ( FileLoadException ex )
162260 {
0 commit comments