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
- Locates plugin assemblies on disk.
- Lets user choose which plugin to load.
- Runs the plugin in its own process.
- 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
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:
SamplePluginControl | A user control with gradient background |
PluginWithInput | A plugin with some text boxes and message boxes |
BitnessCheck | A 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.
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.
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.
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.
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.
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; }
...
}
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:
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:
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.