Техническая статья

Ошибка цикла for в FPC: клик по «Отмена» не останавливает печать

Клик по «Отмена» во время задания печати в просмотрщике компонента PDFium иногда не давал никакого эффекта: цикл продолжал отрисовывать каждую оставшуюся страницу и копию, и задание всё равно доходило до принтера. Причиной был цикл for в Free Pascal, фиксирующий свою верхнюю границу один раз при входе в цикл, так что обнуление переменной за CopyCount или ToPage посреди цикла ничего не меняло в уже выполняющемся. Ошибка границ цикла for — пятый подводный камень, вышедший из того же аудита совместимости PDFiumPas с Delphi/FPC, что произвёл четыре других подвоха кросс-компиляции, и, в отличие от тех четырёх, живёт целиком внутри процедуры печати просмотрщика — потребовался тестировщик, удерживающий «Отмена» на протяжении долгого задания, чтобы реально заметить, что принтер так и не остановился

Почему задание печати продолжается после клика по «Отмена»?

Задание печати продолжалось потому, что проверка отмены сбрасывала только переменные, питающие границы цикла, а не сами циклы, которые Free Pascal уже зафиксировал в момент запуска каждого из них. Обработчик SpeedButtonPrintClick за кнопкой печати в демонстрационных примерах PDFViewer и MultiPageViewer строит каждое задание из трёх вложенных циклов: внешнего цикла по сброшюрованным наборам копий, среднего цикла по страницам и внутреннего цикла по несброшюрованным копиям одной и той же страницы. PrintDialog.Collate решает, какой счётчик — CollateCopyCount или CopyCount — реально держит запрошенное число копий, тогда как другой остаётся равным единице. Отмене нужно дотянуться сквозь все три уровня сразу, и первая версия этого кода пыталась сделать это, сбрасывая сами переменные границ в момент, когда Cancel становился истинным

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 затем выполнялся безусловно после цикла независимо от того, как он завершился, так что даже задание, которое пользователь считал остановленным, всё равно отправлялось в диспетчер печати со всеми страницами, отрисованными до того, как клик был замечен

Что фиксирует Free Pascal при запуске цикла for

Free Pascal вычисляет конечное значение цикла for ровно один раз, в момент начала цикла, и никогда больше на протяжении жизни этого цикла. for Page := FromPage to ToPage do читает ToPage единственный раз, чтобы вычислить, сколько итераций выполнить, и после этого цикл больше не проявляет интереса к переменной по имени ToPage, только к числу итераций, которое уже захватил. Установка ToPage := 0 изнутри тела цикла меняет переменную, к которой выполняющийся цикл больше не обращается, и именно поэтому Cancel мог быть истинным, пока принтер продолжал получать страницы ещё на несколько итераций, а иногда и на все оставшиеся

Этот паттерн — совершенно разумная привычка, переносимая из C или C++, и демонстрационный просмотрщик C++Builder для компонента PDFium зеркалит демо на Pascal достаточно близко, чтобы сделать сравнение прямым. Его for (Copy = 1; Copy <= CopyCount; Copy++) заново проверяет Copy <= CopyCount относительно живого значения CopyCount на каждом проходе, так что обнуление счётчика там действительно завершает цикл на следующей проверке. Демо для C++Builder несло идентичный код обнуления счётчиков, и он работал, — именно это и заставило ту же идею выглядеть безопасной для переиспользования в сборке на Pascal, лежащей прямо рядом в том же репозитории

Почему сборка на Delphi не наткнулась на ту же ошибку?

Демо PDFViewer на Delphi никогда не зависело от того, заметит ли цикл изменённую границу, потому что его путь отмены разворачивается через исключение, а не через сравнение. Его самый внутренний цикл вызывает процедуру RTL Abort в момент, когда Cancel становится истинным, что выбрасывает тихий 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. Ничто внутри тела цикла само по себе не решает, удалось ли задание, — цикл может завершиться только двумя способами: исчерпав сброшюровки, страницы и копии, либо натолкнувшись на цепочку Break, которую запускает Cancel, — и PrintSucceeded считывает итог постфактум, а не отслеживает его по ходу цикла. Вычисление этого таким образом и делает надёжным выбор блока finally между Printer.EndDoc и Printer.Abort: он никогда не срабатывает до того, как цикл реально устоялся

Аудит собственных циклов печати, управляемых отменой

Три проверки применимы далеко за пределами этой одной процедуры печати. Никогда не предполагайте, что цикл for на Pascal заметит изменение переменной границы после того, как он уже запустился; если циклу нужно закончиться досрочно, скажите это прямо через Break, на каждом уровне вложенности, который должна пересечь отмена, а не только на самом внутреннем. Предпочитайте механизм выхода, разворачивающийся по конструкции, такой как исключение, как только вложенность становится достаточно глубокой, чтобы пропущенный Break был правдоподобен, — пара Abort/EAbort в демо на Delphi получила это бесплатно. Заграждайте любой шаг фиксации вроде EndDoc флагом, вычисляемым строго после цикла, никогда внутри него, чтобы задание, остановленное досрочно, никогда нельзя было принять за завершившееся

То же ревью, что исправило этот цикл, также ужесточило то, как девять демонстрационных просмотрщиков обрабатывают клавиатуру, пока видна кнопка отмены: теперь они проглатывают каждую клавишу, кроме Esc, так что случайный Ctrl+P или Ctrl+F во время активной печати или поиска больше не может запустить второй поверх него. Это исправление реентерабельности — другая ошибка с другим механизмом, но она вышла из того же пересмотра того, что Cancel реально делает посреди операции. Привычка, которую стоит вынести из обоих исправлений, — скорее привычка тестирования, чем написания кода: проверяйте отмену на каждом компиляторе, для которого реально поставляется общая процедура печати, потому что идея вроде обнуления счётчиков с расчётом, что цикл это заметит, способна пережить тестирование под C++Builder, дойти до сборки на Free Pascal непроверенной, читаться идентично в исходнике и провалиться так, что никто не увидит, пока кто-то не удержит «Отмена» достаточно долго, чтобы посмотреть, как задание всё равно завершится. Об остальной настройке печати, на которую опирается эта логика отмены, рассказывает руководство по печати документов PDF с компонентом PDFium VCL

Это исправление отмены печати поставляется как часть компонента PDFium для Delphi, C++Builder и FPC/Lazarus, который прогоняет один и тот же набор демо-примеров на всех трёх компиляторах при каждом релизе, так что подобные пробелы отлавливаются матрицей сборки, а не тикетом поддержки