C# / WSC (COM)互操作中的FatalExecutionEngineError

8
我即将开始一项工作迁移项目,针对使用VBScript编写的遗留系统。它有一个有趣的结构,许多组件被编写为“WSC”文件进行隔离,这实际上是一种以COM方式公开VBScript代码的方法。从“核心”到这些组件的边界接口相当紧密和广为人知,因此我希望能够处理编写新核心并重用WSC,推迟它们的重写。
通过添加对“Microsoft.VisualBasic”的引用并调用,可以加载WSC。
var component = (dynamic)Microsoft.VisualBasic.Interaction.GetObject("script:" + controlFilename, null);

"controlFilename"是完整的文件路径。GetObject返回类型为"System.__ComObject"的引用,但可以使用.net的"dynamic"类型访问属性和方法。

这似乎最初运行良好,但当一组相当特定的情况结合在一起时,我遇到了问题——我的担心是这可能会在其他情况下发生,甚至更糟的是,坏事可能经常发生,只是等待着在我最不希望的时候爆炸。

引发的异常类型为"System.ExecutionEngineException",听起来特别可怕(而且模糊)!

我已经拼凑出了我认为是最小重现案例,并希望有人能解释一下问题可能是什么。我还确定了一些可以进行的调整,似乎可以防止这种情况发生,尽管我无法解释原因。

  1. 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)

  2. Add a reference to "Microsoft.VisualBasic" to the project

  3. 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; }
                }
            }
        }
    }
    
  4. 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>
    
运行一次应该不会出现任何问题,"Log.txt"文件将被写入"bin"文件夹。然而,刷新页面通常会导致异常。
“Managed Debugging Assistant 'FatalExecutionEngineError'在'C:\ Program Files(x86)\ IIS Express \ iisexpress.exe'中检测到了一个问题。”
“附加信息:运行时遇到了致命错误。错误的地址位于0x733c3512,在线程0x1e10上。错误代码为0xc0000005。这个错误可能是CLR或用户代码的不安全或不可验证部分中的bug。这个bug的常见来源包括COM-> interop或PInvoke的用户编组错误,这可能会破坏堆栈。”
偶尔,第二个请求不会导致这个异常,但在浏览器窗口按住F5几秒钟将确保它显露出来。就我所知,异常发生在“如果IsEmpty”检查处(此重现案例的其他版本有更多的日志调用,指向该行是问题的源头)。

我尝试了各种方法来找到问题的根源,我尝试在控制台应用程序中重新创建,但问题并未发生,即使我启动了数百个线程并让它们处理上面的工作。我尝试了使用ASP.Net MVC Web应用程序,而不是使用Web表单,但仍然存在相同的问题。我尝试将公寓状态从默认的MTA更改为STA(那时我有点拼凑!),但它对行为没有任何影响。我尝试构建一个使用Microsoft的OWIN实现的Web项目,该情况下也出现了问题。

我注意到的两件有趣的事情是 - 如果“DataContainer”类没有索引属性(或者默认的方法/属性,用 [DispId(0)] 属性装饰 - 在这个示例中未说明),那么错误就不会发生。如果“logger”闭包不包含“FileInfo”引用(如果维护了字符串“logFilePath”,而不是 FileInfo 实例“logFile”),那么错误也不会发生。我想这听起来像一个方法可以避免做这些事情!但我担心可能有其他触发这种情况的方式,而我目前并不知道,试图强制执行不做这些事情的规则可能会随着代码库的增长变得很复杂,我能想象这个错误会在没有立即明显原因的情况下再次出现。
在一次运行中(通过Katana),我获得了额外的调用堆栈信息:
这个线程只有调用堆栈上的外部代码框架。外部代码框架通常来自框架代码,但也可以包括在目标进程中加载的其他优化模块。
带有外部代码的调用堆栈:
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]

