.NET Frameworkの集約例外ハンドラ

# コードリポジトリ


https://github.com/pea-sys/DotNetFramework-UnhandledException

# 準備
Windowsフォームプロジェクトを作成します。
今更感はありますが、.NetFrameworkを指定します。
※大部分は.Net Coreでも同様の話になります。
![0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/19f9252f-8fd1-172e-4932-d39ac3dfaee6.png)

例外を発生させる関数を用意
今回は補足されない例外を作りたいので、敢えて`try ~ catch`はしません。
```Form.cs
private int ThrowOutOfRangeExceptionMethod()
{
    int[] myArray = new int[0];
    return myArray[myArray.Length];
}
```
もちろん、例外を事前に補足し、個別にエラーハンドリング出来るのが理想ですが、エラーハンドリングに不具合があるケースにも備えるに越したことはありません。

フォームにボタンを置く
![UI.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/c4067941-1084-4aae-fdfe-3a58c1b02a7a.png)

WindowsFormでボタンを押した際に、関数を呼ぶ
```Form.cs
private void uiThreadExceptionButton_Click(object sender, EventArgs e)
{
    ThrowOutOfRangeExceptionMethod();
}
```
ボタンを押すと、Runtimeで例外が補足されます
![例外.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/23a7c116-6020-9f36-e6a7-7ccf1593c8da.png)

# UIスレッドの補足されない例外処理を補足
Windowsフォームアプリケーションは、捕捉されなかった例外がスローされるとApplication.ThreadExceptionイベントが発生します。

補足されなかった例外が発生する場合、大概アプリケーションは不慮の状況に陥っているため、アプリを再起動するように変更します。
もちろん、起動処理でも補足されない例外が発生する場合はあるので、必ずしもアプリ再起動が良いとは限りません。OS再起動の方が適している場合も多いでしょう。

エラー回数を永続化しておき、リトライ回数を決めても良いでしょう。

Application.ThreadExceptionイベントハンドラを追加します。
```Program.cs
[STAThread]
static void Main()
{
    Application.ThreadException += new System.Threading.ThreadExceptionEventHandler(Application_ThreadException);
    Application.Run(new Form());
}
        
private static void Application_ThreadException(object sender, System.Threading.ThreadExceptionEventArgs e)
{
    try
    {
        MessageBox.Show(e.Exception.Message, string.Format("エラースレッド番号:{0}", Thread.CurrentThread.ManagedThreadId.ToString()));
    }
    finally
    {
        Application.Restart();
    }
}
```
ボタンを押すと次のメッセージが表示されます。
また、OKボタンを押すと、アプリケーションが再起動するようになりました。
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/9f999ece-12df-f412-fa96-184a23033a35.png)



# 別スレッドの補足されない例外処理
状況によっては、これだけでは全ての例外は補足できません。
`UnhandledExceptionMode`が`ThrowException'になっている場合、Application_ThreadExceptionは別スレッドの未処理例外を補足できません。

実験してみます。
```Program.cs
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException);
// 省略
Application.Run(new Form());
```
Formにボタンを追加し、ボタンクリックで非同期処理で例外を発生させます。
```Form.cs
private void threadExceptionButton_Click(object sender, EventArgs e)
{
    Task.Run(() => ThrowOutOfRangeExceptionMethod()).Wait();
}
```
ボタンを押すとアプリケーションが何も通知せずにクラッシュして死にます。
![UI2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/c4135784-7823-6883-26dd-e50df1144329.png)



コマンドプロンプトを立ち上げ、イベントログを確認すると例外が記録されています。  
```
C:\Users\masami>wevtutil qe Application /rd:true /f:text /c:1 /q:"*[System[(Level=1 or Level=2)]]"
Event[0]
  Log Name: Application
  Source: Application Error
  Date: 2024-01-20T14:50:18.3500000Z
  Event ID: 1000
  Task: アプリケーション クラッシュ イベント
  Level: エラーー
  Opcode: 情報ー
  Keyword: N/A
  User: S-1-5-21-2720931630-4156238581-2832548830-1005
  User Name: HOGEHOGE
  Computer: FUGAFUGA
  Description:
