start

C#の0がenum値に暗黙キャストされてinvokeStaticで例外出て超ハマった

僕はついさっき知ったばかりなんですけど、C#の数値0ってあらゆるenum値に暗黙的キャストされるんですってね。要するに、0だけはキャスト不要で列挙型の値として変数に代入できちゃったりするということ。コードで示すと以下のようなかんじ。

using System;
 
public class Program
{
    enum Hoge { A, B }
    enum Piyo { C, D }
 
    public static void Main()
    {
        Hoge h1 = 0,   h2 = 0.0f;
        Piyo p1 = 0x0, p2 = 0.0m;
        Console.WriteLine($"{h1}, {h2}");
        Console.WriteLine($"{p1}, {p2}");
    }
}

このコードは何の警告もなくビルドが通り、以下の実行結果が得られる。

A, A
C, C

浮動小数点数やDecimalのゼロも同様の扱いっていうのが、なかなかのキモいポイント。C#のenumってC/C++のそれと違ってそれなりに厳密なので、自分的には結構衝撃的な仕様だった。まぁ、分かってしまえばどうってことはない。

で、ここからが本題。

そんな0のenumへの暗黙的型変換のおかげで、意図せぬメソッドのオーバーロード解決が行われ、小一時間ハマった。

以下のような処理メソッドAddと、それに対する単体テストAddTestを考える。対象メソッドはプライベートな静的メソッドなので、テスト呼び出しにはPrivateType.InvokeStaticメソッドを使っているが、至ってシンプルなコードである。

何の疑いもなく動くと思いきや、ケース3のテストでのみMissingMethodException例外が発生し、Addメソッドの呼び出しに失敗するのだ!

// 加算
private static decimal? Add(decimal? v1, decimal? v2)
{
    return v1 + v2;
}
 
// Addメソッドの単体テスト
void AddTest()
{
    var privateType = new PrivateType(typeof(AddClass));
 
    // ケース1
    var value = (decimal?)privateType.InvokeStatic("Add", 1.0m, 1.0m); // OK
    Assert.AreEqual(2, value);
 
    // ケース2    
    value = (decimal?)privateType.InvokeStatic("Add", 1.0m, 0.0m); // OK
    Assert.AreEqual(1, value);
 
    // ケース3
    value = (decimal?)privateType.InvokeStatic("Add", 0.0m, 1.0m); // MissingMethodException例外が発生!
    Assert.AreEqual(1, value);
}

MissingMethodExceptionは読んで字のごとく、メソッドが見つからなかった場合に投げられる例外だ。

ケース1~2は通っているのに見つからないとは一体…!?という感じなのだが、これまでの説明からお気づきであろう、ケース1~2と3では呼び出されるInvokeStaticのシグネチャが違うのだ。同メソッドは引数違いで10個ほどのオーバーロードが定義されており、それぞれ以下のシグネチャのものが呼び出される。

  • ケース1~2
    • InvokeStatic(String, params Object[])
  • ケース3
    • InvokeStatic(string, System.Reflection.BindingFlags, params Object[])

ケース3では、第二引数の0.0mがBindingFlagsに暗黙キャストされた結果、引数を1つ持つAddメソッドを呼び出そうとして例外を吐くというわけ。なんじゃそりゃー!分かるわけネェーッ!!意図通り動かすにはprivateType.InvokeStatic(“Add”, (decimal?)0.0m, 1.0m)のように、明示的にキャストし正しいオーバーロード解決を導いてあげればよい。

この暗黙型変換を禁止ないし警告出してくれるコンパイルオプションとかないのかしら…ハマった時に死ぬほどわかりづらいんですけど……

MSYS2/MinGW-w64でWindows版QMPlay2をビルド

思う所があってマルチプラットフォームなメディアプレーヤーQMPlay2をWindowsで自前ビルドした。公式にWindows用ビルドが提供されているので、自前ビルドしようっていう物好きな人はそういないだろうけど、参考になればとメモがてら手順を書いておく。

  • Windows 10 (x64) 20H2
  • msys2
  • MinGW-w64
  • GCC 10.3.0
  • QMPlay2 21.06.07-gti-417708b8

とりあえずmsys2のインストールとパッケージ更新までは済ませておく。

以下の作業はMinGW-w64環境(MSYS2インストールディレクトリのmingw64.exe起動で出てくるシェル)で行う。

