DirectX Graphics フルスクリーンモードと窓使いの憂鬱: 解決編

サークルで作成しているゲームについて「フルスクリーン環境で実行した後にゲームを終了すると一般保護違反が発生する」という症状が報告されて,色々調べてみたところ原因は『窓使いの憂鬱』にありました.どおりでこちらの環境で再現しなかったわけです.実際『窓使いの憂鬱 Ver.3.30』を常駐させることで問題を再現できることを確認しました.

多くの場合こういう現象は「相性問題」という便利な言葉で真実に蓋をされてしまいがちですが…たまには「解」でもご覧あれ.

すんません,これ「相性問題」でした.今更ながらにコールスタックを眺め直していたら,こちらのゲーム側に Win32 ウィンドウのリークがありそうな気がしてきて実際ソースを読み直したらまさにその通りだったという……
窓使いの憂鬱』を一方的に原因扱いしてしまって申し訳ありません.また,以前質問を受けたときに気付けていれば id:applet_at_h さんにこれほどお時間を取っていただくこともなかったわけで,本当にご迷惑をおかけしました.お詫び申し上げます.



原因が分かったので問題の詳細を定義し直しておきます.
のどか - チケット #19267: DirectX全画面アプリ終了時に、アプリケーションエラーを引き起こす。』は,実際には Direct3D のフルスクリーンモードは直接は関係が無く,以下のような問題と言い換えることができます.

WinMain 終了時に DestroyWindow されていないウィンドウが残っていて,かつそのウィンドウに新着送信メッセージ (いわゆる SendMessage 系で送信されるメッセージ) がキューイングされている状態でアプリケーションが終了した場合に,『窓使いの憂鬱』系列のフック DLL が常駐していると終了ルーチンが不安定になる.

もっとも,そもそもアプリケーションがウィンドウをリークさせてしまっているので,もとより安定とは言い難い終了なわけですが.
『窓使いの憂鬱』の有無で挙動が変わるのは,『窓使いの憂鬱』のフック DLL が DllMain で SendMessageTimeout を使用しているためです.SMTO_BLOCK を使用せずに呼び出された SendMessageTimeout は,送信先ウィンドウからの返信を待つ間,キューイングされている新着送信メッセージをせっせとディスパッチしていきます.この結果,単にリークしているだけだったウィンドウにメッセージが配信されるように変化します.
『窓使いの憂鬱』が常駐することで発生するようになったこのメッセージディスパッチは,WinMain 終了後,しかも DllMain という非常に扱いの難しい状態から行われるため,呼び出されたウィンドウプロシージャで問題が起きる可能性は非常に高くなります.WinMain 終了後ということで,多くのリソースは解放されてしまっているでしょうし,DllMain 内ということで LoaderLock の問題もあります.結果として,リソースリーク (とはいえすぐに OS に回収される) をともなうサイレントな終了処理が,クラッシュが発生しユーザーが気付きやすい終了処理へと変化する,というのがことのあらましでした.
以下に問題を再現させるミニマムコードを掲載します.「のどか 4.10」と Windows XP SP3 および Windows 7 x64 版にて,WinMain 終了後にウィンドウプロシージャが呼び出されることを確認しました.

#define STRICT
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <tchar.h>

// Aux Library は以下からダウンロードできる.
// http://go.microsoft.com/FWLink/?LinkId=85311
// あるいは,Windows 7 SDK にも Aux Library は同梱されており,こちらを使用しても良い.
// http://go.microsoft.com/fwlink/?LinkID=150217
#include <Aux_ulib.h>

// Visual C++ 2005 でビルドする場合は以下の HotFix を適用する
// http://support.microsoft.com/kb/949009
#pragma comment (lib, "Aux_ulib.lib")

DWORD WINAPI threadProc(void* arg) {
  HWND window_handle = reinterpret_cast<HWND>(arg);
  ::SendNotifyMessage(window_handle, WM_NULL, 0, 0);
  return 0;
}

bool g_unexpected = false;

LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
  bool debug_break = false;

  if (g_unexpected) {
    // WinMain 終了後のコールバックは通常予想していない
    // -> クラッシュする可能性が高い
    ::OutputDebugString(_T("Unexpected callback was detected!\n"));
    debug_break = true;
  }

  BOOL has_loader_lock = FALSE;
  // http://msdn.microsoft.com/en-us/library/bb432187.aspx
  ::AuxUlibIsDLLSynchronizationHeld(&has_loader_lock);
  if (has_loader_lock) {
    // DllMain からのコールバックは通常想定していない
    // -> クラッシュする可能性が高い
    ::OutputDebugString(_T("LoaderLock was detected!\n"));
    debug_break = true;
  }

  if (debug_break) {
    // 問題が存在する場合ここで break
    DebugBreak();
  }

  switch (uMsg) {
    case WM_DESTROY:
      ::PostQuitMessage(0);
      break;
  }
  return ::DefWindowProc(hWnd, uMsg, wParam, lParam);
}


