防止从BeginInvoke抛出外部异常被丢弃

13

我有一个针对 Application.ThreadException 的处理程序,但我发现异常并不总是被正确地传递给它。具体来说,如果我从 BeginInvoke 回调中抛出具有内部异常的异常,则我的 ThreadException 处理程序无法获得外部异常--它只能获取内部异常。

示例代码:

public Form1()
{
    InitializeComponent();
    Application.ThreadException += (sender, e) =>
        MessageBox.Show(e.Exception.ToString());
}
private void button1_Click(object sender, EventArgs e)
{
    var inner = new Exception("Inner");
    var outer = new Exception("Outer", inner);
    //throw outer;
    BeginInvoke(new Action(() => { throw outer; }));
}
如果我取消注释 throw outer; 这一行并点击按钮,那么消息框会显示外部异常(以及它的内部异常):

System.Exception: Outer ---> System.Exception: Inner
--- 内部异常堆栈跟踪的结尾 ---
在 WindowsFormsApplication1.Form1.button1_Click(Object sender, EventArgs e) 位置 C:\svn\trunk\Code Base\Source.NET\WindowsFormsApplication1\Form1.cs 中: 行 55
在 System.Windows.Forms.Control.OnClick(EventArgs e)
在 System.Windows.Forms.Button.OnClick(EventArgs e)
在 System.Windows.Forms.Button.OnMouseUp(MouseEventArgs mevent)
在 System.Windows.Forms.Control.WmMouseUp(Message& m, MouseButtons button, Int32 clicks)
在 System.Windows.Forms.Control.WndProc(Message& m)
在 System.Windows.Forms.ButtonBase.WndProc(Message& m)
在 System.Windows.Forms.Button.WndProc(Message& m)
在 System.Windows.Forms.Control.ControlNativeWindow.OnMessage(Message& m)
在 System.Windows.Forms.Control.ControlNativeWindow.WndProc(Message& m)
在 System.Windows.Forms.NativeWindow.Callback(IntPtr hWnd, Int32 msg, IntPtr wparam, IntPtr lparam)

但如果 throw outer; 在一个 BeginInvoke 调用内部,就像上面的代码一样,那么 ThreadException 处理程序仅会得到内部异常。在调用 ThreadException 之前,外部异常被剥离,我只能得到以下信息:

System.Exception: Inner

(这里没有调用堆栈,因为 inner 从未被抛出。在一个更现实的示例中,当我捕获一个异常并包装它以重新抛出时,就会有一个调用堆栈。)
如果我使用 SynchronizationContext.Current.Post 代替 BeginInvoke,那么外部异常将被剥离,并且 ThreadException 处理程序仅会得到内部异常。
我尝试在外面包装更多层异常,以防止它仅仅剥离最外层的异常,但是没有任何帮助:显然某个地方有一个循环执行类似 while (e.InnerException != null) e = e.InnerException; 的操作。
我使用 BeginInvoke 是因为我的代码需要抛出一个未处理的异常,以便立即由 ThreadException 处理,但是此代码位于调用堆栈的较高位置的 catch 块内(具体来说,它位于 Task 的操作内部,而 Task 将捕获异常并阻止其传播)。我正在尝试使用 BeginInvokethrow 延迟到下一次消息在消息循环中处理时,此时我不再在那个 catch 中。我并没有固定使用 BeginInvoke 的解决方案;我只想抛出未处理的异常。
如何使一个异常(包括它的内部异常)在我处于别人的 catch-all 内部时达到 ThreadException?(由于程序集依赖项,我无法直接调用我的 ThreadException 处理程序方法,因为处理程序是被 EXE 的启动代码钩住的,而我的当前问题在较低层的 DLL 中。)

