Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Baktun Shell: Hosting WPF Child Windows in Another Process

0.00/5 (No votes)
11 Mar 2014 1  
Baktun Shell is a demo app that hosts its child windows in separate processes.

Update: March 11, 2014

I have published a description of a new version of Baktun Shell in the MSDN Magazine. It also has been published on GitHub. In fact, it is a completely different project created from scratch on the same principles as Baktun Shell. This version is much closer to an actual production system. It lets the host and the plugins call each other, takes care of a "sudden death" of the shell or a plugin, adds more robust error logging, et cetera, et cetera.

Old Baktun Shell is still available under the "branches/1.0" folder.

Table of Contents

What is Baktun Shell

Baktun Shell is a WPF application that hosts child windows in a separate process. It

  1. Locates plugin assemblies on disk.
  2. Lets user choose which plugin to load.
  3. Runs the plugin in its own process.
  4. Instantiates a UserControl from the plugin and displays it as a tab of the tab control.

Why is This a Good Idea?

Hosting in another process is useful for a number of reasons:

  • Reliability through isolation: a plugin runs in its own address space and cannot mess with other plugin's data
  • Unloading at will: a plugin can be safely unloaded at any time.
  • Mixing 32-bit and 64-bit code: as each process is either 32-bit or 64-bit, it is not possible to mix two types of code in a single process.

Other benefits of process isolation that are not yet implemented by this demo are:

  • Separate configuration for each plugin: it is possible to give each plugin its own app.config file.
  • Mixing CLR versions: by applying per plugin configuration it should be possible to run one plugin as .NET 4.0 while another as .NET 4.5, etc.

What is Baktun?

Baktun is a time period in ancient Mayan calendar roughly equal to 400 years. The end of the 12th Baktun on December 20, 2012 caused widespread rumors about (yet another) end of the world. As I was finishing up the shell code on December 21st, I noticed that the end of the world did not happen, and the 13th Baktun has happily begun. In honor of this event, I decided to call my program Baktun Shell. After all, we won't have another chance to celebrate beginning of a Baktun for about 400 more years.

Screenshots

Screnshot1
3D Molecule Viewer running inside Baktun Shell. The viewer is created by InterKnowlogy.
See 3dmoleculeviewer.codeplex.com for more information.
Screnshot2
64-bit plugin running inside 32-bit shell. Note the amount of virtual and physical memory allocated by the plugin.

How to Use the Shell

Download and unzip the project and compile the solution with Visual Studio 2010 or 2012. Upon startup the shell analyzes the assemblies located in its binary directory and lets you choose which assembly and which class to load. Only classes that derive from UserControl are shown. The shell process itself is 32-bit. A plugin can be loaded as 32-bit or 64-bit. In DEBUG mode plugin processes are created with console windows that show some diagnostic information. In RELEASE modes these windows are hidden.

The following projects are part of the shell core: Shell, Interfaces, PluginHost, PluginHost64.

SamplePlugin project contains a number of simple plugins:

SamplePluginControlA user control with gradient background
PluginWithInputA plugin with some text boxes and message boxes
BitnessCheckA plugin that shows whether it is 32 or 64-bit and that can allocate memory in large quantities

I have also included two third party applications from CodePlex: 3D MoleculeViewer and Smith HTML Editor. The former required slight modifications to convert main window to a UserControl. The latter is used as is, since it is already a user control.

How It Works in a Nutshell

WPF controls cannot be marshaled between processes directly. However, they can be converted to an INativeHandleContract interface and back using FrameworkElementAdapters class from MAF technology stack.

Marshalling Diagram

This raw scheme does not quite work out of the box, but with a little tweaking it can be used successfully to marshal WPF controls between processes.

Existing Plugin Frameworks and Isolation

A scenario where a GUI application is broken up into shell (host) and plugins (add-ons, extensions, modules) is not new. Over the years a number of frameworks were developed to facilitate this: OLE, CAB, MAF, MEF, and Prism to name just a few. Of these only MAF, MEF and MOF Prism are relevant to WPF applications.

When a shell loads a plugin, it has three sensible choices for plugin isolation:

  • Load plugin assemblies into the shell's AppDomain, i.e. no isolation,
  • Run plugin in the shell process, but in separate AppDomain,
  • Run plugin in its own process,

followed perhaps by "run plugin on another machine", "run plugin in another country", and "run plugin on another planet", but I digress here.

Higher level of isolation typically means more plumbing and more overhead, but also more degrees of freedom and more reliability. E.g. if we want to unload our plugins at will, we must use separate AppDomains. AppDomains provide certain level of protection against data corruption and failures, but processes provide even better protection. If we want to mix 32-bit and 64-bit plugins, or mix different versions of the CLR, we must use separate processes.

