Клик по «Отмена» во время задания печати в просмотрщике компонента 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, который прогоняет один и тот же набор демо-примеров на всех трёх компиляторах при каждом релизе, так что подобные пробелы отлавливаются матрицей сборки, а не тикетом поддержки