最后一点说明:如果我为“DataProvider”类创建一个包装器,使用IReflect并将调用映射到基础“DataProvider”实例上,则问题会消失。但是,再次决定这是答案似乎对我来说很危险-如果我必须仔细确保传递给组件的任何引用都具有这样的包装器,那么错误可能会悄然而至,这可能很难跟踪。如果一个被IReflect实现包围的引用从一个方法或属性调用中返回了一个没有以同样方式包装的引用怎么办?我想这个包装器可以尝试做一些事情,例如确保它仅返回“安全”的引用(即那些没有索引属性或DispId = 0方法或属性)而不包装它们在进一步的IReflect包装器中...但这似乎有点hacky。

我真的不知道下一步该怎么做,请问有人有什么想法吗?


我认为您可能需要确保WSC代码一次只能被单个线程访问。您可能需要在使用该代码时使用“lock”语句。我怀疑它不是用于像ASP.NET这样的多线程环境的。 - John Saunders
我尝试在Default.aspx.cs中加入一个静态锁对象,并使用它来包装工作,但仍然没有成功。如果第一个请求完全终止并且第二个(非并发)请求进来时出现错误,那么我会感到惊讶。但这值得一试,所以谢谢! :) - Dan Roberts
1个回答

2
我的猜测是,你看到的错误是由于WSC脚本组件的本质是COM STA对象所致。它们是由底层VBScript Active Scripting Engine实现的,该引擎本身是一个STA COM对象。因此,它们需要创建和访问STA线程,并且这样的线程应该保持不变,直到任何特定的WSC对象的生命周期结束(该对象需要线程关联)。
ASP.NET线程不是STA线程。它们是ThreadPool线程,当您开始在它们上使用COM对象时,它们会隐式地成为COM MTA线程(有关STA和MTA之间的差异,请参阅INFO:OLE线程模型的描述和工作原理)。然后,COM会为您的WSC对象创建一个单独的隐式STA公寓,并从您的ASP.NET请求线程中调用该公寓。在ASP.NET环境中,整个过程可能会顺利进行,也可能不会。
理想情况下,您应该摆脱WSC脚本组件,并将其替换为.NET程序集。如果短期内无法实现这一点,我建议您运行自己明确控制的STA线程来托管WSC组件。以下内容可能有所帮助:

更新:为什么不试试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();

如果这样做可行,您可以通过使用 PageAsyncTaskasync/await 而不是阻塞的 Wait() 来在一定程度上提高 Web 应用程序的可伸缩性。

2
感谢您的回复!我曾尝试将AspCompat="true"添加到页面声明中,据我所知,这告诉Web表单在STA模式下运行线程。将“即将调用Go”记录器调用更改为logger("About to call Go - Hosting Thread has ApartmentState: " + System.Threading.Thread.CurrentThread.GetApartmentState())似乎证实了这一点,但问题仍然存在。这就是您所说的吗?重新编写旧代码以适应.NET肯定是理想的(并且是长期计划),但我认为一口气完成所有工作可能有些难以承受。 - Dan Roberts
1
这看起来非常有前途!在初步检查中,我似乎无法重现错误。我将再多玩一下,如果仍然看起来不错,就会接受你的答案。这真是个好消息,非常感谢! :) 我猜我得深入研究一下它的含义(从你链接的以前的答案开始)。非常小的一点:在你的示例代码中,“GlobalState”构造函数需要括号,因为“public”访问器被删除,然后一切都编译得很好。 - Dan Roberts
1
我已经进行了更全面的测试,并且在我尝试的各种情况下都无法破坏它 - 所以我认为这是一个成功!再次感谢您的帮助,我一定会阅读您提供的所有信息,以尝试理解解决方案。非常高兴地接受答案! - Dan Roberts
@DanDanDan,如果您在多个TaScheduler.Run调用中使用相同的COM对象,请确保保持线程亲和性。这里有一个相关的答案,说明我的意思。 - noseratio - open to work
我非常有兴趣看到你对此所做的任何事情!我正在尝试做类似的事情,我认为我已经理解了每件事情的作用和原因 - 但是在我完全掌握这个主题之前还需要一些时间! - Dan Roberts
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接