29

UPDATE: I now have a solution I'm much happier with that, whilst not solving all the problems I ask about, it does leave the way clear to do so. I've updated my own answer to reflect this.

Original Question

Given an App Domain, there are many different locations that Fusion (the .Net assembly loader) will probe for a given assembly. Obviously, we take this functionality for granted and, since the probing appears to be embedded within the .Net runtime (Assembly._nLoad internal method seems to be the entry-point when Reflect-Loading - and I assume that implicit loading is probably covered by the same underlying algorithm), as developers we don't seem to be able to gain access to those search paths.

My problem is that I have a component that does a lot of dynamic type resolution, and which needs to be able to ensure that all user-deployed assemblies for a given AppDomain are pre-loaded before it starts its work. Yes, it slows down startup - but the benefits we get from this component totally outweight this.

The basic loading algorithm I've already written is as follows. It deep-scans a set of folders for any .dll (.exes are being excluded at the moment), and uses Assembly.LoadFrom to load the dll if it's AssemblyName cannot be found in the set of assemblies already loaded into the AppDomain (this is implemented inefficiently, but it can be optimized later):

void PreLoad(IEnumerable<string> paths)
{
  foreach(path p in paths)
  {
    PreLoad(p);
  }
}

void PreLoad(string p)
{
  //all try/catch blocks are elided for brevity
  string[] files = null;

  files = Directory.GetFiles(p, "*.dll", SearchOption.AllDirectories);

  AssemblyName a = null;
  foreach (var s in files)
  {
    a = AssemblyName.GetAssemblyName(s);
    if (!AppDomain.CurrentDomain.GetAssemblies().Any(
        assembly => AssemblyName.ReferenceMatchesDefinition(
        assembly.GetName(), a)))
      Assembly.LoadFrom(s);
  }    
}

LoadFrom is used because I've found that using Load() can lead to duplicate assemblies being loaded by Fusion if, when it probes for it, it doesn't find one loaded from where it expects to find it.

So, with this in place, all I now have to do is to get a list in precedence order (highest to lowest) of the search paths that Fusion is going to be using when it searches for an assembly. Then I can simply iterate through them.

The GAC is irrelevant for this, and I'm not interested in any environment-driven fixed paths that Fusion might use - only those paths that can be gleaned from the AppDomain which contain assemblies expressly deployed for the app.

My first iteration of this simply used AppDomain.BaseDirectory. This works for services, form apps and console apps.

It doesn't work for an Asp.Net website, however, since there are at least two main locations - the AppDomain.DynamicDirectory (where Asp.Net places it's dynamically generated page classes and any assemblies that the Aspx page code references), and then the site's Bin folder - which can be discovered from the AppDomain.SetupInformation.PrivateBinPath property.

So I now have working code for the most basic types of apps now (Sql Server-hosted AppDomains are another story since the filesystem is virtualised) - but I came across an interesting issue a couple of days ago where this code simply doesn't work: the nUnit test runner.

This uses both Shadow Copying (so my algorithm would need to be discovering and loading them from the shadow-copy drop folder, not from the bin folder) and it sets up the PrivateBinPath as being relative to the base directory.

And of course there are loads of other hosting scenarios that I probably haven't considered; but which must be valid because otherwise Fusion would choke on loading the assemblies.

I want to stop feeling around and introducing hack upon hack to accommodate these new scenarios as they crop up - what I want is, given an AppDomain and its setup information, the ability to produce this list of Folders that I should scan in order to pick up all the DLLs that are going to be loaded; regardless of how the AppDomain is setup. If Fusion can see them as all the same, then so should my code.

Of course, I might have to alter the algorithm if .Net changes its internals - that's just a cross I'll have to bear. Equally, I'm happy to consider SQL Server and any other similar environments as edge-cases that remain unsupported for now.

Any ideas!?

