技術記事

DelphiでのPDFium DLLの配置:ロード失敗の修正

顧客の環境でpdfium.dllのロードが拒否された場合、DelphiおよびLazarus用のPDFiumコンポーネントは、不可解なネイティブエラーを特定原因へと変換します。CheckLoadLibraryルーチンは、ERROR_BAD_EXE_FORMAT(193)を32/64ビットのアーキテクチャの不一致として読み取り、CheckGetProcAddressは、バインディングよりも古いpdfium.dllによるエクスポートの欠落を報告します。どちらのメッセージも、開発者に推測させることなく修正方法を提示します

これは、ネイティブDLLの失敗には3つの異なるパターンがあり、オペレーティングシステムの生テキストではこれらすべてが混同されるためです。アーキテクチャが間違っているか、配置されたバイナリがそれを呼び出すPascalバインディングに対して古すぎるか、あるいは2つのスレッドが同時にそれをバインドしようと競合している可能性があります。それぞれに異なる修正方法があり、v2.11.0のロードライフサイクル作業で追加された診断の主な目的是、実際にどれが発生しているかを特定することにあります

なぜpdfium.dllが不正なEXEフォーマットエラーでロードに失敗するのか

Windowsエラー193(ERROR_BAD_EXE_FORMAT)は、ファイルが見つかって開かれたものの、その PEマシンタイプがホストプロセスと一致しないことを意味します。64ビットの実行ファイルは32ビットのpdfium.dllをロードできず、32ビットの実行ファイルは64ビットのpdfium.dllをロードできません。ファイル自体は存在するため、見つからないDLLを探そうとする直感は、まったく誤った方向へと向かわせることになります

PDFiumコンポーネントは、CheckLoadLibraryでこのケースをインターセプトします。LoadLibraryがnullハンドルを返し、GetLastErrorが193の場合、DLLは見つかったもののアーキテクチャがホストプロセスと一致しないというヒントを追加し、対応するDLLs/Win32またはDLLs/Win64ビルドを指し示します。サブディレクトリはBuildDllSubDirによって選択され、IsWin64がtrueの場合はWin64を、そうでない場合はWin32を返します。バインディングが実際に検索するツリーの下にDLLを配置すれば、不一致は解消されます

uses
  SysUtils, PDFium;

procedure OpenDocument(const AFileName: string);
var
  Pdf: TPdf;
begin
  // Deploy pdfium.dll next to the executable, matched to the build target:
  //   <AppDir>\DLLs\Win32\pdfium.dll   for a 32-bit host process
  //   <AppDir>\DLLs\Win64\pdfium.dll   for a 64-bit host process
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := AFileName;
    try
      Pdf.Active := True;   // first use lazily binds pdfium.dll
    except
      on E: EPdfError do
        // The message already names the real cause: a 32/64-bit mismatch
        // (ERROR_BAD_EXE_FORMAT) or a pdfium.dll older than this binding
        raise Exception.CreateFmt('PDF engine did not start: %s', [E.Message]);
    end;
    if Pdf.Active then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

PDFiumのエクスポート欠落は何を意味するのか

2番目の失敗パターンはバージョンのズレです。PDFiumは時間の経過とともに出力関数を新しく追加しており、PascalバインディングはLoadLibrary中にCheckGetProcAddressを介して必要なすべての関数を解決します。必要なエクスポートが存在しない場合、バインディングがディスク上のpdfium.dllよりも新しいことを意味し、配置されたDLLが破損しているのではなく古くなっていると診断するのが適切です。CheckGetProcAddress関数はまさにそのように動作します。配置されたpdfium.dllがこのPDFiumPasバインディングのビルドよりも古いことを報告するEPdfErrorを発生させ、対応するバイナリが属するDLLs/<subdir>パスを明示します

このパスを堅牢にする2つの詳細があります。第一に、CheckGetProcAddressは例外を発生させる前にUnloadLibraryを呼び出すため、一部だけバインドされた中途半端な関数ポインタが残り、次の試行でエラーを引き起こすことはありません。第二に、報告されるDLL名はBuildDllNameから取得されます。これは通常はpdfium.dllを返し、グローバルなEnableV8Engineフラグが設定されている場合はpdfium.v8.dllを返します。アプリケーションがXFAまたはスクリプトフォーム用にV8 JavaScriptエンジンを有効にしている場合、エラーテキストは通常のビルドではなくV8ビルドを指すため、最初から正しいファイルを置き換えることができます

PDFium DLLはバックグラウンドスレッドから安全にロードできるか

ライブラリのロードはどのスレッドからでも安全に行えるようになりましたが、複数のスレッドからPDFium APIを呼び出すことは依然として安全ではありません。これらは2つの異なる保証であり、それらを区別しておくことが、安定したワーカープールと断続的なクラッシュの境界線となります。バックグラウンドレンダリングは通常、各ページレンダリングをTPdfFuture<T>ワーカーを介して実行し、TPdfに触れる最初のフューチャーが遅延バインドをトリガーします。保護がない場合、2つのワーカーが両方ともロードされていないライブラリを検知し、両方がバインドシーケンスを実行して、モジュールハンドルを上書きし、最初のロードをリークさせる可能性があります

v2.11.0の作業では、LoadLibraryUnloadLibraryの両方へのエントリをラップするプロセスグローバルなTRTLCriticalSectionであるPDFiumLoadLockによって、この問題を解決しています。クリティカルセクションは確認後のロードシーケンスをアトミックにするため、2つのスレッドが同時にLoaded=Falseを検知して二重にバインドすることはなく、またあるスレッドがバインドを実行している間にもう1つのスレッドがDLLを解放することもできません。ロックはユニットのinitializationセクションで作成され、finalizationで破棄されます。PDFiumLoadLockReadyフラグによって保護されているため、シャットダウン中であっても安全です。キャンセル機能を含む完全なワーカー&リプライパターンが必要な場合は、キャンセル可能なフューチャーを使用したバックグラウンドレンダリングに関する解説記事で詳しく説明しています

uses
  PDFium, FPdfAsync;

// Worker method runs on a background thread. The first future to bind
// pdfium.dll is serialised by PDFiumLoadLock, so a second concurrent
// worker cannot double-bind or leak the module handle.
function TReportForm.RenderThumbnail(
  const AToken: IPdfCancellationToken): TBitmap;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.Active := True;         // safe concurrent bind
    AToken.ThrowIfCancelled;
    Result := Pdf.Thumbnail;    // this TPdf is owned by one thread only
  finally
    Pdf.Free;
  end;
end;

procedure TReportForm.StartRender;
begin
  TPdfFuture<TBitmap>.Run(RenderThumbnail, ThumbnailReady);
end;

procedure TReportForm.ThumbnailReady(
  const AResult: TPdfFutureResult<TBitmap>);
begin
  if AResult.IsSuccess then
    Preview.Picture.Assign(AResult.Value);
end;

ロードロックが保護しないもの

この境界線は明確に述べておく価値があります。なぜなら、この修正を過大評価しやすいからです。PDFiumLoadLockは、モジュールハンドルとFPDF_*関数ポインタの割り当てというグローバルなバインディング状態のみをシリアル化します。上流のヘッダーが宣言している通り、基盤となるPDFium C API自体は依然としてスレッドセーフではないため、FPDF_*関数を呼び出すことや、1つのFPDF_DOCUMENTをスレッド間で共有する場合は、開発者自身の責任でシリアル化する必要があります。安全な構成は上記の通りです。各ワーカーが独自のTPdfを所有し、他のスレッドにアクティブなドキュメントを渡さないようにします。この境界線とそれに関連するABIルールに関する詳細については、ABIおよびメモリ障害に対するPDFium VCLバインディングの堅牢化を参照してください

なぜfinalizationとFPDF_DestroyLibraryが重要なのか

シャットダウンパスには、さらに分かりにくい隙間がありました。PDFiumユニットにfinalizationセクションがなかったため、プロセス終了時にFPDF_DestroyLibraryが実行されませんでした。オペレーティングシステムはDLLメモリを回収したものの、PDFium独自のクリーンアップ(ワーカースレッドの結合、EnableV8Engine設定時のV8アイソレートの破棄、フォントとキャッシュの解体など)はスキップされていました。通常のレンダリングワークロードでは、これは終了時の静かなリークにすぎませんが、V8エンジンが有効な場合は実行するたびにJavaScriptアイソレートがリークし、自動化を繰り返すとすぐに問題が顕在化します

現在のfinalizationセクションはUnloadLibraryを呼び出します。これによりFPDF_DestroyLibraryが呼びされ、モジュールが解放されます。これがユニットスコープに存在するのには理由があります。TPdf.Destroyは現在のアクティブなドキュメントを閉じるためにActive := Falseを設定するだけで、グローバルライブラリは意図的にアンロードしないため、複数インスタンスのアプリケーションではプロセス全体の生存期間中、1つの共有PDFiumバインディングが維持されます。解体処理をfinalizationに配置することで、共有ライブラリはユニットがアンロードされるときに正確に一度だけ解放されます。また、UnloadLibraryは最初にLoadedフラグをチェックするため、PDFiumが一度も使用されなかった場合でも、この呼び出しは無害なno-opとなります

簡単な配置チェックリスト

現場でのロード失敗のほとんどは、3つの習慣によって防ぐことができます。DLLアーキテクチャをホストプロセスと一致させ、DLLs/Win32またはDLLs/Win64の下に配置すること。必要なエクスポートが不足しないように、pdfium.dllをバインディングバージョンと同期させておくこと。そして、バインド自体がアトミックになったとはいえ、スレッド間でTPdfやドキュメントハンドルを共有しないことです。DelphiとLazarusの両方に同じソースをビルドする場合、ターゲットをまたぐ配置で問題となるパッケージングの違いについて、DelphiとFPCのクロスコンパイラの落とし穴に関する注意書きで説明しています。ここで説明した診断、ロードロック、およびfinalizationのクリーンアップはすべて、DelphiおよびC++Builder用のPDFiumコンポーネントに同梱されています