連載Index(読む順・公開済(リンク)はここが最新): S00_門前の誓い_総合Index
「落ちた」ではなく消えた。そんな場面がたまに起きる。
ボタンを押した瞬間に画面が閉じる。
HotKeyを押した瞬間にプロセスがいなくなる。
SetWindowPosを呼んだ直後に、何事もなかった顔で終了している。
イベントログも薄い。むしろログがない。
デバッガでも止まりにくい。否。止まらない。
この手の“突然終了”は、「突発で起きるやつ」の話ではなく、
境界(P/Invoke / WndProc / Handle寿命)を踏み外した時に、例外が観測できないまま終わるタイプの話です。
断末魔: 症状
- UI操作や特定キー入力の直後に、プロセスがスッと消える
- ログに例外が残らない(もしくは落ちる直前のログが途切れる)
- 本番/特定端末/特定操作だけで再現し、調査が長引く
- P/InvokeやWndProc、HotKey、Windowハンドル周りを触った直後から増えた気がする
真犯人: まずは「落ち方」を3つに分ける
最短で詰まりどころが減る分類はこれです。
| 分類 | 何が起きていることが多いか | “次に見る場所” |
|---|---|---|
| A: 例外は出ているが拾えていない | UIスレッド例外/Task例外/イベントハンドラ例外がログに出ていない | アプリ側の例外観測(ハンドラ) |
| B: .NETの外で落ちている | AccessViolation(0xC0000005) / スタック破壊 / FailFast など | Windows側の障害記録/ダンプ |
| C: 自分で終わらせている | Environment.Exit / Application.ExitThread / “終了導線”の副作用 | 終了経路のログ、終了コード |
このページで扱うメインは AとB です。
「例外ログが残らない」の大半がここに入ります。
処方箋: 解決手順(最短)
0. まず“Windows側の記録”で A/B を判定する
アプリのログが無いなら、Windowsの記録が先です。
最初に「管理内で落ちた(A)」のか「管理外で落ちた(B)」のかを分けます。
- イベントビューア
- 「.NET Runtime」系のエラーが出ている → A寄りになりやすい
- 「Application Error」系で 0xC0000005 などが見える → B寄りになりやすい
- 落ちた瞬間に何も残らない
- B(管理外)か、C(自前終了)が濃くなります
ここで A/B を分けるだけで、次の手がブレにくくなります。
1. A(拾えていない例外)なら、まず“最後に拾う”を必ず入れる
WinFormsは「例外は出ているのに、記録されずに終わる」経路が簡単に作れます。
まずは “最後に拾う”観測点 を入れて、証拠を残すところから始めます。
// Program.cs (.NET 6+ / .NET 8 でも同様)
using System.Diagnostics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
internal static class Program
{
[STAThread]
static void Main()
{
// 最低限: 落ちる直前に残る経路を増やす
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
Application.ThreadException += (_, e) =>
{
LogFatal("Application.ThreadException", e.Exception);
};
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
LogFatal("AppDomain.UnhandledException", e.ExceptionObject as Exception);
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
LogFatal("TaskScheduler.UnobservedTaskException", e.Exception);
e.SetObserved();
};
// まずは“どこまで動いたか”を残す
Trace.Listeners.Add(new TextWriterTraceListener("fatal.log", Encoding.UTF8));
Trace.AutoFlush = true;
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
static void LogFatal(string where, Exception? ex)
{
try
{
Trace.WriteLine($"[{DateTime.Now:O}] {where}");
Trace.WriteLine(ex?.ToString() ?? "(exception is null)");
Trace.Flush();
}
catch
{
// ここで投げ直しても得られるものが少ないため、黙って抜けます
}
}
}
「ログが残らない」を相手にするとき、最初の勝ち筋は “残る経路を増やす” ことになります。
原因の推理より先に、観測点を入れて“次に進める状態”を作ります。
2. B(管理外で落ちる)なら、観測をアプリ外へ寄せる
P/Invoke絡みの落ち方は、例外ハンドラに来ないことがあります。
その場合は、アプリ内のtry/catchを増やしても進まないので、発想を切り替えます。
- “落ちた瞬間の状態”を残す(ダンプ)
- どの呼び出しの直後に落ちたかを狭める(境界ログ)
アプリ内でできる最短の寄せ方はこれです。
- Win32呼び出しの直後だけ、必ず成否とエラーを記録する
- WndProcで処理したメッセージを、必要最小限だけ記録する
- Handleの生成/破棄タイミングを記録する
3. ここからが本題: P/Invoke / WndProc / Handle寿命で潰す
“突然終了”の入口はだいたいこの3つです。
どれも「境界」なので、境界の前後にログを置く のが効きます。
追い討ち: 入口別の切り分け(最短)
A) WndProc: まず「処理を置かない」へ寄せる
WndProcは、UIの入口です。
ここに重い処理や例外が混ざると、再現がブレて調査が伸びます。
最短の寄せ方は「受けたらキューへ逃がす」 です。
protected override void WndProc(ref Message m)
{
try
{
const int WM_HOTKEY = 0x0312;
if (m.Msg == WM_HOTKEY)
{
// ここでは“判断だけ”に寄せる
var hotKeyId = m.WParam.ToInt32();
// 実処理はBeginInvokeへ逃がす(メッセージ処理から分離)
BeginInvoke(() => OnHotKey(hotKeyId));
return;
}
base.WndProc(ref m);
}
catch (Exception ex)
{
// WndProc境界は「握る/握らない」の議論に入る前に、まず残す
Trace.WriteLine($"[{DateTime.Now:O}] WndProc exception: {ex}");
Trace.Flush();
// 続行方針はアプリの性質に合わせて決めます
throw;
}
}
private void OnHotKey(int id)
{
// ここは通常のアプリコードとして扱える(例外も観測しやすい)
}
ポイントは2つです。
- WndProcでは“判断だけ” に寄せる
- 重い処理はBeginInvokeへ 逃がして、例外を通常の経路に戻す
B) P/Invoke: “成功/失敗”を毎回同じ取り方にする
Win32 APIは「失敗しても例外が飛ばない」ことがあります。
なので、呼ぶ側で揃えます。
SetLastError=true + 戻り値 + GetLastWin32Error をセットで扱います。
using System.Runtime.InteropServices;
internal static class Native
{
[DllImport("user32.dll", SetLastError = true)]
static extern bool SetWindowPos(
IntPtr hWnd,
IntPtr hWndInsertAfter,
int X,
int Y,
int cx,
int cy,
uint uFlags);
internal static void SetWindowPosOrThrow(IntPtr hWnd, int x, int y, int w, int h, uint flags)
{
if (!SetWindowPos(hWnd, IntPtr.Zero, x, y, w, h, flags))
{
var err = Marshal.GetLastWin32Error();
throw new Win32Exception(err, $"SetWindowPos failed. err={err}");
}
}
}
ここで狙うのは「落ちる前に、失敗が見える状態」へ寄せることです。
P/Invokeの定義ズレ(構造体サイズ、CharSet、呼び出し規約)は、たまにしか落ちないこともあります。
その場合でも “直前のWin32呼び出し” が残ると、一気に狭まります。
C) Handle寿命: 触っていいタイミングだけに限定する
WinFormsのHandleは永遠に同じではありません。
作られる/破棄される/作り直される、が普通に起きます。
最短で効くのは、以下を揃えることです。
- Handleがあるときだけ登録する
- Handleが消えるときに必ず解除する
HotKeyは特にこの影響が出やすいです。
HotKeyの定番: 登録/解除をHandle寿命に合わせる
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Windows.Forms;
public sealed class HotKeyRegistration : IDisposable
{
private readonly Control _owner;
private readonly int _id;
private bool _registered;
public HotKeyRegistration(Control owner, int id)
{
_owner = owner;
_id = id;
_owner.HandleCreated += (_, __) => TryRegister();
_owner.HandleDestroyed += (_, __) => TryUnregister();
if (_owner.IsHandleCreated)
{
TryRegister();
}
}
public void Dispose()
{
TryUnregister();
}
private void TryRegister()
{
if (_registered) return;
if (!_owner.IsHandleCreated) return;
// 例: Ctrl + Alt + K
const uint MOD_CONTROL = 0x0002;
const uint MOD_ALT = 0x0001;
const uint VK_K = 0x4B;
if (!RegisterHotKey(_owner.Handle, _id, MOD_CONTROL | MOD_ALT, VK_K))
{
var err = Marshal.GetLastWin32Error();
throw new Win32Exception(err, $"RegisterHotKey failed. err={err}");
}
_registered = true;
}
private void TryUnregister()
{
if (!_registered) return;
if (!_owner.IsHandleCreated) { _registered = false; return; }
UnregisterHotKey(_owner.Handle, _id);
_registered = false;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
}
これで、典型の詰まりどころが減ります。
- 解除漏れで「次回起動だけ登録に失敗する」
- Handleの作り直しで「登録は古いHandleに残り続ける」
- WndProcでWM_HOTKEYを受けても、登録側が崩れていて再現がブレる
追い討ち: ハマりポイント(よくある順)
- UIスレッド以外からWin32 APIを叩く
- 呼び出し箇所がTask/Timer/別スレッド経由になっていると、再現が端末依存になりやすいです
- 呼び出しはUIスレッドに寄せるか、InvokeRequiredで揃えます
- コールバック(delegate)をローカル変数で渡している
- ネイティブ側が後で呼ぶとき、GCで回収されて落ちることがあります
- フィールドで保持し、寿命を合わせます
- 構造体の定義ズレ(32/64bit差、Pack、CharSet)
- 64bitでだけ落ちる/特定環境でだけ落ちる、の入口になりやすいです
- StructLayout/サイズ/ポインタ型(IntPtr)を再確認します
- WndProcでやりすぎる
- 再入(同じイベントが連鎖)で想定外の順序になり、落ちる条件が増えます
- 「受けたら逃がす」に寄せます
再発防止の掟: ルール化(運用・規約)
- WndProc内では処理を増やさず、判断だけに寄せてキューへ逃がす
- Win32呼び出しはラッパーに集約し、戻り値/エラー取得/ログの取り方を揃える
- HotKeyやフック系は「登録と解除」をHandle寿命とDisposeで保証する
- UIスレッド境界を明文化し、Win32 APIはUIスレッドに寄せる
禁書庫: 即効チェック(症状→次の一手)
| 症状 | まず見る | 次に疑う | 最短の対処 |
|---|---|---|---|
| ログが残らずプロセスが消える | Windows側の記録(イベントビューア) | 管理外(0xC0000005等) / 自前終了 | Win32境界ログ(呼び出し直後)を入れる |
| 特定キーで消える | WM_HOTKEY周辺 | HotKey登録/解除漏れ / ID競合 | HandleCreated/Destroyedに合わせて登録/解除 |
| UI操作直後に消える | WndProc入口 | WndProcでの例外 / 再入 | WndProcは判断だけに寄せ、BeginInvokeへ逃がす |
| 端末差が強い | 32/64bit差、OS差 | 構造体サイズ/CharSet/Pack | P/Invoke定義を見直し、戻り値とエラーを揃える |
関連トピック
- シリーズ総合Index(S00): https://qiita.com/vivinko/items/03e5b22a39bd79c34a4d
- E04 UIフリーズの正体(メッセージループ/Invoke/async): https://qiita.com/vivinko/items/2ea3f4b2f7e7384be262
- G11 メッセージループ入門(Application.Run): https://qiita.com/vivinko/items/dc0d9186055f49c3dce1
- G13 UIスレッドと非同期(awaitの帰還/デッドロック): https://qiita.com/vivinko/items/9cdff83d6bf3fb5d7c00
- R04 例外設計の掟(握り潰し禁止/throw;): https://qiita.com/vivinko/items/373886139954dd4e9d58
- R05 ログ設計の掟(証拠を残す/構造化ログ): https://qiita.com/vivinko/items/b15ed51fb2db6f19abf8