Andras Zoltan
  • 41,961
  • 13
  • 104
  • 160
  • not really an answer, but very helpful for me was this MSDN-Article: http://msdn.microsoft.com/en-us/library/yx7xezcf.aspx). And the mentioned Fuslogvw.exe should help getting the "why is that dll not found". – ralf.w. Jun 21 '10 at 06:35
  • ralf.w: I did wonder if I could cheat by getting a fusion log for an non-existent assembly and parse it to see all the places it's looking. Although it would probably work, I get the feeling I'd have to switch on Fusion Logging on the target box (= slow); plus the fact that I'd be coding by exception, which is just simply wrong! Thanks for the link to the article - perhaps a bit more MSDN mining might give me the answer... – Andras Zoltan Jun 21 '10 at 07:18
  • I have exactly this problem, would you be willing to share the code you arrived at? thanks :) – Andrew Bullock Oct 20 '10 at 09:46
  • @Andrew Bullock: would love to; but I haven't yet finished writing and testing it yet! As soon as I have I will most certainly be posting the code here. Indeed if you get a good solution beforehand, feel free to add an answer here and I'll mark you as the answer :) – Andras Zoltan Oct 20 '10 at 13:31

4 Answers4

19

I have now been able to get something much closer to a final solution, except it's still not processing the private bin path correctly. I have replaced my previously live code with this and have also solved a few nasty runtime bugs I've had into the bargain (dynamic compilation of C# code referencing far too many dlls).

The golden rule I've since discovered is always use the load context, not the LoadFrom context, since the Load context will always be the first place .Net looks when performing a natural bind. Therefore, if you use the LoadFrom context, you will only get a hit if you actually load it from the same place that it would naturally bind it from - which isn't always easy.

This solution works both for web applications, taking into account the bin folder difference versus 'standard' apps. It can easily be extended to accommodate the PrivateBinPath problem, once I can get a reliable handle on exactly how it is read(!)

private static IEnumerable<string> GetBinFolders()
{
  //TODO: The AppDomain.CurrentDomain.BaseDirectory usage is not correct in 
  //some cases. Need to consider PrivateBinPath too
  List<string> toReturn = new List<string>();
  //slightly dirty - needs reference to System.Web.  Could always do it really
  //nasty instead and bind the property by reflection!
  if (HttpContext.Current != null)
  {
    toReturn.Add(HttpRuntime.BinDirectory);
  }
  else
  {
    //TODO: as before, this is where the PBP would be handled.
    toReturn.Add(AppDomain.CurrentDomain.BaseDirectory);
  }

  return toReturn;
}

private static void PreLoadDeployedAssemblies()
{
  foreach(var path in GetBinFolders())
  {
    PreLoadAssembliesFromPath(path);
  }
}

private static void PreLoadAssembliesFromPath(string p)
{
  //S.O. NOTE: ELIDED - ALL EXCEPTION HANDLING FOR BREVITY

  //get all .dll files from the specified path and load the lot
  FileInfo[] files = null;
  //you might not want recursion - handy for localised assemblies 
  //though especially.
  files = new DirectoryInfo(p).GetFiles("*.dll", 
      SearchOption.AllDirectories);

  AssemblyName a = null;
  string s = null;
  foreach (var fi in files)
  {
    s = fi.FullName;
    //now get the name of the assembly you've found, without loading it
    //though (assuming .Net 2+ of course).
    a = AssemblyName.GetAssemblyName(s);
    //sanity check - make sure we don't already have an assembly loaded
    //that, if this assembly name was passed to the loaded, would actually
    //be resolved as that assembly.  Might be unnecessary - but makes me
    //happy :)
    if (!AppDomain.CurrentDomain.GetAssemblies().Any(assembly => 
      AssemblyName.ReferenceMatchesDefinition(a, assembly.GetName())))
    {
      //crucial - USE THE ASSEMBLY NAME.
      //in a web app, this assembly will automatically be bound from the 
      //Asp.Net Temporary folder from where the site actually runs.
      Assembly.Load(a);
    }
  }
}

First we have the method used to retrieve our chosen 'app folders'. These are the places where the user-deployed assemblies will have been deployed. It's an IEnumerable because of the PrivateBinPath edge case (it can be a series of locations), but in practise it's only ever one folder at the moment:

The next method is PreLoadDeployedAssemblies(), which gets called before doing anything (here it's listed as private static - in my code this is taken from a much larger static class that has public endpoints that will always trigger this code to be run before doing anything for the first time.

Finally there's the meat and bones. The most important thing here is to take an assembly file and get it's assembly name, which you then pass to Assembly.Load(AssemblyName) - and not to use LoadFrom.

I previously thought that LoadFrom was more reliable, and that you had to manually go and find the temporary Asp.Net folder in web apps. You don't. All you have to is know the name of an assembly that you know should definitely be loaded - and pass it to Assembly.Load. After all, that's practically what .Net's reference loading routines do :)

Equally, this approach works nicely with custom assembly probing implemented by hanging off the AppDomain.AssemblyResolve event as well: Extend the app's bin folders to any plugin container folders you may have so that they get scanned. Chances are you've already handled the AssemblyResolve event anyway to ensure they get loaded when the normal probing fails, so everything works as before.

Andras Zoltan
  • 41,961
  • 13
  • 104
  • 160
  • This is awesome. Just what I was looking for. Thanks for posting updated code!!!! – Byron Whitlock Apr 27 '11 at 21:36
  • are you doing any checking of the dll file before you Assembly.Load to check if it's even a .net dll, or do you just catch the exception and move on? – JJS Aug 17 '16 at 14:11
  • @JJS - yeah all the potential breaking points would be in a `try/catch` - in the situation where I've used it we didn't have any non-CLR DLLs, so it was never an issue, but it should be fine. – Andras Zoltan Sep 01 '16 at 09:00
4

This is what I do:

public void PreLoad()
{
    this.AssembliesFromApplicationBaseDirectory();
}

void AssembliesFromApplicationBaseDirectory()
{
    string baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
    this.AssembliesFromPath(baseDirectory);

    string privateBinPath = AppDomain.CurrentDomain.SetupInformation.PrivateBinPath;
    if (Directory.Exists(privateBinPath))
        this.AssembliesFromPath(privateBinPath);
}

void AssembliesFromPath(string path)
{
    var assemblyFiles = Directory.GetFiles(path)
        .Where(file => Path.GetExtension(file).Equals(".dll", StringComparison.OrdinalIgnoreCase));

    foreach (var assemblyFile in assemblyFiles)
    {
        // TODO: check it isnt already loaded in the app domain
        Assembly.LoadFrom(assemblyFile);
    }
}
Andrew Bullock
  • 36,616
  • 34
  • 155
  • 231
  • interesting - that looks like a good solution; however the PrivateBinPath folder can be, apparently, a list of folders separated by semicolons under ApplicationBase from which DLLs will be loaded. They could be relative or absolute (although these will be ignored if not under the ApplicationBase). So perhaps a tweak is needed there. Also - in an Asp.Net app it's important to load the dlls first from the Asp.Net temp directory when using LoadFrom. If you load A.dll from bin\ the runtime will later load it again from the temp folder and you end up with two copies of the same assembly :( – Andras Zoltan Nov 02 '10 at 22:34
  • im using it in an asp.net app and not had any problems so far. i need to load from the temp first? grrr this is such a crappy problem to have. – Andrew Bullock Nov 02 '10 at 22:44
  • yes you do to be absolutely safe (it seems to depend on where the runtime will locate the assembly if loaded organically, and I only managed to remove duped assemblies this way). It was a pain in the arse to debug that particular problem too! However - +1 anyway :) – Andras Zoltan Nov 25 '10 at 22:09
  • I have now posted my most recent, and improved solution. It's subtlely different from my original one and yours - and I can vouch for it working perfectly (except when there's a PrivateBinPath in-place, which I think will be a simple fix). It works in web apps, normal windows apps and MS Test-hosted tests. – Andras Zoltan Jan 18 '11 at 23:35