3
是的,这是有意为之的。Control.InvokeMarshaledCallbacks()捕获任何异常,以便可以将其编组回调用者。在使用Invoke()或EndInvoke()时相关。它使用Exception.GetBaseException()。您不能修复它。但是,在这种特殊情况下,您可以自己捕获异常并直接调用事件处理程序。 - Hans Passant
2
@HansPassant,我看到在InvokeMarshaledCallbacks中调用GetBaseException的代码。由于GetBaseException是虚拟的,这表明我可以重写它。不幸的是,我的重写GetBaseException方法从未被调用——所以我不确定这是否是导致问题的特定代码。 - Joe White
@Joe,对我来说使用GetBaseException确实有效。 - Oliver Bock
3
@HansPassant,你知道为什么Control.InvokeMarshaledCallbacks()会吞掉外部异常吗?请帮我翻译。 - Oliver Bock
1
@HansPassant 在这里调用 Exception.GetBaseException() 的意义是什么?它并没有提供任何好处,只会减少在显示的未处理异常对话框中看到的上下文/堆栈数量,并防止任何 ThreadException 处理程序看到实际抛出的(外部)异常。既然这是有意为之的,那么人们试图通过这样做解决什么问题呢?难道不能修复吗? - binki
4个回答

1

一种方法是将内部异常引用放在自定义属性或Data字典中,即将InnerException属性保留为空,并以其他方式传递引用。

当然,这需要建立某种约定,可以在抛出代码和处理代码之间共享。最好的方法可能是在被两个代码片段引用的项目中定义一个自定义异常类和自定义属性。

示例代码(虽然它需要更多的注释来解释它为什么要做疯狂的事情):

public class ExceptionDecorator : Exception {
    public ExceptionDecorator(Exception exception) : base(exception.Message) {
        Exception = exception;
    }
    public Exception Exception { get; private set; }
}

// To throw an unhandled exception without losing its InnerException:
BeginInvoke(new Action(() => { throw new ExceptionDecorator(outer); }));

// In the ThreadException handler:
private void OnUnhandledException(object sender, ThreadExceptionEventArgs e) {
    var exception = e.Exception;
    if (exception is ExceptionDecorator)
        exception = ((ExceptionDecorator) exception).Exception;
    // ...
}

我正在尝试找出在这里最好的做法:https://stackoverflow.com/q/59146505/7453 - Patrick Szalapski

0

我假设您在x64 Windows系统上看到了这种行为,这是x64 Windows的一个相当不为人知的实现细节。在此处阅读相关信息。

该文章详细介绍了如何通过应用某些热修复来解决此问题,据说这个修复程序已经随Win7 SP1一起发布,但我几周前在Win7 SP1上遇到了这个问题。

此外,您还可以附加到AppDomain.FirstChanceException事件,该事件使您可以访问传递给CLR进行处理之前的每个异常。


这台机器确实运行着 Windows 7 x64。该应用程序是32位进程,但快速浏览文章表明这可能并不重要。现在我会更深入地阅读它... - Joe White
1
我认为这不是问题所在。异常确实已经通过了 - 它并没有像文章中所说的那样被完全阻止。只是 .NET 内部的某些东西只传递给我最内层的异常,而不是实际的异常。 - Joe White
@JoeWhite 好的。反正那只是一个猜测。最后一段话有帮助吗?如果没有,我就删掉我的回答。 - yas4891
1
我之前不知道有FirstChanceException事件,所以你至少教会了我一些新东西。但由于它在每次异常被抛出时都会触发,无论是否已处理,我真的看不出在这里如何好好利用它。 - Joe White

0
推荐的将异常传递到更高层的方式(除了通过等待任务隐式重新抛出)是在任务主体中删除 catch-all,并使用 Task.ContinueWith 注册一个故障继续,指定 TaskContinuationOptions.OnlyOnFaulted。如果您正在通过中间层进行工作并且无法访问任务,则可以进一步包装此操作以通过自己的 UnhandledException 事件向上传递异常对象。

