I'm about to start a migration project at work for a legacy system written in VBScript. It has an interesting structure in that much of it was segregated by writing various components as "WSC" files, which are effectively a way of exposing VBScript code in a COM-like manner. The boundary interface from the "core" to these components is fairly tight and well known so I was hoping that I would be able to tackle writing a new core and reuse the WSCs, postponing their rewriting.
It's possible to load a WSC by adding a reference to "Microsoft.VisualBasic" and calling
var component = (dynamic)Microsoft.VisualBasic.Interaction.GetObject("script:" + controlFilename, null);
where "controlFilename" is the full file path. GetObject returns a reference of type "System.__ComObject" but the properties and methods can be accessed using .net's "dynamic" type.
This appeared to work fine initially, but I've encountered problems when quite a specific set of circumstances are combined - my worry is that this may happen in other cases or, even worse, that bad things are happening much of the time and being masked, just waiting to blow up when I least expect it.
The raised exception is of type "System.ExecutionEngineException", which sounds particularly scary (and vague)!
I've cobbled together what I believe to be the minimum reproduce case and was hoping that someone could cast a little light on what the problem could be. I've also identified some tweaks that can be made that seem to prevent it, though I can't explain why.
Create a new empty "ASP.NET Web Application" called "WSCErrorExample" (I've done this in VS 2013 / .net 4.5 and VS 2010 / .net 4.0, it makes no difference)
Add a reference to "Microsoft.VisualBasic" to the project
Add a new "Web Form" called "Default.aspx" and paste the following over the top of "Default.aspx.cs"
using System;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using Microsoft.VisualBasic;
namespace WSCErrorExample
{
public partial class Default : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
var currentFolder = GetCurrentDirectory();
var logFile = new FileInfo(Path.Combine(currentFolder, "Log.txt"));
Action<string> logger = message =>
{
// The try..catch is to avoid IO exceptions when reproducing by requesting the page many times
try { File.AppendAllText(logFile.FullName, message + Environment.NewLine); }
catch { }
};
var controlFilename = Path.Combine(currentFolder, "TestComponent.wsc");
var control = (dynamic)Interaction.GetObject("script:" + controlFilename, null);
logger("About to call Go");
control.Go(new DataProvider(logger));
logger("Completed");
}
private static string GetCurrentDirectory()
{
// This is a way to get the working path that works within ASP.Net web projects as well as Console apps
var path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().GetName().CodeBase);
if (path.StartsWith(@"file:\", StringComparison.InvariantCultureIgnoreCase))
path = path.Substring(6);
return path;
}
[ComVisible(true)]
public class DataProvider
{
private readonly Action<string> _logger;
public DataProvider(Action<string> logger)
{
_logger = logger;
}
public DataContainer GetDataContainer()
{
return new DataContainer();
}
public void Log(string content)
{
_logger(content);
}
}
[ComVisible(true)]
public class DataContainer
{
public object this[string fieldName]
{
get { return "Item:" + fieldName; }
}
}
}
}
Add a new "Text File" called "TestComponent.wsc", open its properties window and change "Copy to Output Directory" to "Copy if newer" then paste the following in as its content
<?xml version="1.0" ?>
<?component error="false" debug="false" ?>
<package>
<component id="TestComponent">
<registration progid="TestComponent" description="TestComponent" version="1" />
<public>
<method name="Go" />
</public>
<script language="VBScript">
<![CDATA[
Function Go(objDataProvider)
Dim objDataContainer: Set objDataContainer = objDataProvider.GetDataContainer()
If IsEmpty(objDataContainer) Then
mDataProvider.Log "No data provided"
End If
End Function
]]>
</script>
</component>
</package>
Running this once should cause no apparent issue, the "Log.txt" file will be written to the "bin" folder. Refreshing the page, however, normally results in an exception
Managed Debugging Assistant 'FatalExecutionEngineError' has detected a problem in 'C:\Program Files (x86)\IIS Express\iisexpress.exe'.
Additional information: The runtime has encountered a fatal error. The address of the error was at 0x733c3512, on thread 0x1e10. The error code is 0xc0000005. This error may be a bug in the CLR or in the unsafe or non-verifiable portions of user code. Common sources of this bug include user marshaling errors for COM-> interop or PInvoke, which may corrupt the stack.
Occasionally, the second request does not result in this exception, but holding down F5 in the browser window for a couple of seconds will ensure that it rears its ugly head. The exception, so far as I can tell, happens at the "If IsEmpty" check (other versions of this reproduce case had more logging calls, which pointed to that line being the source of the problem).
I've tried various things to try to get to the bottom of this, I've tried to recreate in a console app and the problem does not occur, even if I spin up hundreds of threads and get them to process the work above. I've tried an ASP.Net MVC web application, rather than using a Web Form and the same issue DOES occur. I've tried changing the apartment state from the default MTA to STA (I was clutching at straws a bit at that point!) and it made no change to the behavour. I've tried building a web project that uses Microsoft's OWIN implementation and the issue occurs in that scenario as well.
Two interesting things that I have noticed - if the "DataContainer" class does not have an indexed property (or a default method / property, decorated with a [DispId(0)] attribute - not illustrated in this example) then the error does not occur. If the "logger" closure does not contain a "FileInfo" reference (if a string "logFilePath" was maintained, rather than the FileInfo instance "logFile") then the error does not occur. I suppose it sounds like one approach would be to avoid doing these things! But I would be concerned that there could be other ways to trigger this scenario that I don't currently know about and trying to enforce the rule of not-doing-these-things could get complicated as the code base grows, I can imagine this error creeping back in without it being immediately obvious why.
On one run (through Katana), I got additional call stack information:
This thread is stopped with only external code frames on the call stack. External code frames are typically from framework code but can also include other optimized modules which are loaded in the target process.
Call stack with external code
mscorlib.dll!System.Variant.Variant(object obj) mscorlib.dll!System.OleAutBinder.ChangeType(object value, System.Type type, System.Globalization.CultureInfo cultureInfo) mscorlib.dll!System.RuntimeType.TryChangeType(object value, System.Reflection.Binder binder, System.Globalization.CultureInfo culture, bool needsSpecialCast) mscorlib.dll!System.RuntimeType.CheckValue(object value, System.Reflection.Binder binder, System.Globalization.CultureInfo culture, System.Reflection.BindingFlags invokeAttr) mscorlib.dll!System.Reflection.MethodBase.CheckArguments(object[] parameters, System.Reflection.Binder binder, System.Reflection.BindingFlags invokeAttr, System.Globalization.CultureInfo culture, System.Signature sig) [Native to Managed Transition]
One final note: if I create a wrapper for the "DataProvider" class, using IReflect and map the calls over IDispatch on to calls to the underlying "DataProvider" instance then the issue goes away. But again, deciding that this is somehow the answer seems dangerous to me - if I have to be meticulous about ensuring that any reference passed to the components has such a wrapper then errors could creep in that could be difficult to track down. What if a reference that IS encased in an IReflect-implementing wrapper returns a reference from a method or property call that isn't wrapped up in the same way? I suppose the wrapper could try to do something like ensuring it only returns "safe" reference (ie. those without indexed properties or DispId=0 methods or properties) without wrapping them in a further IReflect wrapper.. but it all seems a bit hacky.
I really have no clue where next to go with this problem, does anyone have any idea?
My guess is, the error you're seeing is caused by the fact that WSC script components are COM STA objects by their nature. They're implemented by the underlying VBScript Active Scripting Engine, which itself is an STA COM object. As such, they require an STA thread to be created and accessed on, and such thread should remain the same for the lifetime of any particular WSC object (the object requires thread affinity).
ASP.NET threads are not STA. They're ThreadPool
threads, and they implicitly become COM MTA threads when you start using COM objects on them (for differences between STA and MTA, refer to INFO: Descriptions and Workings of OLE Threading Models). COM then creates a separate implicit STA apartment for your WSC objects and marshal calls there from your ASP.NET request thread. The whole thing may or may not go well in the ASP.NET environment.
Ideally, you should get rid of WSC script components and replace them with .NET assemblies. If that's not feasible short-term, I'd recommend that you run your own explicitly controlled STA thread(s) to host the WSC components. The following may help:
How to use non-thread-safe async/await APIs and patterns with ASP.NET Web API?
StaTaskScheduler and STA thread message pumping
Updated, why not give this a try? Your code would look like this:
// create a global instance of ThreadAffinityTaskScheduler - per web app
public static class GlobalState
{
public static ThreadAffinityTaskScheduler TaScheduler { get; private set; }
public static GlobalState()
{
GlobalState.TaScheduler = new ThreadAffinityTaskScheduler(
numberOfThreads: 10,
staThreads: true,
waitHelper: WaitHelpers.WaitWithMessageLoop);
}
}
// ... inside Page_Load
GlobalState.TaScheduler.Run(() =>
{
var control = (dynamic)Interaction.GetObject("script:" + controlFilename, null);
logger("About to call Go");
control.Go(new DataProvider(logger));
logger("Completed");
}, CancellationToken.None).Wait();
If that works, you can somewhat improve the web app's scalabilty by using PageAsyncTask
and async/await
instead of the blocking Wait()
.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With