コンパイラ、開発環境類をインストール。

pacman -S base-devel mingw-w64-x86_64-toolchain mingw-w64-x86_64-cmake

注意すべきはcmake。msys2リポジトリにもcmakeがあるが、こちらはファイルパスの扱いがUNIXスタイルとなるため、mingw-w64リポジトリのソフトで正しく扱えない。したがって、Windowsスタイルのファイルパス用のming-w64のcmakeを使う必要がある。

gitをインストール

pacman -S git

Qtをインストール

pacman -S mingw-w64-x86_64-qt5

FFmpegをインストール。これだけでビルドに必要な最低限のライブラリは殆ど自動で入った記憶。

pacman -S mingw-w64-x86_64-ffmpeg

PortAudioをビルド。MinGWにビルド済みパッケージがあるけど、WASAPIが有効になってないので自前ビルドする必要がある。tarballの取得と展開は割愛。

cd portaudio
/mingw64/bin/cmake .. -G"MSYS Makefiles" -DCMAKE_INSTALL_PREFIX=/mingw64 -DPA_USE_WMME=1 -DPA_USE_WASAPI=1 -DPA_USE_DS=1 -DPA_USE_WDMKS=1 -DMINGW=1
make -j8
make install

ここまででQMPlay2をビルドする環境が整った。

ソースコードとサブモジュールを取得。

git clone https://github.com/zaps166/QMPlay2.git
git submodule update --init

ビルドディレクトリを作ってcmake。cmakeは前述の通りmingw64のバイナリを明示的に使い、加えてMakefileを生成したいので-Gでオプションが必要。さもないとVisual Studio用のソリューションファイルが生成されてしまう。

cd QMPlay2
mkdir build
cd build
/mingw64/bin/cmake .. -DCMAKE_INSTALL_PREFIX=../install -G"MSYS Makefiles"

上手くいけば↓こんな感じでcmakeが成功する。

-- Could NOT find TAGLIB (missing: TAGLIB_LIBRARY TAGLIB_INCLUDE_DIR)
-- Could NOT find LIBGME (missing: LIBGME_LIBRARY LIBGME_INCLUDE_DIR)
-- Enabled features:
 * Updates, Build with software updates
 * OpenGL, Build with OpenGL support
 * Vulkan, Build with Vulkan support
 * libass, Build with libass support
 * Inputs, Build with Inputs module
 * Modplug, Build with Modplug module
 * Extensions, Build with Extensions module
 * MediaBrowser, Build with MediaBrowser support
 * LastFM, Build with LastFM support
 * Lyrics, Build with lyrics support
 * Radio, Build with Radio Browser support
 * YouTube, Build with YouTube support
 * Visualizations, Build with Visualizations module
 * AudioFilters, Build with AudioFilters module
 * VideoFilters, Build with VideoFilters module
 * PortAudio, Build with PortAudio module
 * DXVA2, Build D3D11VA acceleration into FFmpeg
 * CUVID, Build with CUVID module
 * Notifications, Build additional notifications module
 * Git version, Append Git HEAD to QMPlay2 version

-- Disabled features:
 * PCH, Use precompiled headers
 * CMD, Show CMD when running QMPlay2
 * GLSLC, Compile Vulkan shaders
 * TagLib, Build with tags editor
 * VAAPI, Build VAAPI acceleration into FFmpeg
 * VDPAU, Build VDPAU acceleration into FFmpeg
 * libavdevice, Build FFmpeg with libavdevice suport
 * AudioCD, Build with AudioCD module
 * ALSA, Build with ALSA module
 * Chiptune GME, Build Chiptune with GME support
 * Chiptune SIDPLAY, Build Chiptune with SIDPLAY support
 * PulseAudio, Build with PulseAudio module
 * PipeWire, Build with PipeWire module
 * XVideo, Build with XVideo module
 * Link Time Optimization, Enable link time optimization for release builds
 * Address Sanitizer, Use Address Sanitizer
 * Undefined Behavior Sanitizer, Use Undefined Behavior Sanitizer

-- Build type: Release
-- Configuring done
-- Generating done
-- Build files have been written to: C:/Home/proj/QMPlay2/build

「TagLibとGMEがない」と怒られるが、必須ではないので問題なし。必要ならpacman -S mingw-w64-x86_64-taglib mingw-w64-x86_64-libgmeしとけばいいかと。

