0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

E06 【現場救急Tips】例外ログが残らず“突然終了”する P/Invoke/WndProc/Handle寿命の最短切り分け

Posted at

連載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を増やしても進まないので、発想を切り替えます。

  • “落ちた瞬間の状態”を残す(ダンプ)
  • どの呼び出しの直後に落ちたかを狭める(境界ログ)

アプリ内でできる最短の寄せ方はこれです。

  1. Win32呼び出しの直後だけ、必ず成否とエラーを記録する
  2. WndProcで処理したメッセージを、必要最小限だけ記録する
  3. 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定義を見直し、戻り値とエラーを揃える

関連トピック


0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?