int PASCAL _tWinMain(HINSTANCE hinst, HINSTANCE, LPTSTR, int)
{
  const TCHAR* windowClassName = _T("SendMessageTest");
  const TCHAR* windowName = _T("Test Window");

  if (!::AuxUlibInitialize()) {
    return 1;
  }

  WNDCLASS wc      = {0};
  wc.lpfnWndProc   = WndProc;
  wc.hInstance     = hinst;
  wc.lpszClassName = windowClassName;
  if (!::RegisterClass(&wc)) {
    return 1;
  }

  // このウィンドウをリークさせる
  const HWND window_handle = ::CreateWindowEx(0, windowClassName, windowName, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, NULL, NULL, hinst, NULL);

  // リークするウィンドウに別スレッドから SendMessage 系列でメッセージを送っておく
  DWORD thread_id;
  HANDLE threadHandle = ::CreateThread(NULL, 0, threadProc, window_handle, 0, &thread_id);
  ::WaitForSingleObject(threadHandle, INFINITE);
  ::CloseHandle(threadHandle);

  // これ以降 WndProc が呼ばれなければ,ウィンドウのリークは表面化しない
  g_unexpected = true;

  // 以下のコードで問題を使用することで
  // 『窓使いの憂鬱』が常駐しない環境でも問題をエミュレーションできる
#if 0
  DWORD result = 0;
  // SMTO_NORMAL フラグが指定されているため,このスレッドのウィンドウに対して
  // 送られてきていた送信メッセージキューの配信も発生する
  ::SendMessageTimeout(::GetDesktopWindow(), WM_NULL, 0, 0, SMTO_ABORTIFHUNG | SMTO_NORMAL, 5000, &result);
#endif

  return 0;
}

送信メッセージ (PostMessage と SendMessage の違い) に関しては,『メッセージ管理 - EternalWindows』および『Windowsプログラミングの極意 歴史から学ぶ実践的Windowsプログラミング!』の第 15 章を参照してください.



さて,問題の解決方法ですが,まずアプリケーション側がきちんとウィンドウを破棄していれば問題は発生しません.この点,特に本当にご迷惑をおかけしました.
窓使いの憂鬱』側で取ることができる対応としては,DllMain 内ということを考慮して,不用意なメッセージディスパッチを極力避けるという方法が考えられます.とりあえず 3 つほど挙げてみます.下に行くほどうまくいきそうな感じで.

SendMessageTimeout + SMTO_BLOCK

/nodoka/trunk/nodoka/dll/hook.cpp (revision 105) では SendMessageTimeout が SMTO_NORMAL 付きで使用されていますが,これを SMTO_BLOCK に置き換えることで,SendMessageTimeout 内部からの予期せぬディスパッチは避けることができます.ただし,この変更による副作用も考えられますので,これで解決といえるかはまだ分かりません.

SendNotifyMessage

SendMessageTimeout を SendNotifyMessage に置き換えられるかもしれません.SendNotifyMessage は WM_COPYDATA と併用できませんが,/nodoka/trunk/nodoka/nodoka/hook.h (revision 105) を見る限り,送信しているデータは以下の 3 つだけのようです.これだけなら WM_USER + X の X,lParam, wParam で表現できそうです.

  • Notify::Type m_type
  • DWORD m_debugParam;
  • DWORD m_threadId;

SendNotifyMessage であれば,ノンブロッキングに処理できるため,レスポンスも良くなりそうです.

OpenThread

窓使いの憂鬱』/『のどか』本体の方のコード次第によっては,そもそも DLL_PROCESS_DETACH / DLL_THREAD_DETACH で Win32 メッセージを使った通信を行う必要をなくすことができそうな気もします.
ざっと見たところ,フック先プロセスで終了したスレッドの ID を,『窓使いの憂鬱』/『のどか』のサーバ側で使用しているようです.

窓使いの憂鬱」で、実施していることは、Hookしているアプリが終了して、DLLデタッチした際、そのDLLのGetCurrentThreadId()値を、EXEに通知し、m_detachedThreadIds list を更新するというもの。それを、そのまま「のどか」も「Yamy」も踏襲している。

この目的のために DLL_PROCESS_DETACH / DLL_THREAD_DETACH のたびにフック DLL からサーバに SendMessageTimeout で通知していたのがオリジナルのオリジナルの『窓使いの憂鬱』で,それが派生版にも引き継がれているとのこと.
ここですが,もし Windows 2000 以前を非サポートにしてよいのであれば,OpenThread を使ってサーバからスレッドハンドルを開き,そのシグナル状態を監視することでも可能かもしれません.DLL_PROCESS_DETACH / DLL_THREAD_DETACH はプロセスの異常終了等のため呼ばれないケースも存在しますし,外部からの監視で済むならそれに超したことは無いような気がします.
罪滅ぼしもかねてご参考までにということで.



参考文献.