必須ライブラリがない場合、怒られてcmakeに失敗するので頑張って対処する。

makeとインストール。

make -j8
make install

上手くいけば、CMAKE_INSTALL_PREFIXで指定したフォルダ、ここでは../installにQMPlay2.exeなどがコピーされる。

PATHの関係でエクスプローラからexeダブルクリックでは起動できないため、ビルドしたシェルに../install/QMPlay2.exeと打ち込んで起動する。試してないけど、必要なDLLをexeと同じ場所に置いたり、PATHにmingw64/binを通せばエクスプローラからでも起動できると思う。

代替データストリームの取得はNtQueryInformationFile一択

C#でNTFSの代替データストリーム(Alternate Data Stream)を読み書きしたくなったので調べたことをメモ。正確な部分は把握しきれてないが、非公開関数であるNtQueryInformationFileで列挙するのが確実のようだ。

代替データストリームの取得(列挙)には以下の3つの方法がある。

  1. BackupRead関数を使う
  2. FindFirstStreamW関数, FindNextStreamW関数を使う (Windows Vista以降)
  3. NtQueryInformationFile関数を使う (非公開関数)

正攻法は1, 2で、調べた限りADSの読み書きを行う既存のC#ライブラリは、1のBackupRead/BackupWriteを使っている。

ところがどう言う訳か、BackupReadでは列挙されないストリームが存在しうる。dir /rNirSoft AlternateStreamViewでは表示されるにも関わらずだ。NTFSによるアクセス権限の問題らしいが、詳しいことは分からない。

NtQueryInformationFileはアクセス権限を無視して情報を取得できるらしく、前述のdirやAlternateStreamViewはこのAPIを使っているのだろう。多分。

2の方法は試してないが、アクセス権を無視するオプションはないらしいので望み薄と思われる。

こちらの記事のC#でNtQueryInformationFileを使ったサンプルで、無事目的のADSが取得できることを確認。というわけで、非公開関数ではあるもののNtQueryInformationFileを使うのが確実っぽい。

PostgreSQLのDBドライバ使用時の「SSL error」はスレッド周りを見直す

超絶ハマったのでメモ。

QtのPostgreSQLドライバ(QSqlDatabaseの“QPSQL”)で連続したクエリを大量に発行すると、ポスグレのログに以下のようなログが記録されて正しく実行されない現象に遭遇した。

2020-06-11 19:41:00.598 JST [2593] db@postgres STATEMENT:  DEALLOCATE qpsqlpstmt_10
2020-06-11 19:41:00.633 JST [3924] db@postgres LOG:  SSL error: decryption failed or bad record mac
2020-06-11 19:41:00.634 JST [3924] db@postgres LOG:  could not receive data from client: Connection reset by peer
2020-06-11 19:41:00.634 JST [3924] db@postgres LOG:  unexpected EOF on client connection with an open transaction
2020-06-11 19:41:00.645 JST [3925] db@postgres ERROR:  invalid input syntax for type timestamp: " " at character 77
2020-06-11 19:41:00.645 JST [3925] db@postgres STATEMENT:  EXECUTE qpsqlpstmt_16 (TIMESTAMP WITH TIME ZONE '2020-06-11T10:41:00.634Z', '[, )', FALSE, 0, 0, 0, 0, 0, 10, '', 0, 2020061110480001, 0, 1, 0, 10)
2020-06-11 19:41:00.646 JST [3925] db@postgres WARNING:  there is no transaction in progress
2020-06-11 19:41:56.266 JST [12492] db@postgres LOG:  SSL error: tlsv1 alert protocol version
2020-06-11 19:41:56.272 JST [12492] db@postgres LOG:  could not receive data from client: Connection reset by peer

Qt側のログは消失してしまったが、SSL SYSCALL Resource temporarily unavailableSSL SYSCALL error: EOF detectedといったエラーが出てDBとのコネクションが破壊され、トランザクションに失敗したりとかそんな感じ。

両方のログから読み取れるのは、クエリ発行時にプログラムとポスグレ間のSSL接続が通信途中で切れたり、データが破壊され変なクエリを実行しようとしたり、SSLの認証に失敗したりしてる。どーしてそんなことが起こるの?

それらしい単語でググると、postgresのSSLを無効にして解決とか出てくるけど、それって何の解決にもなってなくね?っていう。自分が加えた変更以後に発生するようになったので、原因は間違いなく自分ってことだけはハッキリしてる( ˘ω˘ )。