1
当然可以。但我希望异常能够在我的ThreadException处理程序中结束,这意味着UI线程需要调用Wait来等待我的任务(或以其他方式在UI线程上重新抛出异常)。我的实际代码在BeginInvoke内部执行了WaitAll(我的示例代码只是为了简单起见而显示了一个throw)。WaitAll会抛出一个包含多个InnerExceptions的AggregateException,但由于钻入InnerException的行为,我只看到其中一个内部异常,而不是完整的AggregateException。 - Joe White

0

这虽然是一种hack方法,但它是我能想到的最好的解决方案,支持WinForms中的全局异常处理和所有异常,即使有内部异常也可以。

特别感谢yas4891提供的答案,启发了这个解决方案。

Program.cs文件中:

internal static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();

        AppDomain.CurrentDomain.FirstChanceException += CurrentDomain_FirstChanceException;
        Application.ThreadException += Application_ThreadException;
        Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException, true);

        Application.Run(new MyMainForm());
    }


    private static void CurrentDomain_FirstChanceException(object sender, FirstChanceExceptionEventArgs e)
    {
        _outermostExceptionCache.AddException(e.Exception);
    }


    private static void Application_ThreadException(object sender, ThreadExceptionEventArgs e)
    {
        Exception exception = null;
        if (e?.Exception != null)
            exception = _outermostExceptionCache.GetOutermostException(e.Exception);
        // Handle exception
    }


    private static OutermostExceptionCache _outermostExceptionCache = new();
}

你需要使用OutermostExceptionCache类:

public class OutermostExceptionCache
{
    public void AddException(Exception ex)
    {
        if ((ex != null) && (ex is not TargetInvocationException))
        {
            Exception innermostException = GetInnermostException(ex);
            lock (_syncRoot)
            {
                RemoveOldEntries();
                _cache[innermostException] = new CacheEntry(ex);
            }
        }
    }


    public Exception GetOutermostException(Exception ex)
    {
        Exception innermostException = GetInnermostException(ex);
        Exception outermostException = null;
        lock (_syncRoot)
        {
            if (_cache.TryGetValue(innermostException, out CacheEntry entry))
            {
                outermostException = entry.Exception;
                _cache.Remove(innermostException);
            }
            else
            {
                outermostException = ex;
            }
        }
        return outermostException;
    }


    private void RemoveOldEntries()
    {
        DateTime now = DateTime.Now;
        foreach (KeyValuePair<Exception, CacheEntry> pair in _cache)
        {
            TimeSpan timeSinceAdded = now - pair.Value.AddedTime;
            if (timeSinceAdded.TotalMinutes > 3)
                _cache.Remove(pair.Key);
        }
    }


    private Exception GetInnermostException(Exception ex)
    {
        return ex.GetBaseException() ?? ex;
    }


    private readonly object _syncRoot = new();
    private readonly Dictionary<Exception, CacheEntry> _cache = new();


    private class CacheEntry
    {
        public CacheEntry(Exception ex)
        {
            Exception = ex;
            AddedTime = DateTime.Now;
        }


        public Exception Exception { get; }
        public DateTime AddedTime { get; }
    }
}

这个工作的方式是通过观察每一个异常,当它被抛出时,在运行时甚至将异常冒泡到最近的catch块之前。每次抛出异常时,它都会被添加到一个缓存中,由最内部(即基本)异常进行索引。因此,当捕获异常并抛出新异常时,原始异常作为其内部异常,缓存就会更新为该外部异常。然后,当Application.ThreadException事件处理程序提供了未包装的、最内部的异常时,处理程序可以从缓存中查找最外层的异常。
注意:由于即使是本地捕获的异常也会被添加到缓存中(因此永远不会通过调用GetOutermostException来删除),因此,它会对每个异常进行时间戳,并自动丢弃任何早于3分钟的异常。这是一个任意的超时时间,可以根据需要进行调整。如果超时时间太短,可能会在调试过程中出现问题,因为如果您在调试器中暂停进程时间太长(在异常被抛出但在处理之前),它会导致异常处理只处理最内部的异常。

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