技术文章

FPC for 循环缺陷:点击 Cancel 无法停止打印

在 PDFium Component 查看器执行打印任务时,点击 Cancel 有时毫无作用:循环会继续渲染所有剩余页面和副本,任务最终仍会送到打印机。根因在于 Free Pascal 的 for 循环会在进入循环时固定上限,因此在循环中途将 CopyCountToPage 背后的变量清零,并不会影响已经开始执行的循环。这个 for 循环边界缺陷,是同一次 PDFiumPas Delphi/FPC 兼容性审查发现的第五个陷阱,另有四个跨编译器陷阱;它与前四个不同,完全位于查看器的打印例程内部,直到测试人员在一次长时间打印任务中持续按住 Cancel,才注意到打印机实际上从未停止

为什么点击 Cancel 后打印任务仍会继续

打印任务之所以继续,是因为取消检查只重置了提供循环边界的变量,并没有停止循环本身;而 Free Pascal 在每个循环开始时就已经锁定了这些边界。PDFViewer 和 MultiPageViewer 示例中 Print 按钮背后的 SpeedButtonPrintClick 处理程序,用三个嵌套循环构建每个打印任务:最外层遍历已整理的副本组,中间层遍历页面,最内层遍历同一页面的未整理副本。PrintDialog.Collate 决定 CollateCopyCountCopyCount 哪个计数器实际保存请求的副本数,另一个计数器保持为 1。取消操作必须同时穿过这三层,而这段代码的第一版试图在 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 都会无条件执行,因此即使用户以为已经停止的任务,仍会在 Cancel 被发现之前渲染的所有页面后提交给后台打印程序

Free Pascal 在 for 循环开始时会锁定什么

Free Pascal 会在 for 循环开始的瞬间精确计算一次终值,并且在该循环的整个生命周期内不再计算。for Page := FromPage to ToPage do 只读取一次 ToPage 来计算迭代次数,之后循环不再关心名为 ToPage 的变量,只使用已经捕获的迭代次数。在循环体内设置 ToPage := 0,改变的是运行中的循环已经不再查询的变量,这正是 Cancel 已经为 true 而打印机仍收到后续多个页面的原因,有时甚至会收到全部页面

这种模式从 C 或 C++ 延续过来完全合理,而 PDFium Component 的 C++Builder 查看器示例与 Pascal 示例足够相似,正好可以直接比较。它的 for (Copy = 1; Copy <= CopyCount; Copy++) 每次迭代都会将 Copy <= CopyCountCopyCount 的当前值重新比较,因此在那里将计数器清零确实会让循环在下一次检查时结束。C++Builder 示例使用了完全相同的清零计数器代码,而且确实能够工作,这正是同一思路看起来可以安全复用于旁边 Pascal 构建的原因

为什么 Delphi 构建没有遇到同一个缺陷

Delphi PDFViewer 示例从未依赖循环去发现边界发生变化,因为它的取消路径通过异常展开,而不是通过比较。最内层循环在 Cancel 变为 true 的瞬间调用 RTL 的 Abort 过程;这会引发一个静默的 EAbort,直接穿过三层嵌套的 for 循环,抵达包围整个打印代码块的处理程序

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 示例的作者只是选择了另一种工具。不过,基于异常的退出仍值得作为更稳妥的模式明确提出:以后即使增加第四层嵌套,它仍然有效;而一串手动放置的 Break 语句,则必须在每次改变循环嵌套时记得补齐

修复方式:在每一层嵌套中使用 Break,并由 PrintSucceeded 标记控制

PDFiumPas v2.27.0 发布的修复保留了 Lazarus 和 C++Builder 查看器示例中的三层循环结构,但在每一层都显式处理取消,并用一个只有在循环完全结束后才读取的标志,将停止循环与提交打印任务分开

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。循环体中的任何代码都不能自行决定任务是否成功——循环只有两种结束方式:整理组、页面和副本全部耗尽,或遇到由 Cancel 触发的 Break 链,而 PrintSucceeded 会在事后读取结果,而不是随着循环持续跟踪。这样计算,finally 代码块在 Printer.EndDocPrinter.Abort 之间做出的选择才可信,因为它不会在循环真正稳定下来之前触发

审查自己的取消驱动型打印循环

有三项检查可以推广到这一个打印例程之外。不要假设 Pascal 的 for 循环在启动后会注意到边界变量发生变化;如果循环需要提前结束,就应在取消必须穿过的每一层嵌套中直接使用 Break,而不只是放在最内层。嵌套足够深、漏掉某个 Break 的可能性足够高时,优先使用异常这类天然能够展开的退出机制——Delphi 示例的 Abort/EAbort 组合无需额外操作就具备这一点。任何类似 EndDoc 的提交步骤,都应由循环结束后严格计算的标志控制,绝不要在循环内部决定,这样提前停止的任务就不会被误认为已经完成

修复这个循环的同一次审查还收紧了九个查看器示例在取消按钮可见时的键盘处理:现在除 Esc 外的所有按键都会被吞掉,因此在打印或搜索进行期间,偶然按下 Ctrl+PCtrl+F 都不会再在当前操作之上启动第二个操作。这项可重入性修复的缺陷和机制都不同,但同样源于对 Cancel 在操作中途实际行为的审查。从这两项修复中真正值得带走的更多是一种测试习惯,而不是编码技巧:共享打印例程实际发布到哪些编译器,就要在每个编译器上测试取消,因为清零计数器并相信循环会察觉这一想法,可能在 C++Builder 下通过测试,却未经验证地进入 Free Pascal 构建;它在源代码中看起来完全相同,最终却可能直到有人长时间按住 Cancel、眼看任务仍然完成时才暴露。关于这套取消逻辑所依托的打印设置,使用 PDFium VCL 组件打印 PDF 文档的教程介绍了其余内容

本次打印取消修复随 PDFium Component 一同提供,支持 Delphi、C++Builder 和 FPC/Lazarus;每次发布都会让三个编译器运行同一套示例,因此这类缺口会由构建矩阵发现,而不是等到支持工单出现