DBドライバとコネクションについて調べ、コードを追ってみたところ、スレッドをまたいで同一のDBコネクションを使ってたのが原因のようだった。一般的に、1つのデータベースコネクションを複数のスレッドで操作するのは禁じ手だそうで、Qtのドキュメントにも

A connection can only be used from within the thread that created it. Moving connections between threads or creating queries from a different thread is not supported.
(訳:コネクションは生成されたスレッドからしか使用できない。コネクションのスレッドをまたぐ移動や異なるスレッドからのクエリ生成は非対応。)

という記述がある。でーびーしょしんしゃなのでしらんかった。

DB操作スレッドで作られ使われていたコネクションに対し、今回メインスレッドからクエリを発行する変更を加え、なおかつ、特定の操作を行うと両スレッドが入り乱れてコネクションを使うために、問題が顕在化したようだった。

というわけで、メインスレッド用に別途コネクションを用意し問題を回避した。解決ではなく回避なのは、かなりアレな設計で緊急措置的な対応だから…。元の構造がアレすぎていい対処方法が思いつかなかったの……。

FName::GetPlainANSIString()で正しい文字列が取れない事がある

お急ぎのあなたのために、まずは結論。FNameが保持する文字列が必要な時はToString()で生成したFStringを使うべし。GetPlainANSIString()GetPlainWIDEString()を使うとハマるから止めといた方がいい。

Unreal Engine 4の軽量文字列クラスFName公式リファレンス)のGetPlainANSIString関数/GetPlainWIDEString関数で取得できる文字列ポインタには、そのFNameインスタンスが本来持っている文字列の一部しか入っていない事がある。恐らく仕様。以下が実証コード。

TArray<FName> Names;
Names.Add(FName(TEXT("Hoge_")));
Names.Add(FName(TEXT("Hoge_0000")));
Names.Add(FName(TEXT("Hoge_0001")));
Names.Add(FName(TEXT("Hoge_10")));
Names.Add(FName(TEXT("Hoge_1200")));
Names.Add(FName(TEXT("Hoge_1300")));
Names.Add(FName(TEXT("Hoge_9999")));
Names.Add(FName(TEXT("Hoge9999")));
 
for (const auto& Name : Names)
{
	UE_LOG(LogWindows, Log, TEXT("◆%s"), *Name.ToString());
	UE_LOG(LogWindows, Log, TEXT("  PlainAnsiString=%s (%p)"), *FString(Name.GetPlainANSIString()), Name.GetPlainANSIString());
	UE_LOG(LogWindows, Log, TEXT("  [FNameEntry]"));
	UE_LOG(LogWindows, Log, TEXT("    ComparisonIndex=%d"), Name.GetComparisonIndex());
	UE_LOG(LogWindows, Log, TEXT("      [ComparisonEntry]"));
	UE_LOG(LogWindows, Log, TEXT("        Address=%p"), Name.GetComparisonNameEntry());
	UE_LOG(LogWindows, Log, TEXT("        isWide=%d"), Name.GetComparisonNameEntry()->IsWide());
	UE_LOG(LogWindows, Log, TEXT("    DisplayIndex=%d"), Name.GetDisplayIndex());
	UE_LOG(LogWindows, Log, TEXT("      [DisplayEntry]"));
	UE_LOG(LogWindows, Log, TEXT("        Address=%p"), Name.GetDisplayNameEntry());
	UE_LOG(LogWindows, Log, TEXT("        isWide=%d"), Name.GetDisplayNameEntry()->IsWide());
	UE_LOG(LogWindows, Log, TEXT("    Number=%d"), Name.GetNumber());
}

実行結果を見ると、Hoge_10, Hoge_1200, Hoge_1300, Hoge_999で見事に同一のPlainAnsiStringが返ってきているのが分かる(★の部分)

