Permalink
c245dc4 Aug 31, 2018
1 contributor

Users who have contributed to this file

233 lines (187 sloc) 9.96 KB

Host startup hook

For .NET Core 3+, we want to provide a low-level hook that allows injecting managed code to run before the main application's entry point. This hook will make it possible for the host to customize the behavior of managed applications during process launch, after they have been deployed.

Motivation

This would allow hosting providers to define custom configuration and policy in managed code, including settings that potentially influence load behavior of the main entry point such as the AssemblyLoadContext behavior. The hook could be used to set up tracing or telemetry injection, to set up callbacks for handling Debug.Assert (if we make such an API available), or other environment-dependent behavior. The hook is separate from the entry point, so that user code doesn't need to be modified.

Proposed behavior

The DOTNET_STARTUP_HOOKS environment variable can be used to specify a list of managed assemblies that contain a StartupHook type with a public static void Initialize() method, each of which will be called in the order specified, before the Main entry point

Unix:

DOTNET_STARTUP_HOOKS=/path/to/StartupHook1.dll:/path/to/StartupHook2.dll

Windows:

DOTNET_STARTUP_HOOKS=D:\path\to\StartupHook1.dll;D:\path\to\StartupHook2.dll

This variable is a list of absolute assembly paths, delimited by the platform-specific path separator (; on Windows and : on Unix). It may not contain any empty entries or a trailing path separator. The type must be named StartupHook without any namespace, and should be internal.

Setting this environment variable will cause the public static void Initialize() method of the StartupHook type in each of the specified assemblies to be called in order, synchronously, before the main assembly is loaded. The hooks are all called on the same managed thread (the same thread that calls Main). The environment variable will be inherited by child processes by default. It is up to the StartupHook.dlls and user code to decide what to do about this - StartupHook.dll may clear them to prevent this behavior globally, if desired.

Specifically, hostpolicy starts up coreclr and sets up a new AppDomain, passing in the startup hook variable as the property STARTUP_HOOKS if it was set. This variable can be retrieved using AppContext.GetData("STARTUP_HOOKS"). Hostpolicy then asks the runtime to execute the main method. Just before the main method is called, the runtime will call a private method in System.Private.CoreLib, which will call each StartupHook.Initialize() in turn synchronously. This gives StartupHook a chance to set up new AssemblyLoadContexts, or register other callbacks. After all of the Initialize() methods return, the runtime calls the main entry point of the app like usual.

Rather than forcing all configuration to be done through a single predefined API, this creates a place where such configuration could be centralized, while still allowing user code to do its own thing if it so desires.

The producer of StartupHook.dll needs to ensure that StartupHook.dll is compatible with the dependencies specified in the main application's deps.json, since those dependencies are put on the TPA list during the runtime startup, before StartupHook.dll is loaded. This means that StartupHook.dll needs to built against the same or lower version of .NET Core than the app.

Example

This could be used with AssemblyLoadContext APIs to resolve dependencies not on the TPA list from a shared location, similar to the GAC on full framework. It could also be used to forcibly preload assemblies that are on the TPA list from a different location. Future changes to AssemblyLoadContext could make this easier to use by making the default load context or TPA list modifiable.

Note that the StartupHook type is internal and in the global namespace, and the signature of the Initialize method is public static void Initialize().

internal class StartupHook
{
    public static void Initialize()
    {
        AssemblyLoadContext.Default.Resolving += SharedHostPolicy.SharedAssemblyResolver.LoadAssemblyFromSharedLocation;
    }
}

namespace SharedHostPolicy
{
    class SharedAssemblyResolver
    {
        public static Assembly LoadAssemblyFromSharedLocation(AssemblyLoadContext context, AssemblyName assemblyName)
        {
            string sharedAssemblyPath = // find assemblyName in shared location...
            if (sharedAssemblyPath != null)
                return AssemblyLoadContext.Default.LoadFromAssemblyPath(sharedAssemblyPath)
            return null;
        }
    }
}

Error handling details