Unfortunately, neither MEF nor Prism provide isolation support out of the box. However, see Piotr Włodek's post regarding possible isolation solution for MEF. MAF does support isolation, and there is even a rough sample for cross-process WPF components, with some context given in this thread.

The biggest trouble with MAF is that it is very, very complex. Baktun Shell uses MAF's mechanism for marshalling WPF controls, but bypasses the rest of the MAF pipeline model. This makes it much simpler and easier to work with.

Baktun Shell Inner Workings

Loading a Plugin

Shell's main window contains a standard TabControl slightly enhanced with the "close" button for each tab. When the user clicks on the "Load" button, main window asks PluginHostProxy class to create a new Plugin instance and creates a tab for it. PluginHostProxy class is responsible for spinning off and communicating to the child process that will host the plugin.

Plugin Creation

Unloading a Plugin

When user clicks on the [x] button or when the whole application is closed, MainWindow will remove the plugin from the tab control and will call Dispose() on it. This alerts the PluginHostProxy, that asks the plugin host process to terminate itself.

Plugin Disposal

Spinning Off Plugin Host Process

When PluginHostProxy receives a request to load plugin, it starts a new process. The process executable is either PluginHost.exe or PluginHost64.exe, depending on the requested bitness. The process receives in its command line unique process name based on a GUID, e.g.

PluginHost64.exe PluginHost.f3287246-6b77-48de-826c-6d383c42124e

The plugin host process sets up a remoting service of type PluginHostLoader listening on the URL ipc://PluginHost.f3287246-6b77-48de-826c-6d383c42124e/PluginHostLoader. When the remoting server is ready, the plugin host signals a named "ready" event. In this case the name of the event would be "PluginHost.f3287246-6b77-48de-826c-6d383c42124e.Ready".

After receiving the "ready" signal, PluginHostProxy instance in the shell process requests a remoting object of type IPluginLoader at ipc://PluginHost.f3287246-6b77-48de-826c-6d383c42124e/PluginHostLoader, where IPluginLoader is defined as follows:

public interface IPluginLoader
{
    INativeHandleContract LoadPlugin(string assembly, string typeName);
 
    [OneWay]
    void Terminate();
}  

PluginHostProxy then makes a call to IPluginLoader.LoadPlugin() with plugin assembly and type name. The PluginLoader class in the context of the remote process loads the requested assembly, creates instance of requested type, converts it to INativeHandleContract and returns it back to the shell process. The shell process then converts INativeHandleContract to a FrameworkElement, makes it part of a new Plugin instance and adds it to the main tab control.

Process Spinoff

I did consider a reverse arrangement, when the shell sets up a remoting server and the plugin host makes a call. This eliminates the need for "ready" event, but it creates a difficulty with error reporting. Plugin creation errors, if any, are reported to the plugin host, and it has no easy way of reporting them back to the shell. This definitely can be fixed, but overall solutions seems more complicated than the solution when the shell "drives". Besides, the plugin host process will have to implement some kind of server anyway, so the shell could tell it to terminate: creating TerminateProcess() is just not cool.

Terminating Plugin Process

This one is much easier. When a plugin is disposed, it will signal Disposed event to which its parent PluginHostProxy is subscribed. PluginHostProxy will then call IPluginLoader.Terminate() which gracefully ends plugin host process.

Process Shutdown

Peculiarities of Cross Process Remoting

Initializing Remoting Server

We are forced to use Remoting and not WCF, because we want to marshal INativeHandleContract, which is not marked with [ServiceContract] attribute. Therefore, WCF will not agree to marshal it.

The most common form of remoting I encountered so far was remoting between two AppDomains in the same process. Cross process remoting is a little different in certain aspects. In particular, you must manually initialize your channels and register services. While doing that, you must keep in mind that returning MarshalByRefObject from a method call is not allowed by default. To enable it, one must use a binary formatter with TypeFilterLevel set to Full, see code below.

Another hurdle is that the URL on which the server will listen is defined as an element of a properties hashtable with a magic name of "portname". In our case "portname" is the unique name passed to the host process by the shell, e.g. PluginHost.f3287246-6b77-48de-826c-6d383c42124e. We register a well known service of type PluginLoader at the URI PluginLoader with Singleton activation. The remoting system will create an instance o PluginLoader on our behalf when first call to that service is made.

Since our remoting channel is IPC channel, our "portname" is PluginHost.f3287246-6b77-48de-826c-6d383c42124e, and our service URI is PluginLoader, the full URI for the service as used on the shell side is

ipc://PluginHost.f3287246-6b77-48de-826c-6d383c42124e/PluginLoader 

Here is complete remoting server initialization code from PluginHost\Program.cs:

var serverProvider = new BinaryServerFormatterSinkProvider { TypeFilterLevel = TypeFilterLevel.Full };
var clientProvider = new BinaryClientFormatterSinkProvider();
var properties = new Hashtable();
properties["portName"] = name;

