技術記事

Delphi対FPC:PDFiumビルドにおけるPDFコードの4つの隠れた罠

同じObject Pascalソースコードであっても、DelphiとFPC/Lazarusの間で動作が異なる4つのケースがあり、これがPDFium Componentのコードで繰り返し問題となります。すなわち、FPCではinメンバーシップテストの読み取りが完了する前に関数の戻り値である一時レコードが破棄される問題、dcc32では範囲チェックがデフォルトで無効であるため配列のインデックス範囲外アクセスがサイレントにゴミデータを読み取る問題、Delphi 13のみキャストなしで匿名array of ByteTBytesに代入できる問題、およびDelphiのAnsiStringの結合処理で暗黙のコードページ往復変換により$80以上のバイトデータが破損する問題です。これらはそれぞれ、一方のコンパイラではテストが正常(グリーン)に通過するものの、他方のコンパイラでは失敗(レッド)したり、最悪の場合はサイレントに誤った挙動を引き起こしたりします

デュアルコンパイラ(DelphiとFPC)構成のプロジェクトを初めてセットアップする場合、LazarusおよびFPCビューアのチュートリアルに正常系(パッケージ、検索パス、描画ウィンドウの表示手順など)が解説されています。本記事はチュートリアルとは真逆の内容です。これは、正常系が機能し、FPCとDelphiのCIがともに正常にパスした後に直面した、一方では通過した変更が他方でクラッシュを引き起こした問題の一覧です。以下に示す各トラブルは、PDFiumPasテストスイートまたはデモの実装時に遭遇した本物のバグであり、コミットレベルの調査結果を最小限の再現コード、根本原因、および標準化した修正方法としてまとめています

なぜFPCでは集合データが空として読み取られ、Delphiではそうならないのか?

一言で言えば、FPCは関数の結果であるレコード構造体を保持する一時変数の破棄(ファイナライズ)処理を、その結果のフィールドを読み取る式の評価が完了する前に実行してしまうため、X in Func().Issuesの評価がすでに解放されたメモリに対してメンバーシップの判定を行うことになり、Delphiでは動作する同等の式がFPCでは動作しなくなります。PDF/E準拠テストの最初の実装時にこの問題に直面しました。検証ツールはIssuesフィールド(違反フラグの集合)を持つレコードを返し、アサーション処理は以下のようにその呼び出しをインライン化していました

// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Reliable on both compilers: pin the result to a local first
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

インライン化したコード形式では、FPC環境で集合データが常に空として読み取られたため、特定のフラグを想定したすべてのアサーションが失敗しました。一方で、全く同じコードをDelphiでビルドした場合は正常に通過しました。根本原因は、大きな式の中での関数結果の一時オブジェクトの生存期間(ライフタイム)を2つのコンパイラがどのように管理するかという違いにあります。Delphiはステートメントの最後まで一時オブジェクトを生存させますが、FPCではレコードの一時オブジェクトの破棄タイミングが、そのデータを読み取っている最中の集合メンバーシップ演算子と競合(レース)してしまいます。私たちはこれ以前にも、PDF/AテストユニットのFlagPresentヘルパーのコメントで全く同じ挙動をドキュメント化していましたが、新しいテストコードをゼロから記述した際にこのバグを再混入させてしまいました。これはバグを含むコード記述がいかに自然に見えるかを示しています。この問題の修正は機械的であり、共通のルールとして適用すべきです。すなわち、レコードを返す関数の呼び出し直後に、フィールドへのアクセスや集合の判定を直接チェーンしてはなりません。一度ローカル変数に結果を代入してから、そのフィールドを参照するようにしてください。この1行を追加するだけで、コンパイラ固有の不安定な動作を完全に排除できます

なぜDelphiはFPCでコンパイルエラーになる配列インデックスを許容するのか?

一言で言えば、dcc32は固定サイズの配列に対して範囲外のインデックス指定があっても通常通りコンパイルを行い、さらにデフォルトでは範囲チェックが無効になっているため、実行時にエラーを発生させずに隣接するメモリ領域をサイレントに読み書きしますが、FPCは同じ指定をコンパイルエラーとして拒否します。PDFium Componentは、PDFのQuadPointsエントリの一般的なナンバリングに合わせて、1から始まる配列として四角形の座標をTQuadrilateralPoint = array [1..4] of TPdfPointと宣言しています。インデックスを0から開始する再帰的ループで配列を埋めるデモコードは、Delphi環境下で数ヶ月間何の問題もなく動作していました

var
  I: Integer;
begin
  for I := 0 to 3 do                       // wrong: the array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
                                           // silently touches adjacent memory
                                           // FPC: compile-time range check error
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct on both compilers
end;

Delphiでのビルド結果は誤検出(偽陽性)でした。dcc32のデフォルト設定である範囲チェックが無効の状態では、インデックス0へのアクセスはレコード内で配列の直前にある無関係なフィールドにデータを書き込んでおり、デモは表面上動作しているように見えていました。同じデモプログラムをLazarusへ移植した際、FPCから即座にコンパイル時の範囲チェックエラーが出力されました。インデックスの指定ミスを修正したことで、誤ったメモリアクセスによって隠蔽されていた、ライブラリの注釈処理パス内の別根の深刻なバグ(四角形注釈の記事で詳述されているバグ)が検出されました。この事例から2つの教訓が得られました。第1に、配列のインデックス構造が0ベースであることが保証されていない限り、固定の数値指定ではなく常にLow()およびHigh()を使用することです。第2に、新しいデモやテストを記述する際は、FPCでのコンパイルチェック、または少なくともDelphi側で{$R+}ディレクティブを有効にした状態でのビルドを必須ステップにすることです。dcc32のデフォルト設定ではこの種のバグは検出できず、プログラムが動作することとコードが正しいこととは同義ではありません

Delphi 13のみが許容するTBytesへの代入

一言で言えば、匿名でarray of Byteと宣言されたフィールドをTBytes変数に代入するコードは、Delphi 13(コンパイラバージョン37.0)ではビルドが通りますが、Delphi 12 Athens以前のすべてのバージョンではE2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'というコンパイルエラーになります。これはDelphiとFPCの違いではなく、Delphiの新旧バージョン間の差異ですが、同じマルチコンパイラコードベースに対して同様の悪影響を及ぼします。すなわち、最新のコンパイラが他ですべてエラーになる記述を何事もなかったかのように許容してしまうという現象です

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymous dynamic array type
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Delphi 13 only; E2010 on Delphi 12
                                 // Athens and earlier
  OrigBytes := TBytes(FBuffer);  // compiles everywhere; same byte layout,
                                 // safe hard cast
end;

私たちは、Delphi 13が動作するローカル環境で開発およびテストした検証用コードにおいて、この暗黙の型変換を含む記述をそのままリリースしてしまいました。このフルソースコードインストーラーは、Delphi 12以前を使用している多くのユーザーにも提供されており、彼らの環境ではこのユニットがコンパイルエラーとなりました。構造的な修正方法としては、上記の例に示すようなハードキャストを行うか(匿名のarray of ByteTBytesは動的配列としてのメモリレイアウトが共通であるためキャストが可能です)、あるいは、より理想的には最初からフィールドの型をTBytesなどの名前付きの型で宣言して変換処理自体を不要にすることです。開発プロセスとしての対策はさらに重要です。最新のツールチェーンでコンパイルが成功したからといって、ユーザーが実行している古いコンパイラでも動作する保証にはなりません。この種の後退バグ(デグレード)は、サポートされているすべてのバージョンに対して実際にビルドを実行するまで検出できません。現在、私たちのリリーススクリプトは、最新の37.0ビルド単体では検出できない互換性の問題を検出するために、コンパイラマトリックス全体に対して自動ビルドを実行しています

中国語版Windowsで消失するAnsiStringのバイトデータ

一言で言えば、AnsiStringの結合時に+演算子を使用して$80以上の生のバイトデータを結合すると、Delphi環境下においてシステムコードページを介したAnsiString→UnicodeString→AnsiStringの暗黙の往復変換が発生し、そのバイトデータがサイレントに?($3F)に置換されてしまうことがあります。この問題は、ISO 19005-2の第6.1.8条で規定されている「不正なUTF-8文字の検出」をテストするために、正当なUTF-8としては使用できない$FEバイト単体を含む文字列をプログラム内で構築した際に発見されました

var
  BadName: AnsiString;
begin
  // On Delphi with a multi-byte system code page (observed on CP936),
  // the concatenation round-trips through UnicodeString and $FE, which
  // is not a valid CP936 sequence, comes back as '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Safe: build with an ASCII placeholder, then patch the byte in place;
  // indexed assignment into a settled AnsiString does not round-trip
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

コードページ936で動作する中国語版のWindowsシステムでは、結合された文字列に$FEバイトが全く含まれなくなり、結果として検証ツールがその問題を検出できなかったため、ライブラリのバグであるかのようにテスト結果がエラーとなりました。しかし、ライブラリ側の処理は正しかったのです。FPCを実行し、$FEバイトを含む正規のPDFファイルを入力した場合は、想定通りのエラーフラグが返されました。データの破損は、文字列の評価時にDelphiテストプログラムの内部で発生していました。これは、DelphiのUnicode優先の文字列モデルが、混在するAnsiString式を一度UnicodeStringに変換して処理するのに対し、$FEはCP936において不正なマルチバイトシーケンスの先頭バイトであるため、変換過程で置換されてしまうためです。この技術仕様の限界を正しく認識しておく必要があります。CP1252などのシングルバイトの欧米向けコードページでは、この結合処理は通常通り通過します。これが、多くの開発機でこのバグが隠蔽され、アジア圏のシステムや特定のCI環境でのみ顕在化する原因です。私たちが採用したルールは次の通りです。$80以上のバイトを含むバイナリのテスト用文字列をAnsiString of 結合処理で構築してはなりません。上記のコード例のように文字列が確定した後に特定のインデックスへ値を書き込むか、または最初からすべてのデータをTBytesを用いて構築してください

デュアルコンパイラ開発においてデフォルトでチェックすべきこと

4つの問題に共通するパターンがあります。すなわち、コンパイラごとに検出できるバグの種類が異なるという点です。FPCのコンパイル時の範囲解析は、dcc32で数ヶ月間警告なしで動作していたインデックスの範囲外アクセスを検出し、dcc32のUnicode文字列モデルは、バイトデータ指向のFPCビルドでは絶対に再現しないコードページ依存のバグを特定しました。実務上の結論として、一方のビルドパイプラインが成功しただけでは十分とは言えません。クロスコンパイルの実行は、単にマルチプラットフォーム対応の保証となるだけでなく、ABIとメモリ安全性の強化で説明している堅牢な境界チェックと同様の役割を果たす、第2の静的コード解析ツールおよび異なる実行ランタイム環境として機能します

これらの事例から策定された開発ルールは、すぐに記憶できるシンプルな内容です。関数の戻り値であるレコードのフィールドを読み取る前に、一度ローカル変数に結果を代入すること。固定サイズの配列のループ処理では常にLow()およびHigh()を使用すること。開発したデモを信用する前に、必ず範囲チェックを有効にするかFPCビルドを実行すること。匿名の動的配列は明示的にキャストするか名前付きの型で宣言すること。リリースの前にサポートするすべてのコンパイラバージョンでビルドテストを実行すること。AnsiStringの結合処理で生のマルチバイトデータを直接結合しないこと。これらは習慣化してしまえば開発コストにはならず、単一のコンパイラによる開発フローでは構造上検出できない不具合を未然に防ぐことができます

本記事で紹介した4つの問題は、Delphi、C++Builder、およびFPC/Lazarus向けに共通のObject Pascalソースコードを提供し、その各ツールチェーン上で回帰テストを継続的に実施しているPDFium Componentのメンテナンスの過程で発見および修正されたものであり、これらの不具合の再発は単なる記憶ではなくテストコードによって強固に監視されています