漆黒の最終防衛ライン:VB.NETにおけるグローバル例外捕捉基盤の極意
レガシーなVBAシステムからの移行期、あるいは過酷な現場で稼働し続けるミッションクリティカルなWindows Forms / WPFアプリケーション。その寿命と信頼性を決定づけるのは、美しいUIでも洗練されたビジネスロジックでもない。
「予期せぬ例外(Unhandled Exception)の全網羅と、死の瞬間の完全な記録」これに尽きる。
業務アプリケーションの現場において、`Try-Catch`の網の目を潜り抜けた致命的な例外は、容赦なくアプリケーションをクラッシュさせ、ユーザーが入力中のデータを闇に葬り去る。さらに悪いことに、開発者の手元には「突然落ちた」という絶望的な一言だけが残される。
今回は、VB.NETの中級から上級へステップアップするエンジニアに向け、CLR(共通言語ランタイム)の挙動の裏側までを熟知したチーフアーキテクトの視点から、`Application.ThreadException`と`AppDomain.CurrentDomain.UnhandledException`を完全に手懐け、鉄壁のグローバル例外捕捉基盤を構築する手法を伝授する。
—
1. 2つの例外捕捉メカニズムの深層
VB.NET(.NET環境)でグローバルな例外を捕捉するには、主に2つの異なるレイヤーを理解し、両者を適切に連携させなければならない。ここを履き違えると、マルチスレッド環境や非同期処理で発生した例外を取りこぼすことになる。
① `Application.ThreadException` (UIスレッド層)
- スコープ: Windows FormsアプリケーションのメインUIスレッド。
- 特性: UIスレッド上で発生した未処理例外を捕捉する。最大の特徴は、例外が発生してもアプリケーションを即死させずに継続(または安全な終了フローへ誘導)できる点にある。
- 限界: バックグラウンドスレッド(`Task`や`Thread`)で発生した例外はこのイベントまで降りてこない。
② `AppDomain.CurrentDomain.UnhandledException` (CLRアプデメイン層)
- スコープ: アプリケーションドメイン全体(全スレッド、バックグラウンド含む)。
- 特性: UIスレッド以外のスレッドで発生した未処理例外を捕捉する最後の砦。
- 限界: このイベントが発火した時点で、CLRはすでにプロセスの強制終了を決定している。 ここで例外を飲み込んでアプリを継続稼働させることは(.NETの仕様上)許されない。あくまで「遺言(ログ出力やダンプ採取)」のためのイベントである。
この2つを二重の網として張り巡らせることで初めて、いかなる死因であっても例外を取りこぼさない防衛網が完成する。
—
2. 実装:鉄壁のグローバル例外ハンドラーの設計
それでは、実際のプロダクションコードに投入可能な実装パターンを示す。
ここでは、単なるログ出力だけでなく、Windows APIを活用したOS情報の取得、メモリ最適化を意識したリソース管理、そして二重起動や多重ポップアップを防ぐ排他制御を組み込む。
Imports System.Threading
Imports System.IO
Imports System.Runtime.InteropServices
Namespace Infrastructure.Diagnostics
”’
”’
Public NotInheritable Class GlobalExceptionHandler
‘ 二重エラーダイアログ防止用のフラグ(スレッドセーフ)
Private Shared _isHandling As Integer = 0
”’
”’
Public Shared Sub Register()
‘ 1. UIスレッドの例外を捕捉(継続可能)
AddHandler Application.ThreadException, AddressOf OnUIThreadException
‘ 2. 非UIスレッド(バックグラウンド等)の例外を捕捉(プロセス終了直前)
AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf OnNonUIThreadException
‘ 3. アンマネージドコードやCOM相互運用からの致命的例外対策
AppDomain.CurrentDomain.SetData(“REGEX_DEFAULT_MATCH_TIMEOUT”, TimeSpan.FromSeconds(2))
End Sub
”’
”’
Private Shared Sub OnUIThreadException(sender As Object, e As ThreadExceptionEventArgs)
Try
‘ 多重発火の物理的防止
If Interlocked.Exchange(_isHandling, 1) = 1 Then Return
Dim ex As Exception = e.Exception
WriteEmergencyLog(ex, “UI_THREAD_CRITICAL”)
‘ ユーザーへの優美な通知
MessageBox.Show(
“アプリケーションで予期せぬエラーが発生しました。” & vbCrLf &
“管理者用のログが出力されました。作業を保存して再起動してください。” & vbCrLf & vbCrLf &
$”詳細: {ex.Message}”,
“致命的なエラー”,
MessageBoxButtons.OK,
MessageBoxIcon.Error
)
Catch fatalEx As Exception
‘ 例外処理中の例外(最悪のシナリオ)
File.AppendAllText(GetEmergencyLogPath(), $”[FATAL_LOOP] {DateTime.Now}: {fatalEx.ToString()}{vbCrLf}”)
Finally
‘ 必要に応じた安全なクリーンアップ
ReleaseUnmanagedResources()
Environment.Exit(1)
End Try
End Sub
”’
”’
Private Shared Sub OnNonUIThreadException(sender As Object, e As UnhandledExceptionEventArgs)
Dim ex As Exception = TryCast(e.ExceptionObject, Exception)
‘ クラッシュ直前のラストチャンス
WriteEmergencyLog(ex, “BACKGROUND_THREAD_CRITICAL”)
‘ IsTerminating が True の場合、プロセスは確実に死ぬ
If e.IsTerminating Then
File.AppendAllText(GetEmergencyLogPath(), $”[CLR_TERMINATE] Process is terminating violently at {DateTime.Now}{vbCrLf}”)
End If
End Sub
”’
”’
Private Shared Sub WriteEmergencyLog(ex As Exception, category As String)
Try
Dim logPath As String = GetEmergencyLogPath()
Dim logDir As String = Path.GetDirectoryName(logPath)
If Not Directory.Exists(logDir) Then
Directory.Exists(logDir)
End If
Dim logContent = String.Format(
“————————————————–{0}” &
“Timestamp: {1}{2}” &
“Category: {3}{4}” &
“Message: {5}{6}” &
“StackTrace:{7}{8}” &
“————————————————–{0}”,
Environment.NewLine,
DateTime.Now.ToString(“yyyy-MM-dd HH:mm:ss.fff”),
Environment.NewLine,
category,
Environment.NewLine,
If(ex IsNot Nothing, ex.Message, “Unknown Exception Object”),
Environment.NewLine,
If(ex IsNot Nothing, ex.StackTrace, “No StackTrace”),
Environment.NewLine
)
File.AppendAllText(logPath, logContent, System.Text.Encoding.UTF8)
Catch
‘ ログ書き込みすら失敗する場合はコンソール等に逃がすか諦める
End Try
End Sub
Private Shared Function GetEmergencyLogPath() As String
‘ ユーザーのAppData等、書き込み権限が確実に保証された領域を使用する
Dim baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), “YourCompany”, “YourApp”)
Return Path.Combine(baseDir, “crash.log”)
End Function
Private Shared Sub ReleaseUnmanagedResources()
‘ GCの強制実行とファイナライザの遅延消化
GC.Collect()
GC.WaitForPendingFinalizers()
GC.Collect()
End Sub
End Class
End Namespace
—
3. チーフアーキテクトが教える「現場の知見」とアンチパターン
長年、エンタープライズ領域のレガシーシステムや肥大化したVB.NETアプリケーションをコードレビューしてきた中で、グローバル例外処理の導入においてエンジニアが陥りがちな罠が存在する。
1. 例外ハンドラー内でさらなる例外を発生させる愚
最も多いのが、例外発生時に「データベースへエラーログを保存しようとする」実装である。
データベース接続が切断されていたり、テーブルロックがかかっていることが原因でクラッシュしている最中に、同じデータベースへアクセスすれば「例外ハンドラー内での例外(Exception in Exception)」が発生し、エラーログすら残らずにプロセスが消滅する。
鉄則: クラッシュ時のログ出力には、ネットワークや外部リソースを一切必要としない「ローカルファイル(テキスト)」または「Windowsイベントログ」を使用せよ。
2. `Catch`での握り潰し(`Catch ex As Exception` のみの記述)
初心者がやりがちなのが、グローバルではなく個別のメソッドで `Catch` を書き、中身を空っぽ(あるいは `MessageBox.Show` のみ)にすることだ。これにより、メモリリークの兆候や、データ不整合のバグが完全に闇に葬られる。
捕捉した例外は、必ず「継続可能な軽度なエラー」か「プロセスを殺すべき致命的エラー」かを分類し、後者の場合は確実に上位(または今回のグローバル基盤)へ再スロー(`Throw`)またはハンドリングさせよ。
3. メモリの枯渇とオブジェクトの明示的解放
VB.NET(特にWindows FormsのGDI+オブジェクトやCOMオブジェクト)において、ガベージコレクション(GC)の自動処理を過信してはならない。
致命的な例外が発生してアプリケーションがシャットダウンする際、アンマネージドリソース(ファイルハンドル、デバイスコンテキスト、ミューテックスなど)が解放されないまま残ると、OS側にゴミが残り、最悪の場合OSの再起動が必要になる。
上記のサンプルコード内にあるように、クラッシュの直前には `GC.Collect()` を明示的に呼び出し、ファイナライザを強制稼働させるアプローチが、極限環境で生き残るシステムでは要求される。
—
総括
アプリケーションの美しさは正常系のコードで決まるが、その「品格」と「信頼性」は異常系のハンドリングで決まる。
今回解説した `Application.ThreadException` と `AppDomain.CurrentDomain.UnhandledException` による二重防衛ラインをメインエントリポイント(`Sub Main` や `Application_Startup`)に一行組み込むだけで、システムは「突然沈没するブラックボックス」から「自己診断してログを残す、洗練されたプロフェッショナルなソフトウェア」へと生まれ変わる。
妥協のない例外設計こそが、レガシーの呪縛を断ち切り、モダンで堅牢な.NETアプリケーションを構築するための唯一の王道である。