0

Have you tried looking at Assembly.GetExecutingAssembly().Location? That should give you the path to the assembly where your code is running from. In the NUnit case, I would expect that to be where the assemblies were shadow copied to.

Andy
  • 411
  • 3
  • 5
  • Yeah, that was one of my first forays into doing this, but it's unreliable in some of the more enhanced situations (the VS test runner, for example). Also, in the case of Asp.Net, for example, the Assembly Loader actually has numerous folders that it will search that are outside the executing assembly's location. – Andras Zoltan Jun 17 '10 at 07:25
  • I definitely agree that it is not sufficient by itself to cover all cases...I was thinking it would give you simply one more data point. So if I understand you correctly, are you looking for one API you can call that will give you all of Fusions search paths? – Andy Jun 18 '10 at 00:11
  • well that would be the holy grail for sure; I can't see anything in the .Net framework that does it and as for unmanaged APIs; I'm not frightened by that stuff (cut my teeth on C++ for many years), but it's difficult to find anything. I've had a look a the Fusion API, but it's mainly for working with the GAC – Andras Zoltan Jun 18 '10 at 08:20
  • I disagree that the Assembly.GetExecutingAssembly() is unreliable in the case of the VS test runner (assuming you mean visual studio unit testing). Unit tests are run from temp directories which, like Environment.CurrentDirectory during debugging, do not equate to the final output path. Perhaps all you need is some more debugging. I have written programs that require me to copy my 3rd party DLL's manually as part of my [ClassInitialize()] routine to said temp directories in order for the assemblies to resolve properly during unit tests. – P.Brian.Mackey Jun 21 '10 at 21:01
  • @P.Brian.Mackey: Thanks for your comment.In the VS Test Runner this system runs quite happily using the AppDomain.BaseDirectory, which typically resolves to the Out/ directory of the current test result. However, the nUnit test runner (used by a colleague) uses shadow copying and additional relative paths for PrivateBinPath so it doesn't work.Equally, in Asp.Net GetExecutingAssembly only solves part of the problem, because that'll be the dynamic dll created for the current page; that provides the dynamic directory, but not the bin\ folder, hence I have to double up with BaseDirectory as well. – Andras Zoltan Jun 21 '10 at 21:13
