I've come across an interesting issue, which is really two-fold I guess. I'll try and keep this focused though. I have an environment set up in which an assembly is programmatically compiled and loaded into a child app domain. A class from that child app domain's assembly is instantiated (it's actually marshaled back to the parent domain and a proxy is used there), and methods are executed against it.
The following resides in a satellite assembly:
namespace ScriptingSandbox
{
public interface ISandbox
{
object Invoke(string method, object[] parameters);
void Disconnect();
}
public class SandboxLoader : MarshalByRefObject, IDisposable
{
#region Properties
public bool IsDisposed { get; private set; }
public bool IsDisposing { get; private set; }
#endregion
#region Finalization/Dispose Methods
~SandboxLoader()
{
DoDispose();
}
public void Dispose()
{
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose()
{
if (IsDisposing) return;
if (IsDisposed) return;
IsDisposing = true;
Disconnect();
IsDisposed = true;
IsDisposing = false;
}
#endregion
[SecurityPermissionAttribute(SecurityAction.Demand, Flags = SecurityPermissionFlag.Infrastructure)]
public override object InitializeLifetimeService()
{
// We don't want this to ever expire.
// We will disconnect it when we're done.
return null;
}
public void Disconnect()
{
// Close all the remoting channels so that this can be garbage
// collected later and we don't leak memory.
RemotingServices.Disconnect(this);
}
public ISandbox Create(string assemblyFileName, string typeName, object[] arguments)
{
// Using CreateInstanceFromAndUnwrap and then casting to the interface so that types in the
// child AppDomain won't be loaded into the parent AppDomain.
BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.CreateInstance;
object instance = AppDomain.CurrentDomain.CreateInstanceFromAndUnwrap(assemblyFileName, typeName, true, bindingFlags, null, arguments, null, null);
ISandbox sandbox = instance as ISandbox;
return sandbox;
}
}
}
The class that is unwrapped from the child app domain is expected to implement the interface above. The SandboxLoader in the code above also runs in the child app domain, and serves the role of creating the target class. This is all tied in by the ScriptingHost class below, which runs in the parent domain in the main assembly.
namespace ScriptingDemo
{
internal class ScriptingHost : IDisposable
{
#region Declarations
private AppDomain _childAppDomain;
private string _workingDirectory;
#endregion
#region Properties
public bool IsDisposed { get; private set; }
public bool IsDisposing { get; private set; }
public string WorkingDirectory
{
get
{
if (string.IsNullOrEmpty(_workingDirectory))
{
_workingDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "bin");
}
return _workingDirectory;
}
}
#endregion
public ScriptingHost() { }
#region Finalization/Dispose Methods
~ScriptingHost()
{
DoDispose(false);
}
public void Dispose()
{
DoDispose(true);
GC.SuppressFinalize(this);
}
private void DoDispose(bool isFromDispose)
{
if (IsDisposing) return;
if (IsDisposed) return;
IsDisposing = true;
if (isFromDispose)
{
UnloadChildAppDomain();
}
IsDisposed = true;
IsDisposing = false;
}
private void UnloadChildAppDomain()
{
if (_childAppDomain == null) return;
try
{
bool isFinalizing = _childAppDomain.IsFinalizingForUnload();
if (!isFinalizing)
{
AppDomain.Unload(_childAppDomain);
}
}
catch { }
_childAppDomain = null;
}
#endregion
#region Compile
public List<string> Compile()
{
CreateDirectory(WorkingDirectory);
CreateChildAppDomain(WorkingDirectory);
CompilerParameters compilerParameters = GetCompilerParameters(WorkingDirectory);
using (VBCodeProvider codeProvider = new VBCodeProvider())
{
string sourceFile = GetSourceFilePath();
CompilerResults compilerResults = codeProvider.CompileAssemblyFromFile(compilerParameters, sourceFile);
List<string> compilerErrors = GetCompilerErrors(compilerResults);
return compilerErrors;
}
}
private string GetSourceFilePath()
{
DirectoryInfo dir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory);
// This points a test VB.net file in the solution.
string sourceFile = Path.Combine(dir.Parent.Parent.FullName, @"Classes\Scripting", "ScriptingDemo.vb");
return sourceFile;
}
private void CreateDirectory(string path)
{
if (Directory.Exists(path))
{
Directory.Delete(path, true);
}
Directory.CreateDirectory(path);
}
private void CreateChildAppDomain(string workingDirectory)
{
AppDomainSetup appDomainSetup = new AppDomainSetup()
{
ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase,
PrivateBinPath = "bin",
LoaderOptimization = LoaderOptimization.MultiDomainHost,
ApplicationTrust = AppDomain.CurrentDomain.ApplicationTrust
};
Evidence evidence = new Evidence(AppDomain.CurrentDomain.Evidence);
_childAppDomain = AppDomain.CreateDomain(Guid.NewGuid().ToString(), evidence, appDomainSetup);
_childAppDomain.InitializeLifetimeService();
}
private CompilerParameters GetCompilerParameters(string workingDirectory)
{
CompilerParameters compilerParameters = new CompilerParameters()
{
GenerateExecutable = false,
GenerateInMemory = false,
IncludeDebugInformation = true,
OutputAssembly = Path.Combine(workingDirectory, "GeneratedAssembly.dll")
};
// Add GAC/System Assemblies
compilerParameters.ReferencedAssemblies.Add("System.dll");
compilerParameters.ReferencedAssemblies.Add("System.Xml.dll");
compilerParameters.ReferencedAssemblies.Add("System.Data.dll");
compilerParameters.ReferencedAssemblies.Add("Microsoft.VisualBasic.dll");
// Add Custom Assemblies
compilerParameters.ReferencedAssemblies.Add(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ScriptingSandbox.dll"));
compilerParameters.ReferencedAssemblies.Add(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ScriptingInterfaces.dll"));
return compilerParameters;
}
private List<string> GetCompilerErrors(CompilerResults compilerResults)
{
List<string> errors = new List<string>();
if (compilerResults == null) return errors;
if (compilerResults.Errors == null) return errors;
if (compilerResults.Errors.Count == 0) return errors;
foreach (CompilerError error in compilerResults.Errors)
{
string errorText = string.Format("[{0}, {1}] :: {2}", error.Line, error.Column, error.ErrorText);
errors.Add(errorText);
}
return errors;
}
#endregion
#region Execute
public object Execute(string method, object[] parameters)
{
using (SandboxLoader sandboxLoader = CreateSandboxLoader())
{
ISandbox sandbox = CreateSandbox(sandboxLoader);
try
{
object result = sandbox.Invoke(method, parameters);
return result;
}
finally
{
if (sandbox != null)
{
sandbox.Disconnect();
sandbox = null;
}
}
}
}
private SandboxLoader CreateSandboxLoader()
{
object sandboxLoader = _childAppDomain.CreateInstanceAndUnwrap("ScriptingSandbox", "ScriptingSandbox.SandboxLoader", true, BindingFlags.CreateInstance, null, null, null, null);
return sandboxLoader as SandboxLoader;
}
private ISandbox CreateSandbox(SandboxLoader sandboxLoader)
{
string assemblyPath = Path.Combine(WorkingDirectory, "GeneratedAssembly.dll");
ISandbox sandbox = sandboxLoader.Create(assemblyPath, "ScriptingDemoSource.SandboxClass", null);
return sandbox;
}
#endregion
}
}
For reference, the ScriptingDemo.vb file that gets compiled:
Imports System
Imports System.Collections
Imports System.Collections.Generic
Imports System.Globalization
Imports Microsoft.VisualBasic
Imports System.Data
Imports System.Text
Imports System.Text.RegularExpressions
Imports System.Xml
Imports System.Net
Imports System.ComponentModel
Imports System.Reflection
Imports System.Runtime.Remoting
Imports System.Runtime.Remoting.Lifetime
Imports System.Security.Permissions
Imports ScriptingSandbox
Imports ScriptingInterfaces
Namespace ScriptingDemoSource
Public Class SandboxClass
Inherits MarshalByRefObject
Implements ISandbox
Public Sub Disconnect() Implements ISandbox.Disconnect
RemotingServices.Disconnect(Me)
End Sub
Public Function Invoke(ByVal methodName As String, methodParameters As Object()) As Object Implements ScriptingSandbox.ISandbox.Invoke
'Return Nothing
Dim type As System.Type = Me.GetType()
Dim returnValue As Object = type.InvokeMember(methodName, Reflection.BindingFlags.InvokeMethod + Reflection.BindingFlags.Default, Nothing, Me, methodParameters)
type = Nothing
Return returnValue
End Function
<SecurityPermissionAttribute(SecurityAction.Demand, Flags:=SecurityPermissionFlag.Infrastructure)> _
Public Overrides Function InitializeLifetimeService() As Object
Return Nothing
End Function
Function ExecuteWithNoParameters() As Object
Return Nothing
End Function
Function ExecuteWithSimpleParameters(a As Integer, b As Integer) As Object
Return a + b
End Function
Function ExecuteWithComplexParameters(o As ScriptingInterfaces.IMyInterface) As Object
Return o.Execute()
End Function
End Class
End Namespace
The first issue I ran into was that even after cleaning up the sandbox, memory leaked. This was resolved by keeping an instance of the sandbox around and not destroying it after executing methods from the script. This added/changed the following to the ScriptingHost class:
private ISandbox _sandbox;
private string _workingDirectory;
private void DoDispose(bool isFromDispose)
{
if (IsDisposing) return;
if (IsDisposed) return;
IsDisposing = true;
if (isFromDispose)
{
Cleanup();
}
IsDisposed = true;
IsDisposing = false;
}
private void CleanupSandboxLoader()
{
try
{
if (_sandboxLoader == null) return;
_sandboxLoader.Disconnect();
_sandboxLoader = null;
}
catch { }
}
private void CleanupSandbox()
{
try
{
if (_sandbox == null) return;
_sandbox.Disconnect();
}
catch { }
}
public void Cleanup()
{
CleanupSandbox();
CleanupSandboxLoader();
UnloadChildAppDomain();
}
public object Execute(string method, object[] parameters)
{
if (_sandboxLoader == null)
{
_sandboxLoader = CreateSandboxLoader();
}
if (_sandbox == null)
{
_sandbox = CreateSandbox(_sandboxLoader);
}
object result = _sandbox.Invoke(method, parameters);
return result;
}
This really didn't resolve the underlying issue (destroying the sandbox and loader didn't release memory as expected). As I have more control over that behavior, though, it did allow me to move on to the next issue.
The code that uses ScriptingHost looks like the following:
private void Execute()
{
try
{
List<MyClass> originals = CreateList();
for (int i = 0; i < 4000; i++)
{
List<MyClass> copies = MyClass.MembersClone(originals);
foreach (MyClass copy in copies)
{
object[] args = new object[] { copy };
try
{
object results = _scriptingHost.Execute("ExecuteWithComplexParameters", args);
}
catch (Exception ex)
{
_logManager.LogException("executing the script", ex);
}
finally
{
copy.Disconnect();
args.SetValue(null, 0);
args = null;
}
}
MyClass.ShallowCopy(copies, originals);
MyClass.Cleanup(copies);
copies = null;
}
MyClass.Cleanup(originals);
originals = null;
}
catch (Exception ex)
{
_logManager.LogException("executing the script", ex);
}
MessageBox.Show("done");
}
private List<MyClass> CreateList()
{
List<MyClass> myClasses = new List<MyClass>();
for (int i = 0; i < 300; i++)
{
MyClass myClass = new MyClass();
myClasses.Add(myClass);
}
return myClasses;
}
And the code for MyClass:
namespace ScriptingDemo
{
internal sealed class MyClass : MarshalByRefObject, IMyInterface, IDisposable
{
#region Properties
public int ID { get; set; }
public string Name { get; set; }
public bool IsDisposed { get; private set; }
public bool IsDisposing { get; private set; }
#endregion
public MyClass() { }
#region Finalization/Dispose Methods
~MyClass()
{
DoDispose();
}
public void Dispose()
{
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose()
{
if (IsDisposing) return;
if (IsDisposed) return;
IsDisposing = true;
Disconnect();
IsDisposed = true;
IsDisposing = false;
}
#endregion
[SecurityPermissionAttribute(SecurityAction.Demand, Flags = SecurityPermissionFlag.Infrastructure)]
public override object InitializeLifetimeService()
{
// We don't want this to ever expire.
// We will disconnect it when we're done.
return null;
}
public void Disconnect()
{
// Close all the remoting channels so that this can be garbage
// collected later and we don't leak memory.
RemotingServices.Disconnect(this);
}
public object Execute()
{
return "Hello, World!";
}
public MyClass MembersClone()
{
MyClass copy = new MyClass();
copy.ShallowCopy(this);
return copy;
}
public void ShallowCopy(MyClass source)
{
if (source == null) return;
ID = source.ID;
Name = source.Name;
}
#region Static Members
public static void ShallowCopy(List<MyClass> sources, List<MyClass> targets)
{
if (sources == null) return;
if (targets == null) return;
int minCount = Math.Min(sources.Count, targets.Count);
for (int i = 0; i < minCount; i++)
{
MyClass source = sources[i];
MyClass target = targets[i];
target.ShallowCopy(source);
}
}
public static List<MyClass> MembersClone(List<MyClass> originals)
{
if (originals == null) return null;
List<MyClass> copies = new List<MyClass>();
foreach (MyClass original in originals)
{
MyClass copy = original.MembersClone();
copies.Add(copy);
}
return copies;
}
public static void Disconnect(List<MyClass> myClasses)
{
if (myClasses == null) return;
myClasses.ForEach(c => c.Disconnect());
}
public static void Cleanup(List<MyClass> myClasses)
{
if (myClasses == null) return;
myClasses.ForEach(c => c.Dispose());
myClasses.Clear();
myClasses.TrimExcess();
myClasses = null;
}
#endregion
}
}
As the code stands, memory slowly leaks the more iterations run and GCHandles soar through the roof. I've played with adding a finite lease instead of setting up the leases to never expire, but that caused wild fluctuations in memory that would eventually drop but not completely and ultimately still consumed more memory overall than the current solution (by a margin of dozens of megabytes).
I fully understand that creating a large number of classes like that and dropping them shortly there after is undesirable, but it simulates a much larger system. We may or may not address that issue, but for me I would like to better understand why the memory is leaking in the current system.
EDIT:
I just wanted to note that the memory leaking doesn't appear to be managed memory. Using various profiling tools, it appears that that the managed heaps tend to stay within a pretty set range whereas the unmanaged memory is what seems to grow.
EDIT #2
Rewriting the code to keep the list of classes around rather than dumping them every iteration does seem to alleviate the issues (my assumption is that this works because we're reusing everything we've already allocated), but I'd like to keep this open if only for an academic exercise. The root issue still is unresolved.