Problems with the startup hook should be fairly straightforward to diagnose. All of these exceptions will contain the startup hook path (System.StartupHookProvider.ProcessStartupHooks) on the stack trace. They fall into the following categories:

  • Errors detected eagerly, with exceptions thrown before the execution of any startup hook.

    • Invalid syntax throws an ArgumentException.

    • Partially qualified paths in the startup hook throw an ArgumentException.

  • Exceptions thrown during the call to a given startup hook. Previous hooks may have run successfully.

    • Missing startup hook assemblies throw a FileNotFoundException.

    • Invalid startup hook assemblies throw a BadImageFormatException.

    • Missing startup hook types throw a TypeLoadException.

    • Missing Initialize methods in startup hooks throw a MissingMethodException.

    • Invalid Initialize methods (with an incorrect signature - that take parameters, have a non-void return type, are not public, or are not static) throw an ArgumentException.

    • Unhandled exceptions thrown from a startup hook will have the same exception behavior as any other managed exception thrown from Main - by default, they will terminate the process and show a stack trace.

Guidance and caveats

This hook is meant as a low-level, powerful way to inject code into the process at runtime, for use by tool developers who truly have a need for this kind of power. It should only be used in situations where modifying application code is not an option and there is not an existing structured dependency injection framework in place. An example of such a use case is a hook that injects logging, telemetry, or profiling into an existing deployed application at runtime.

It is prone to ordering issues when multiple hooks are used, and does nothing to attempt to make dependencies of hooks easy to manage. Multiple hooks should be independent of each other.

No built-in solution to ordering issues

For example, if one hook sets global state that introduces logging in the process, the new behavior will affect all subsequent hooks in the process and the Main entry point. Subsequent hooks may attempt to modify logging behavior in a way that conflicts with the first hook, leading to unexpected results. This kind of problem exists for any framework that gives independently-owned components access to shared resources - often dependency injection frameworks will have a dependency manager that loads components in a specific order. If this kind of behavior is required, a proper dependency injection framework should be used instead of multiple startup hooks.

No dependency resolution for non-app assemblies

Another example regarding hook dependencies: the startup hook dll must not depend on any assemblies outside of the app's TPA list. If a startup hook has a static dependency on an assembly like 'Newtonsoft.Json' but the app does not, executing the hook will throw a FileNotFoundException. There is no extra resolution logic for startup hooks. Any startup hook that wants to modify load behavior will have to use framework APIs like AssemblyLoadContexts to do this manually.

No conflict resolution for dependencies shared by hooks or the app

If a startup hook decides to do something dangerous like force the load of a particular assembly, any later hooks (or the entry point) that run in the same AssemblyLoadContext and depend on that assembly will use the version that was forcefully loaded, even if they were compiled against a different version.

Threading behavior

Each startup hook will run on the same managed thread as the Main method, so thread state will persist between startup hooks. The threading apartment state will be set based on any attributes present in the Main method of the app, before startup hooks execute. As a result, attemps to explicitly set the thread apartment state in a startup hook will fail if the requested state is incompatible with the app's threading state.

While it may make sense to set global behavior in startup hooks, it is not recommended to use the thread state as a communication mechanism between startup hooks. Any setup that requires multiple communicating hooks should consider using a plugin system instead.

In order to use ThreadStatic storage, for example, the class containing the shared thread state needs to be a common dependency of the hooks that use it. Because hooks can not depend on assemblies outside of the app's TPA list, this requires the shared state class to be defined either in the app or within the first hook that uses it:

  • If defined in the app, the shared state used by startup hooks would need to be compiled into the app. In that case, consider explicitly activating the desired behavior by modifying the app code, instead of using startup hooks.

  • If defined in the first startup hook, all subsequent hooks that access the ThreadStatic need to be compiled with references to the first. In a situation like this, consider making the components that need to communicate with each other part of a common plugin framework. If necessary, the plugin host could be injected into the process with a single startup hook.

Visibility of StartupHook type

The type should be made internal to prevent exposing it as API surface to any managed code that happens to have access to the startup hook dll. However, the feature will also work if the type is public.