障害が発生しているアプリケーション名: ExceptionExWindowsForms.exe、バージョン: 1.0.0.0、タイム スタンプ: 0x8d1e7dd0
障害が発生しているモジュール名: KERNELBASE.dll、バージョン: 10.0.22621.2861、タイム スタンプ: 0x9e57f18c
例外コード: 0xe0434352
障害オフセット: 0x00149392
障害が発生しているプロセス ID: 0x0x5870
障害が発生しているアプリケーションの開始時刻: 0x0x1DA4B648AE26D29
障害が発生しているアプリケーション パス: C:\Users\masami\source\repos\ExceptionExWindowsForms\ExceptionExWindowsForms\bin\Release\ExceptionExWindowsForms.exe
障害が発生しているモジュール パス: C:\Windows\System32\KERNELBASE.dll
レポート ID: 59d5093d-e4ea-4f68-bc88-68d2dc65a7df
障害が発生しているパッケージの完全な名前:
障害が発生しているパッケージに関連するアプリケーション ID:
```
アプリケーションで補足されていない例外なので原因調査の難度が上がります。

アプリケーションで別スレッドの例外を補足したい場合、`UnhandledExceptionMode.CatchException`を設定します
```Program.cs
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
// 省略
Application.Run(new Form());
```
これでボタンを押してみましょう。
![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/215a0292-bc5a-0882-b735-9bf528f58847.png)
無事にApplication.ThreadExceptionで補足されるようになりました。


ですが、よく見ると例外メッセージがUIスレッドのものと変わっており、
メッセージ内容が不明瞭です。

:::note info
NET Coreだとメッセージ内容が改良されて中のメッセージが表示されるようになっています。

![core.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/1b0a936d-4a38-2f9b-8169-5e4d5e0652cd.png)

ただし、エラーが複数集約されている場合の見え方については未確認です。
:::

# 非同期処理・並列処理の例外
非同期処理、並列処理を行っている場合、例外が同時に発生する可能性があるため、タスク内で発生した例外は AggregateException に集約され、その中の InnerException に実際に発生した例外が格納されます。

そのため、AggregateExceptionの中から例外情報を取り出す必要があります。

AggregateExceptionの中から例外情報を取り出す関数を用意します
```Program.cs
private static string PickErrorMessageFromAggregateException(AggregateException ae, int depth = 0)
{
    StringBuilder sb = new StringBuilder();
    foreach (var ie in ae.InnerExceptions)
    {
        if (ie is AggregateException)
        {
            sb.Append(PickErrorMessageFromAggregateException((AggregateException)ie, depth + 1));
        }
        else
        {
            sb.Append(string.Format("集約エラー階層{0}",depth) +Environment.NewLine + ie.Message);
        }
    }
    return sb.ToString();
}
```
Application_ThreadExceptionから上記関数を呼び出すようにします。
```Program.cs
private static void Application_ThreadException(object sender,ThreadExceptionEventArgs e)
{
    try
    {
        StringBuilder errorMessageSb = new StringBuilder();
        AggregateException ae = e.Exception as AggregateException;
        if (ae is AggregateException)
        {
            errorMessageSb.Append(PickErrorMessageFromAggregateException(ae).ToString());
        }
        else
        {
            errorMessageSb.Append(e.Exception.Message);
        }
        MessageBox.Show(errorMessageSb.ToString(), string.Format("エラースレッド番号:{0}", Thread.CurrentThread.ManagedThreadId.ToString()));
    }
    finally
    {
        Application.Restart();
    }
}
```
それでは、別スレッドで例外を起こしてみましょう。
![5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/7f3b14f0-ad03-3f5d-8652-ccf350b6c684.png)

無事に集約エラーのメッセージが表示されました。

# AccessViolation


.NetFramework 4 以降では、まだ補足されない例外があります。
私も何度もお世話になったことがあるAccessViolationです。
通常、メモリ破壊が起きているため、そのままプログラムクラッシュに流すのがデフォルトの動作です。

:::note warn
NET CoreだとAccess Violationをフレームワークで補足する手段はないようです。

unsafeコードを扱うコードを別プロセスにして、プロセスの生死監視をする等の対策をとる必要があります。
:::

試してみます。

まずは、コンパイルオプションのunsafeオプションを有効にします。
![unsafe.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/96fc5bf1-e115-7007-9b48-84d43f3010f3.png)

AccessViolationを引き起こす関数を定義します。
※StackOverFlowから適当に拾ってきました。
```Program.cs
// ref:https://stackoverflow.com/questions/41031308/system-accessviolationexception-for-unsafe-code
[DllImport("user32")]
private static extern int CallWindowProc(int lpPrevWndFunc, int hWnd, int Msg, int wParam, int lParam);
private unsafe void ThrowAccessViolationException()
{
    byte[] b = { 0x8B };
    fixed (byte* bb = &b[0])
    {
        int bi = (int)bb;
        CallWindowProc(bi,0, 0, 0, 0);
    }
}
```
Formにボタンを追加し、ボタンクリックで例外を発生させます。
```Form.cs
private void accessViolationExceptionButton_Click(object sender, EventArgs e)
{
    ThrowAccessViolationException();
}
```

ボタンを押すとアプリケーションが何も通知せずにクラッシュして死にます。

![AV.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/6f091ad0-1f7d-26d2-7af0-cc15b949927f.png)

コマンドプロンプトを立ち上げ、イベントログを確認すると例外が記録されています。  
```
C:\Users\masami\source\repos>wevtutil qe Application /rd:true /f:text /c:1 /q:"*[System[(Level=1 or Level=2)]]"
Event[0]
  Log Name: Application
  Source: Application Error
  Date: 2024-01-20T21:32:08.7990000Z
  Event ID: 1000
  Task: アプリケーション クラッシュ イベント
  Level: エラーー
  Opcode: 情報ー
  Keyword: N/A
  User: S-1-5-21-2720931630-4156238581-2832548830-1005
  User Name: HOGEHOGE
  Computer: FUGAFUGA
  Description:
障害が発生しているアプリケーション名: ExceptionExWindowsForms.exe、バージョン: 1.0.0.0、タイム スタンプ: 0x97d6ba1b
障害が発生しているモジュール名: unknown、バージョン: 0.0.0.0、タイム スタンプ: 0x00000000
例外コード: 0xc0000005
障害オフセット: 0x026e6708
障害が発生しているプロセス ID: 0x0x3578
障害が発生しているアプリケーションの開始時刻: 0x0x1DA4B9CA1F326B4
障害が発生しているアプリケーション パス: C:\Users\masami\source\repos\DotNetFramework-UnhandledException\ExceptionExWindowsForms\bin\Debug\ExceptionExWindowsForms.exe
障害が発生しているモジュール パス: unknown
レポート ID: 174e9d16-797a-4ae3-a994-aac2cfafdb43
障害が発生しているパッケージの完全な名前:
障害が発生しているパッケージに関連するアプリケーション ID:
```
設定ファイルに手を入れれば、この例外もUnhandledExceptionで補足可能になります。

```App.config
<configuration>
    <!--追加ブロック-->
	<runtime>
		<legacyCorruptedStateExceptionsPolicy enabled="true" />
	</runtime>
     <!--追加ブロック-->
</configuration>
```

ボタンを押すと、無事補足されるようになりました。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383255/e223d2ff-fdc0-7b0a-876b-6d3d382d95ca.png)

ただし、この例外を補足した場合は、アプリケーションだけではなくOSも再起動したほうが良いような気もします。
また、通常の例外処理で握りつぶされるリスクもあるため、個々のメソッドに対し下記属性をつけて対処したほうが、構成ファイルをいじるよりは安全と言えそうです。
```cs
[HandleProcessCorruptedStateExceptions]
```

ちなみに集約例外ハンドラにより表示するメッセージは開発環境ではログを出力し、本番環境では再起動誘導するよう表示の切り替えを推奨します。

以上。