var channel = new IpcChannel(properties, clientProvider, serverProvider);
ChannelServices.RegisterChannel(channel, false);

RemotingConfiguration.RegisterWellKnownServiceType(
    typeof(PluginLoader), "PluginLoader", WellKnownObjectMode.Singleton);

Client Side Type Casts and Internal Method Calls

Another peculiarity of cross-process remoting is that it handles type conversions on the client side in an unexpected way. To illustrate this, let me start from the beginning of the ordeal that led to his discovery. In the PluginLoader class I used to have the following code:

class PluginLoader : MarshalByRefObject, IPluginLoader
{
    public INativeHandleContract LoadPlugin(string assembly, string typeName)
    {
        ...
        var contract = (INativeHandleContract)
                       Program.Dispatcher.Invoke(createOnUiThread, assembly, typeName);
        return contract; // does not work as expected!
    }
    ...
} 

This code blew up on the client side with the following exception:

System.Runtime.Remoting.RemotingException: 
Permission denied: cannot call non-public or static methods remotely.
Server stack trace: 
   ...
   at System.AddIn.Pipeline.AddInHwndSourceWrapper.RegisterKeyboardInputSite(AddInHostSite hwndHost)
   at MS.Internal.Controls.AddInHost..ctor(INativeHandleContract contract)
   at System.AddIn.Pipeline.FrameworkElementAdapters.ContractToViewAdapter(INativeHandleContract nativeHandleContract)
   at Shell.PluginHostProxy.LoadPlugin(String assemblyName, String typeName) 
   at Shell.MainViewModel.Load()

What happened here is that we returned a INativeHandleContract from the plugin host process, and it could not be converted back to FrameworkElement on the client side. The failure occurred within the constructor of an internal class MS.Internal.Controls.AddInHost. With a little help from .NET reflector it turned out that the offending code looks like this:

// from Reflector
1: internal AddInHost(INativeHandleContract contract) : base(true)
2: {
3:    _contractHandle = new ContractHandle(contract);
4:    _addInHwndSourceWrapper = contract as AddInHwndSourceWrapper;
5:    if (_addInHwndSourceWrapper != null)
6:    {
7:        _addInHwndSourceWrapper.RegisterKeyboardInputSite(new AddInHostSite(this));
8:    }
9: }

The reason this code fails is as follows. When the shell calls PluginLoader.LoadPlugin() via remoting, it receives back an INativeHandleContract reference. However, the remoting system retains information about its real type in the remote process. The "as" conversion on line 4 succeeds, and the "if" condition on line 5 is true.

When on line 7 we make a call to AddInHwndSourceManager.RegisterKeyboardInputSite(), remoting realizes that this is an internal method call, and such calls are not allowed to go cross-process for security reasons. In other words, it is OK for the client to convert the proxy to an internal type, but it is not OK to call any internal methods. Note that this limitation does not apply when calling a different AppDomain within the same process. This is why this code works fine with AppDomains.

The solution to this problem is to return an object that implements INativeHandleContract, but does not inherit from AddInHwndSourceWrapper. For this purpose I created the NativeHandleContractInsulator class. This is a simple decorator that forwards all its methods to a real INativeHandleContract. Its only purpose in life is to prevent unwanted type casts on the client side.

The working implementation of the PluginLoader.LoadPlugin() therefore looks like this:

class PluginLoader : MarshalByRefObject, IPluginLoader
{
    public INativeHandleContract LoadPlugin(string assembly, string typeName)
    {
        ...
        var contract = (INativeHandleContract)
                       Program.Dispatcher.Invoke(createOnUiThread, assembly, typeName);
        var insulator = new NativeHandleContractInsulator(contract);
        return insulator; 
    }
    ...
}

The revised marshalling object diagram:

Marshalling Revised

Status of Baktun Shell and to Do Items

Although Baktun Shell code is working, there is still a lot of room for improvement:

  • Add "self-destruct" feature to orphaned plugin host processes, perhaps using remoting ISponsor mechanism.
  • Add ability to specify configuration file for a plugin.
  • Lower CLR version of plugin host to 3.5 and allow to specify a CLR version for a plugin: 3.5, 4.0, 4.5, ...
  • Add ability to load plugins from arbitrary locations on disk.
  • Improve error reporting when PluginHost process crashes.

Conclusion

Hosting a WPF window in another process requires some plumbing, but it works surprisingly well. If your task is limited to visual integration, Baktun shell may be all you need, perhaps with some improvements specified in the previous section. If your application simply cannot be fit into a single process due to conflicting requirements (e.g. "module X must interact with this old 32-bit code, but module Y needs 10G of memory"), such multi-process solution may be the only reasonable way out.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here