在MSTest期间,终结器中的NullReferenceException

20

我知道这是一个非常冗长的问题。我试图将问题与我的调查分开,以便更容易阅读。

我正在使用MSTest.exe运行单元测试。偶尔会出现这个测试错误:

在单个单元测试方法中:“代理进程在运行测试时停止了。”

在整个测试运行期间:

其中一个后台线程引发异常:
System.NullReferenceException:对象引用未设置为对象实例。
   在System.Runtime.InteropServices.Marshal.ReleaseComObject(Object o)
   在System.Management.Instrumentation.MetaDataInfo.Dispose()
   在System.Management.Instrumentation.MetaDataInfo.Finalize()

因此,这是我认为我需要做的事情:我需要找到导致MetaDataInfo错误的原因,但我一筹莫展。我的单元测试套件需要超过半个小时才能运行,并且错误并不总是发生,因此很难复制它。

有其他人看到过这种运行单元测试失败的情况吗?您能否将其跟踪到特定组件?

编辑:

被测试的代码是C#、C++/CLI和一小部分不受管理的C++代码的混合物。未受管理的C++仅从C++/CLI中使用,从未直接从单元测试中使用。单元测试全部是C#。

被测试的代码将在独立的Windows服务中运行,因此没有来自ASP.net或任何类似东西的复杂性。在被测试的代码中,有线程启动和停止、网络通信以及对本地硬盘的文件I/O。


我的调查:

我花了一些时间在我的Windows 7机器上挖掘多个版本的System.Management程序集,并发现在我的Windows目录中具有MetaDataInfo类的System.Management。 (位于Program Files \ Reference Assemblies下的版本要小得多,而且没有MetaDataInfo类。)

使用Reflector检查这个程序集,我发现MetaDataInfo.Dispose()中似乎存在明显的错误:

// From class System.Management.Instrumentation.MetaDataInfo:
public void Dispose()
{
    if (this.importInterface == null) // <---- Should be "!="
    {
        Marshal.ReleaseComObject(this.importInterface);
    }
    this.importInterface = null;
    GC.SuppressFinalize(this);
}
使用此“if”语句,MetaDataInfo将泄漏COM对象(如果存在),否则将抛出NullReferenceException。我已在Microsoft Connect上报告了此问题:https://connect.microsoft.com/VisualStudio/feedback/details/779328/。 使用反射器,我找到了MetaDataInfo类的所有用途。(它是一个内部类,因此仅搜索程序集应该是完整的列表。)它仅被使用了一次:
public static Guid GetMvid(Assembly assembly)
{
    using (MetaDataInfo info = new MetaDataInfo(assembly))
    {
        return info.Mvid;
    }
}

由于所有对 MetaDataInfo 的使用都已经正确地被释放了,这是正在发生的情况:

  • 如果 MetaDataInfo.importInterface 不为 null:
    • 静态方法 GetMvid 返回 MetaDataInfo.Mvid
    • using 调用 MetaDataInfo.Dispose
      • Dispose 泄漏 COM 对象
      • Dispose 将 importInterface 设置为 null
      • Dispose 调用 GC.SuppressFinalize
    • 稍后,当 GC 收集 MetaDataInfo 时,终结器被跳过。
  • .
  • 如果 MetaDataInfo.importInterface 为 null:
    • 静态方法 GetMvid 调用 MetaDataInfo.Mvid 会得到 NullReferenceException。
    • 在异常传播之前,using 调用 MetaDataInfo.Dispose
      • Dispose 调用 Marshal.ReleaseComObject
        • Marshal.ReleaseComObject 抛出 NullReferenceException。
      • 因为抛出了异常,Dispose 不会调用 GC.SuppressFinalize
    • 异常传播到 GetMvid 的调用者。
    • 稍后,当 GC 收集 MetaDataInfo 时,它运行终结器
      • Finalize 调用 Dispose
        • Dispose 调用 Marshal.ReleaseComObject
          • Marshal.ReleaseComObject 抛出 NullReferenceException,该异常一直传播到 GC,并终止应用程序。

顺便说一下,这是来自 MetaDataInfo 的其余相关代码:

public MetaDataInfo(string assemblyName)
{
    Guid riid = new Guid(((GuidAttribute) Attribute.GetCustomAttribute(typeof(IMetaDataImportInternalOnly), typeof(GuidAttribute), false)).Value);
    // The above line retrieves this Guid: "7DAC8207-D3AE-4c75-9B67-92801A497D44"
    IMetaDataDispenser o = (IMetaDataDispenser) new CorMetaDataDispenser();
    this.importInterface = (IMetaDataImportInternalOnly) o.OpenScope(assemblyName, 0, ref riid);
    Marshal.ReleaseComObject(o);
}

private void InitNameAndMvid()
{
    if (this.name == null)
    {
        uint num;
        StringBuilder szName = new StringBuilder {
            Capacity = 0
        };
        this.importInterface.GetScopeProps(szName, (uint) szName.Capacity, out num, out this.mvid);
        szName.Capacity = (int) num;
        this.importInterface.GetScopeProps(szName, (uint) szName.Capacity, out num, out this.mvid);
        this.name = szName.ToString();
    }
}

public Guid Mvid
{
    get
    {
        this.InitNameAndMvid();
        return this.mvid;
    }
}