LogWindows: ◆Hoge_
LogWindows:   PlainAnsiString=Hoge_ (000000021299B988)
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029720
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B978
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029720
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B978
LogWindows:         isWide=0
LogWindows:     Number=0
LogWindows: ◆Hoge_0000
LogWindows:   PlainAnsiString=Hoge_0000 (000000021299B9A0)
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029721
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B990
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029721
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B990
LogWindows:         isWide=0
LogWindows:     Number=0
LogWindows: ◆Hoge_0001
LogWindows:   PlainAnsiString=Hoge_0001 (000000021299B9C0)
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029722
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9B0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029722
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9B0
LogWindows:         isWide=0
LogWindows:     Number=0
LogWindows: ◆Hoge_10
LogWindows:   PlainAnsiString=Hoge (000000021299B9E0)…★
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029723
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029723
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     Number=11
LogWindows: ◆Hoge_1200
LogWindows:   PlainAnsiString=Hoge (000000021299B9E0)…★
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029723
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029723
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     Number=1201
LogWindows: ◆Hoge_1300
LogWindows:   PlainAnsiString=Hoge (000000021299B9E0)…★
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029723
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029723
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     Number=1301
LogWindows: ◆Hoge_9999
LogWindows:   PlainAnsiString=Hoge (000000021299B9E0)…★
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029723
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029723
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9D0
LogWindows:         isWide=0
LogWindows:     Number=10000
LogWindows: ◆Hoge9999
LogWindows:   PlainAnsiString=Hoge9999 (000000021299B9F8)
LogWindows:   [FNameEntry]
LogWindows:     ComparisonIndex=1029724
LogWindows:       [ComparisonEntry]
LogWindows:         Address=000000021299B9E8
LogWindows:         isWide=0
LogWindows:     DisplayIndex=1029724
LogWindows:       [DisplayEntry]
LogWindows:         Address=000000021299B9E8
LogWindows:         isWide=0
LogWindows:     Number=0

FNameはハッシュ付き文字列として実装されている。文字列はFNameの共用領域に格納され、各FNameインスタンスはその文字列格納領域へのインデックス=ハッシュを保持してる。FNameの同士の比較は互いのハッシュの比較、つまり整数の比較に還元されるため、通常の文字列比較より速いって仕掛けなんですな。

ただ、このハッシュ生成方法がちょっと曲者で、文字列がアンダースコア+ゼロ詰めされていない数値で終わっていたら、その部分を除いた文字列を共用領域に格納し、数値は各FNameインスタンスで保持するという方法なのだ。割と最近のバージョンアンプで変わったらしい(知人曰く4.12あたりで変わった気がすると)。なかなか破壊的な変更をしてくださりやがるな!言葉だと分かりにくいので図を作ってみた。

図を踏まえつつ改めて実行結果を見てみると、条件に合致するHoge_10, Hoge_1200, Hoge_1300, Hoge_999の中身が、その通りになっている事がお分かりいただけよう。Numberが実際の数値+1されているのは、これまた仕様で、数値分割条件を満たさないFNameインスタンス(Number == 0)との区別の為っぽい。

UE4では生成されたオブジェクトのインスタンスに対し、オブジェクト名+連番の名前を自動付与するため、大規模な開発になるとFName文字列ストアの肥大化が無視できなくなり、保持方法を変更したのだと思われる。

こんな格納の仕方で大丈夫なの!?と思うが、ふつーにFNameを使う分には何の問題もない。や、正確には大丈夫じゃなかったからこの記事書いてる訳だけど、文字列の生ポインタ取ってこねくり回すような事をしなければ大丈夫。FName文字列に対して低レベルな操作を行いたい時は、ToString()関数で生成したFStringに対して行う事をオススメする。エンジンのコードを見てもらうと分かるが、ComparisonIndexの文字列にアンダースコアと数値をくっつけて元の文字列を復元してやがるので(笑)

ちなみに、実行結果内のDisplayIndexというのは、名前の通り表示用の文字列へのインデックス。簡単に言えば、FName生成時に与えられた文字列のインデックスである。FNameは基本的にcase-preserving(内部的には大文字小文字を区別するが対外的には区別せずに扱う)なので、比較用と表示用で別々の文字列を持つ必要がある訳だ。知らずに使ってると、これも地味にハマりポイントかも。

FName abc(TEXT("abc"));
FName ABC(TEXT("ABC"));
UE_LOG(LogWindows, Log, TEXT("%s %s %s"), *abc.ToString(), (abc == ABC ? TEXT("==") : TEXT("!=")), *ABC.ToString());

念のため上記コードで確認したら、LogWindows: abc == ABCとなった。

掘ってみるとFNameには結構罠があるので注意が必要だ。

  • start.txt
  • 最終更新: 2022-07-27 15:26
  • by Decomo