0

Based on the answer I went and expanded the solution to a reusable utility with programmable functions to change default assembly discovery behavior. Please correct me if subfolders should be included in the discovery.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;

namespace TheOperator.Foundation.Features
{
    public static class AssemblyLoading
    {
        public static bool IsAssemblyFile(
            string path)
        {
            return File.Exists(path) && Path.GetExtension(path).Equals(".dll", StringComparison.OrdinalIgnoreCase);
        }

        public static Assembly[] TryLoadFromDirectory(
            string path)
            => TryLoadFromDirectoryCore(path).ToArray();

        private static IEnumerable<Assembly> TryLoadFromDirectoryCore(
            string path)
        {
            if (Directory.Exists(path))
            {
                foreach (var assemblyFile in DiscoverFromDirectory(path))
                {
                    if (!IsLoaded(assemblyFile))
                    {
                        yield return Assembly.LoadFrom(assemblyFile);
                    }
                }
            }
        }

        public static Func<IEnumerable<Assembly>> GetLoaded
            = () => AppDomain.CurrentDomain.GetAssemblies();

        public static Func<string, IEnumerable<string>> DiscoverFromDirectory
            = x => Directory.GetFiles(x).Where(xx => IsAssemblyFile(xx));

        public static Action<List<string>> CollectDiscoveryDirectories
            = x =>
            {
                x.Add(AppDomain.CurrentDomain.BaseDirectory);
                x.Add(AppDomain.CurrentDomain.SetupInformation.PrivateBinPath);
            };

        public static void TryLoadFromDiscoveryDirectories()
        {
            var locations = new List<string>();
            CollectDiscoveryDirectories(locations);
            foreach (var location in locations)
            {
                TryLoadFromDirectory(location);
            }
        }

        public static bool TryLoadFromFile(
            string path)
        {
            if (IsAssemblyFile(path))
            {
                if (!IsLoaded(path))
                {
                    Assembly.Load(path);
                    return true;
                }
                else
                {
                    return false;
                }
            }
            else
            {
                return false;
            }
        }

        public static bool IsLoaded(
            string assembly)
        {
            foreach (var loadedAssembly in GetLoaded())
            {
                if (!loadedAssembly.IsDynamic && loadedAssembly.Location.Equals(assembly, StringComparison.OrdinalIgnoreCase))
                {
                    return true;
                }
            }
            return false;
        }
    }
}
Demetris Leptos
  • 1,560
  • 11
  • 12