Thursday, January 31, 2008

Executing code in another app domain.

Recently, we found out that the Infragistics controls we're using have some bugs in them that cause them to never be garbage collected once you've used them in your code. There doesn't seem to be any resolution short of asking them to fix the bugs, as the bugs are related to their code adding event handlers for receiving notification of changes in the office 2007 themes container (their internal class).

Anyway, we figured out that there isn't any workaround for this short of modifying and recompiling their code or waiting for them to fix their bugs. Since we don't have time for that, and it represents a HUGE memory leak in our application (each time our form is opened and closed, the UI sucks up 30-50MB of additional memory!)

So, our solution, you ask? We first built an out of process approach to executing our UI via IDispatch implemented in a out-of-process .EXE COM server. This was a fair amount of work and was a really cool project in and of itself. If you're interested in it, let me know and I'll tell you how it works. Unfortunately, due to changes in windows rules for window activation and some plumbing issues, it turns out that the out-of-process approach won't activate the window when it starts up, and short of doing some really nasty hack, there isn't an easy fix for that.

When we figured the out-of-process solution wasn't really the best answer for our problems, we decided to investigate further. Once we determined that the leak was on our .NET side of the fence (our .NET UI is called by a Dyalog APL-based user interface via their .NET interop support), we decided to investigate the .NET side a bit more. That's when we found that it was a leak caused by the Infragistics controls.

I did a proof of concept that created a separate AppDomain, called our NUI, and then shutdown the app domain. This seems to fix our memory leak problem as all memory associated with the app domain is freed (at least it seems to be). It turned out to be much harder to get this working than I had expected. In theory it's quite easy, but we have several deployment issues that make it very difficult (we can't put our DLLs in the GAC, and our application DLLs are not (and can't be) in the same folder as the 'application base' (because the base application that creates the main app domain is Dyalog.exe or DyalogRT.exe and we don't want this in our application folder).

The process goes something like this:
1) create an app domain
2) load our assembly into the domain
3) create a serializable object with our arguments for the call to the assembly
4) call to the assembly (serializing the arguments) (blocking call)
5) the assembly returns from the call, returning the results in a serializable object
6) the calling code deserializes the object and continues as it normally would.

The problems in this are: (2) finding the assembly; (3) making the serialized object's assembly available to the target assembly; (4) getting a reference to a MarshalByRefObject to make the call against; (5) loading the results objects assembly in the source app domain.

The main issues are loading the assemblies on the caller's side, rather than the target side, amazingly! It seems like this should be the easy part, but for some reason the remoting infrastructure isn't smart enough to realize that the assemblies are ALREADY LOADED!

Anyway, the basics are like this:

  1. create an object that derives from MarshalByRefObject. Put this in an assembly that you don't mind having loaded in both app domains.
  2. define a method on this object that does the work you want done.
  3. make sure that the objects you pass to and from the method are marked Serializable (and can be serialized and deserialized - you can test this by using the BinaryFormatter to write and read the file to/from a stream).
  4. create a method somewhere in your main app domain that does the following:
    1. packages the parameters into the serializable objects
    2. creates an app domain using AppDomain.CreateDomain. We also pass a different appbase that is based on our assemblies' locations.
    3. sets up an event handler on the current app domain's AssemblyResolve event (this is only necessary if you can't get the app domain to properly resolve your assembly and instead your object comes back only as MarshalByRefObject, instead of what you expected).
    4. call CreateInstanceAndUnwrap on the target app domain, asking it to create an instance of your MarshalByRefObject derived object.
    5. cast the object from CreateInstanceAndUnwrap to your target object type.
    6. Call your method.
  5. Put code in the method handler (if you need it) for your AssemblyResolve event that iterates through the loaded assemblies and returns the one that was requested, if it matches by name. ResolveEventArgs.Name should be matched against Assembly.FullName. My code looks like:
    static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
    {
    Debug.WriteLine("Attempting to resolve assembly: "
    + args.Name);
    // search the loaded assemblies for this one.
    foreach (Assembly assy
    in AppDomain.CurrentDomain.GetAssemblies())
    if (assy.FullName == args.Name)
    return assy;
    return Assembly.GetExecutingAssembly().FullName
    == args.Name
    ? Assembly.GetExecutingAssembly() : null;
    }
  6. Write code to unload the AppDomain (call AppDomain.Unload(...)) (I do this in a 'finally' that follows a 'try' started immediately after the app domain is created)

This seems to do the trick. Let me know if you have any problems.

Here's where I got some of my information:

http://blogs.msdn.com/suzcook/archive/2003/06/12/57169.aspx
http://blogs.msdn.com/suzcook/archive/2003/05/29/57120.aspx
http://www.codeguru.com/forum/showthread.php?t=398030
http://blogs.msdn.com/suzcook/archive/2003/05/29/57143.aspx
http://blogs.msdn.com/suzcook/archive/2003/06/13/57180.aspx

By the way, if you've never come across it, Suzanne's blog is a GREAT source of information.

No comments: