技術記事

FPCのforループのバグ:キャンセルをクリックしても印刷が止まらない

PDFiumコンポーネントのビューアで印刷ジョブ中にキャンセルをクリックしても、時々何も起こらなかった:ループは残りのすべてのページとコピーをレンダリングし続け、そのジョブはそれでもプリンタに到達した。原因はFree Pascalのforループであり、これはループの入口で一度だけ上限を固定する。そのため、ループの途中でCopyCountToPageの背後にある変数をゼロにしても、すでに実行中の何も変わらなかった。このforループの上限バグは、他の4つのクロスコンパイラの落とし穴を生んだのと同じPDFiumPas Delphi/FPC互換性監査から出てきた5番目の落とし穴であり、その4つとは異なり完全にビューアの印刷ルーチンの内部に存在する——長いジョブの間キャンセルを押し続けたテスターが、プリンタが決して止まらなかったことに実際に気づくまでかかった

なぜキャンセルをクリックした後も印刷ジョブは続くのか

印刷ジョブが続いたのは、キャンセルのチェックがループの上限に供給される変数だけをリセットし、ループ自体をリセットしなかったからだ。Free Pascalは各ループが始まった時点ですでにそれを固定してしまっていた。PDFViewerとMultiPageViewerのデモにあるPrintボタンの背後のSpeedButtonPrintClickハンドラは、すべてのジョブを3つのネストしたループから構築する:丁合済みのコピーセットにわたる外側のループ、ページにわたる中間のループ、同じページの丁合なしコピーにわたる内側のループである。PrintDialog.Collateは、どちらのカウンタ(CollateCopyCountCopyCount)が実際に要求されたコピー数を保持するかを決め、もう一方は1のままである。キャンセルはこの3つのレベルすべてに同時に届かなければならず、このコードの最初のバージョンは、Cancelがtrueになった瞬間に上限変数自体をリセットすることでそれを試みていた

for CollateCopy:= 1 to CollateCopyCount do
  for Page:= FromPage to ToPage do
    for Copy:= 1 to CopyCount do
    begin
      // ... render the page and send it to the printer ...
      Application.ProcessMessages;
      if Cancel then
      begin
        CollateCopyCount:= 0;
        ToPage:= 0;
        CopyCount:= 0;
      end;
    end;
Printer.EndDoc;  // runs whether or not Cancel fired

その意図は十分明確に読める:残っている丁合数・ページ数・コピー数を定義するカウンタがすべてゼロに落ちれば、ループは仕事がなくなり自然に抜けるはずだ、というものだ。しかしPrinter.EndDocはその後、ループがどう終わったかにかかわらず無条件に実行されていた。そのため、ユーザーが止めたと信じたジョブでさえ、そのクリックに気づかれる前にレンダリングされたすべてのページとともに依然としてスプーラに提出されていた

forループが開始するときにFree Pascalが何を固定するか

Free Pascalはforループの最終値を、ループが始まる瞬間に正確に一度だけ評価し、そのループの生涯にわたって二度と評価しない。for Page := FromPage to ToPage doToPageを一度だけ読んで実行すべき反復回数を計算し、その後ループはもはやToPageという名前の変数には一切興味がなく、すでにキャプチャした反復回数にだけ興味がある。ループ本体の内部からToPage := 0を設定することは、実行中のループがもはや参照していない変数を変更することになる。これがまさに、プリンタがさらに数回、時には全回、ページを受け取り続ける間、Cancelがtrueでありうる理由である

このパターンはCやC++から持ち越すには全く妥当な習慣であり、PDFiumコンポーネントのC++Builderビューアデモは、比較を直接的にするのに十分なほどPascal版に近い形をしている。そのC++版のfor (Copy = 1; Copy <= CopyCount; Copy++)は、毎パスCopyCountの生きた値に対してCopy <= CopyCountを再テストする。そのため、そこでカウンタをゼロにすることは次のチェックで本当にループを終わらせる。C++Builderのデモは同一のカウンタをゼロにするコードを持っており、それは機能していた。これがまさに、同じ発想が、同じリポジトリの中でその隣に座っているPascalビルドで再利用しても安全に見えた理由である

なぜDelphiビルドは同じバグにぶつからなかったのか

DelphiのPDFViewerデモは、ループが変わった上限に気づくことに一度も依存していなかった。なぜなら、そのキャンセル経路は比較ではなく例外を通じて巻き戻るからだ。その最も内側のループは、Cancelがtrueになった瞬間にRTLのAbortプロシージャを呼び、これは3つのネストしたforループすべてを通じて印刷ブロック全体を包むハンドラへ直接伝播する静かなEAbortを発生させる

Printer.BeginDoc;
try
  for CollateCopy:= 1 to CollateCopyCount do
    for Page:= FromPage to ToPage do
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Abort;  // raises EAbort, unwinds all three loops at once
      end;
  Printer.EndDoc;
except
  on E: EAbort do
    Printer.Abort;
else
  begin
    Printer.Abort;
    raise;
  end;
end;

例外は、それが発生した地点とそれを捕まえるハンドラの間にいくつのforループが挟まっているかを気にしない。これはまさにこの問題が必要とする性質である。この頑健さは、上記で説明した上限固定の挙動に対する意図的な防御ではなかった——Delphiデモの作者は単に別のツールに手を伸ばしただけだった。それでもこの例外ベースの脱出はより頑丈なパターンとして名指しする価値がある:それは後から4つ目のネストレベルが追加されても生き残るが、手作業で配置されたBreak文の連鎖は、ループのネストが変わるたびに覚えておいて再追加しなければならない

その修正:PrintSucceededフラグでゲートされた、すべてのネストレベルでのBreak

PDFiumPas v2.27.0で出荷された修正は、LazarusとC++Builderのビューアデモにある3ループの構造は保つが、すべてのレベルでキャンセルを明示的にし、ループが停止することとジョブが提出されることを、ループが完全に終わった後にだけ読まれるフラグに分離する

Printer.BeginDoc;
try
  Cancel:= False;
  for CollateCopy:= 1 to CollateCopyCount do
  begin
    for Page:= FromPage to ToPage do
    begin
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Break;
      end;
      if Cancel then
        Break;
    end;
    if Cancel then
      Break;
  end;
  PrintSucceeded:= not Cancel;
finally
  if PrintSucceeded then
    Printer.EndDoc
  else
    Printer.Abort;
end;

PrintSucceededは、三重にネストしたループが抜けた直後に、not Cancel以外の何物でもないものから、意図的に一度だけ計算される。ループ本体の内部の何も、そのジョブが成功したかどうかを自分で決めることはない——ループは2つの方法でしか終われない:丁合・ページ・コピーが尽きるか、Cancelが引き起こすBreakの連鎖にぶつかるかである。そしてPrintSucceededは、ループが進むにつれて追跡するのではなく、事後にその結果を読む。そのように計算することこそが、finallyブロックのPrinter.EndDocPrinter.Abortの間の選択を信頼できるものにする:それはループが実際に落ち着く前には決して発火しない

自分自身のキャンセル駆動の印刷ループを監査する

3つのチェックがこの1つの印刷ルーチンをはるかに超えて広く適用できる。Pascalのforループが開始後に変わる上限変数に気づくと決して仮定しないこと;ループを早期に終わらせる必要があるなら、キャンセルが越える必要のあるすべてのネストレベルで、最も内側だけでなく、Breakで直接そう述べること。ネストが十分に深く見逃されたBreakがありうる場合には、例外のような構造上巻き戻る脱出機構を優先すること——DelphiデモのAbort/EAbortのペアはこれを無償で得ていた。EndDocのようなコミットステップは、ループの内部ではなく厳密にその後で計算されるフラグの背後にゲートすること。そうすれば早期に停止したジョブが、完了したジョブと決して取り違えられることはない

このループを修正したのと同じレビューは、キャンセルボタンが表示されている間9つのビューアデモがキーボードをどう扱うかも引き締めた:それらは今やEsc以外のすべてのキーを飲み込むため、アクティブな印刷や検索の最中に紛れ込んだCtrl+PやCtrl+Fが、その上にもう1つを起動することはもうない。この再入可能性の修正は異なる仕組みを持つ別のバグだが、それはCancelが操作の途中で実際に何をするかについての同じレビューから生まれた。両方の修正から持ち帰る価値のある習慣は、コーディングの習慣というよりテストの習慣である:共有された印刷ルーチンが実際に出荷されるすべてのコンパイラでキャンセルを演習すること。なぜなら、カウンタをクリアしてループが気づくのを信じるという発想は、C++Builderの下でテストを生き延び、検証されないままFree Pascalビルドに届き、ソース上は同一に読め、誰かがキャンセルを十分長く押し続けてジョブがそれでも完了するのを見るまで誰も気づかない方法で失敗しうるからだ。このキャンセルロジックが上に乗っている印刷のセットアップについては、PDFium VCLコンポーネントによるPDF文書の印刷の解説が残りを扱っている

この印刷キャンセルの修正は、Delphi、C++Builder、FPC/Lazarus向けPDFiumコンポーネントの一部として出荷された。このライブラリはリリースのたびに同じデモスイートを3つのコンパイラすべてにわたって実行しているため、このようなギャップはサポートチケットではなくビルドマトリクスによって捕まえられる