Handling Entry Assemblies that Won't Load: Method 2

The last two days we worked on a shim application that allowed us to handle gracefully the condition where a program's main assembly will not load due to declarative security issues.  While we were definitely able to improve on the shim yesterday, there are still several issues with using this approach.  Namely:

  • Assembly.GetEntryAssembly() will still return shim.exe instead of main.exe
  • Shim::Main is going to be at the root of the callstack
  • There are some perf issues with loading, since now the application requires the main assembly to be loaded out of resources.

So lets take a step back and see if we can't solve these problems.  The root cause of all of the issues is that we enter through a shim application instead of going directly into the main assembly.  And the reason we needed a shim application is that we need to have some code loaded before the main assembly is loaded so that we can catch the FileLoadException generated when its minimum grant set cannot be met.  It seems like the best way to solve our problems is to figure out some other way to get code loaded before Main begins executing.

Well, if we're on Whidbey, we can certainly pull that off ... looking back at the overview of AppDomainManagers, one of the properties of an AppDomainManager is that it is the first code to execute in every AppDomain.  In fact, the trace of the EchoAppDomainManager showed that several methods run before Main is ever called.  More importantly, the trace of EchoHostSecurityManager shows that both the AppDomainManager constructor and InitializeNewDomain get called before policy is resolved for the entry assembly.  That means that any code we put into InitializeNewDomain will run before we ever attempt (and possibly fail) to load the entry point.

One more problem stands in our way.  The shim application wrapped the call into the real Main method with a try ... catch for FileLoadException.  But AppDomainManager::InitializeNewDomain isn't on Main's call stack.  So how will we figure out if loading the entry assembly results in a FileLoadException?

Well, we won't be able to catch it directly, since we won't be on the call stack when Main gets called.  And since nobody will have Main in a try ... catch block, the exception will go unhandled, which was the crux of our original problem.  However, whenever an unhandled exception occurs, AppDomain::UnhandledException fires.  So in our InitializeNewDomain, we can hook UnhandledException and deal with the failure to load there.

Once we're in the unhandled exception handler (which always sounds strange to me ...), we can check to see if the current exception is one we are looking for in the same way the original shim did.  If we find that it is, we can display a friendly message and exit the application.  The reason we need to exit is that if we return from this handler, the default CLR behavior will take over, and the exception object will be dumped to the console again, exactly what we were trying to avoid.  One other optimization to make would be to only hook the unhandled exception handler in the default AppDomain, since if we've made secondary domains obviously the program was able to get started.

Putting that all together, we wind up with some code that looks like this:

using System;
using System.IO;
using System.Reflection;
using System.Runtime.Hosting;

[assembly: AssemblyVersion("1.0.0.0")]

public sealed class UnableToLoadAppDomainManager : AppDomainManager
{
    public const int FailCode = 0;
        
    public override void InitializeNewDomain(AppDomainSetup appDomainInfo)
    {
        // hook unhandled exceptions that occur in the default domain
        if(AppDomain.CurrentDomain.Id == 1)
            AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(OnUnhandledException);

        base.InitializeNewDomain(appDomainInfo);
        return;
    }

    /// <summary>
    /// Called whenever there is an unhandled exception in the default AppDomain
    /// </summary>
    private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
    {
        // check to see if this was a failure to load the entry assembly
        FileLoadException exception = e.ExceptionObject as FileLoadException;
        if(exception != null)
        {
            try
            {
                AssemblyName failedAssembly = new AssemblyName(exception.FileName);
                if(failedAssembly.Name == "main")
                {
                    Console.WriteLine("Could not load main.exe, possibly due to security issues. Please copy to a local location.");
                    Environment.Exit(FailCode);
                }
            }
            catch(ArgumentException) { /* do nothing ... this wasn't an assembly name */ }    
        }

        return;
    }
}

Now, setting up and running our entry point from partial trust yields:

Y:\>set APPDOMAIN_MANAGER_ASM=UnableToLoadAppDomainManager, Version=1.0.0.0, Culture=neutral, PublicKeyToken=6078147f5f4cee9d, processorArchitecture=MSIL
Y:\>set APPDOMAIN_MANAGER_TYPE=UnableToLoadAppDomainManager
Y:\>main
Could not load main.exe, possibly due to security issues. Please copy to a local location.