编辑2:

我能够重现Microsoft的MetaDataInfo类中的错误。然而,我的复现与我在此处看到的问题略有不同。

  • 复现: 我尝试在一个非托管程序集上创建一个MetaDataInfo对象。这将在初始化importInterface之前从构造函数抛出异常。
  • 我在MSTest中遇到的问题:在某些托管程序集上构造了MetaDataInfo,然后某些事情发生使importInterface为null,或在初始化importInterface之前退出构造函数。
    • 我知道MetaDataInfo是在托管程序集上创建的,因为MetaDataInfo是一个内部类,唯一调用它的API是通过传递Assembly.Location的结果来进行的。

但是,在Visual Studio中重新创建该问题意味着它为我下载了MetaDataInfo源代码。这是实际的代码,带有原始开发人员的注释。

public void Dispose()
{ 
    // We implement IDisposable on this class because the IMetaDataImport
    // can be an expensive object to keep in memory. 
    if(importInterface == null) 
        Marshal.ReleaseComObject(importInterface);
    importInterface = null; 
    GC.SuppressFinalize(this);
}

~MetaDataInfo() 
{
    Dispose(); 
} 
原始代码确认反射器中所看到的问题:if语句是反向的,他们不应该从Finalizer访问托管对象。 之前我说过,因为它从未调用ReleaseComObject,所以它泄漏了COM对象。我在.Net中更多地阅读了有关使用COM对象的信息,如果我理解正确,那是不正确的: COM对象在调用Dispose()时不被释放,但当垃圾收集器开始收集运行时可调用包装器(RCW)时,它会被释放,这是托管对象。即使它是非托管COM对象的包装器,RCW仍然是托管对象,"不要从终结器访问托管对象"的规则仍然适用。

1
如果您的分析是正确的,应该在“连接”上报告。 - Marc Gravell
了解您的测试正在做什么以及被测试代码正在做什么将会很有趣。 - John Saunders
是的,那是一个 bug。在终结器中调用 ReleaseComObject() 也是一个 bug。这只是以前从未被诊断出来,它只会在非常特殊的情况下崩溃,importInterface 通常不为 null。最好避免那种情况 ;) - Hans Passant
4
已完成:https://connect.microsoft.com/VisualStudio/feedback/details/779328/ - David Yaw
代码是C++,C++/CLI和C#编写的。单元测试是用C#编写的。没有使用ASP.Net或其他类似的技术。该代码启动和停止线程,进行网络通信,并对本地磁盘进行磁盘I/O操作。 - David Yaw
我能够重现即时错误,正如微软在Connect上所要求的那样,尽管复现和原始错误有不同的根本原因。 - David Yaw
3个回答

1
尝试将以下代码添加到您的类定义中:
bool _disposing = false  // class property

public void Dispose()
{
    if( !disposing ) 
        Marshal.ReleaseComObject(importInterface);
    importInterface = null; 
    GC.SuppressFinalize(this);

    disposing = true;
}

这不是我的类定义。无论如何,如果构造函数运行后importInterface为空,都会抛出异常。将if语句从“== null”切换到“!= null”,并且不从Finalizer调用它是正确的修复方法。 - David Yaw

0
如果MetaDataInfo使用IDisposable模式,则还应该有一个finalizer(在C#中为〜MetaDataInfo())。使用语句将确保调用Dispose(),它将importInterface设置为null。然后,当GC准备好进行最终处理时,将调用〜MetaDataInfo(),这通常会调用Dispose(或者是重载采用bool disposing:Dispose(false))。 我认为这个错误应该经常出现。

大多数情况下,importinterface 不为 null,因此异常永远不会发生,唯一的后果是当垃圾回收器到达时清理 COM 对象,而不是运行 Dispose 时清理。 - David Yaw
有点奇怪 :-) 不管怎样,我想指出的是,终结器调用Dispose,而不是相反。 - AroglDarthu

0

你是在尝试为你的测试修复这个问题吗?如果是这样,请重写你的代码。不要自己处理它,而是编写一些代码使用反射来访问私有字段并正确地处理它们,然后调用GC.SuppressFinalize以防止终结器运行。

顺便说一下(我喜欢你的调查),你说Dispose会调用Finalize。实际上是相反的,当GC调用Finalize时才会调用Dispose。


我不直接使用这个类(它是System.Management内部的一个类,只能通过反射来访问),我的代码也没有调用会调用这个类的任何方法。我怀疑它是从MSTest.exe中被调用的,可能是为了报告所使用的程序集等信息。我想我应该附加一个调试器到MSTest.exe上,并观察是否实例化了MetaDataInfo。 - David Yaw
你对哪个方法调用了哪个方法的抓取很好。已修复。最终结果是相同的错误和崩溃。 - David Yaw
有没有可能你暂时无法将这个测试移植到 NUnit 中?据我所记,你只需要更改属性的名称以使其兼容,并链接到不同的程序集即可。 - Quibblesome
这种情况并不是每次测试都会发生,大概只有10%的测试会出现,所以我可以忍受。我的公司已经设置了构建服务器来运行MSTest,而不是NUnit,所以如果它开始更频繁地失败,切换将会很麻烦,但我会咬紧牙关去做。 - David Yaw

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