Kliknięcie Anuluj podczas zadania drukowania w przeglądarce komponentu PDFium czasami nic nie robiło: pętla wciąż renderowała każdą pozostałą stronę i kopię, a zadanie i tak trafiało do drukarki. Przyczyną była pętla for Free Pascala, która ustala swoją górną granicę raz przy wejściu do pętli, więc wyzerowanie zmiennej stojącej za CopyCount czy ToPage w połowie pętli nie zmieniało niczego, co już się wykonywało. Błąd granic pętli for to piąta pułapka, jaka wyszła z tego samego audytu zgodności PDFiumPas Delphi/FPC, który wyprodukował cztery inne pułapki międzykompilatorowe, a w przeciwieństwie do tamtych czterech mieszka on w całości wewnątrz procedury drukowania przeglądarki — potrzeba było testera trzymającego Anuluj przez cały długi wydruk, żeby faktycznie zauważyć, że drukarka nigdy się nie zatrzymała
Dlaczego zadanie drukowania trwa dalej po kliknięciu Anuluj?
Zadanie drukowania trwało dalej, ponieważ sprawdzenie anulowania resetowało tylko zmienne zasilające granice pętli, nie same pętle, które Free Pascal już zamknął w chwili rozpoczęcia każdej z nich. Handler SpeedButtonPrintClick stojący za przyciskiem Drukuj w demach PDFViewer i MultiPageViewer buduje każde zadanie z trzech zagnieżdżonych pętli: zewnętrznej pętli po zestawach kopii zbieranych, środkowej pętli po stronach i wewnętrznej pętli po niezbieranych kopiach tej samej strony. PrintDialog.Collate decyduje, który licznik, CollateCopyCount czy CopyCount, faktycznie trzyma żądaną liczbę kopii, podczas gdy drugi pozostaje na jedynce. Anulowanie musi dotrzeć przez wszystkie trzy poziomy naraz, a pierwsza wersja tego kodu próbowała to zrobić, resetując same zmienne graniczne w chwili, gdy Cancel stawał się 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
Intencja czyta się wystarczająco jasno: jeśli liczniki definiujące, ile pozostało zbierań, stron i kopii, wszystkie spadną do zera, pętle powinny wyczerpać pracę i wypaść same z siebie. Printer.EndDoc uruchamiał się wtedy bezwarunkowo po pętli niezależnie od tego, jak się zakończyła, więc nawet zadanie, które użytkownik uważał za zatrzymane, i tak trafiało do spoolera z każdą stroną wyrenderowaną, zanim kliknięcie zostało zauważone
Co Free Pascal zamyka na stałe, gdy zaczyna się pętla for
Free Pascal oblicza wartość końcową pętli for dokładnie raz, w chwili rozpoczęcia pętli, i nigdy więcej przez cały czas jej życia. for Page := FromPage to ToPage do odczytuje ToPage jednorazowo, aby obliczyć, ile iteracji uruchomić, a potem pętla nie ma już żadnego dalszego zainteresowania zmienną o nazwie ToPage, tylko liczbą iteracji, którą już przechwyciła. Ustawienie ToPage := 0 wewnątrz ciała pętli zmienia zmienną, z którą działająca pętla już się nie konsultuje, co jest dokładnie powodem, dla którego Cancel mógł być true, podczas gdy drukarka wciąż otrzymywała strony przez kilka kolejnych iteracji, czasem wszystkie z nich
Ten wzorzec to całkowicie rozsądny nawyk przeniesiony z C lub C++, a demo przeglądarki C++Builder komponentu PDFium odzwierciedla to demo Pascala na tyle blisko, by porównanie było bezpośrednie. Jego for (Copy = 1; Copy <= CopyCount; Copy++) ponownie sprawdza Copy <= CopyCount względem żywej wartości CopyCount przy każdym przebiegu, więc wyzerowanie licznika tam naprawdę kończy pętlę przy następnym sprawdzeniu. Demo C++Builder niosło identyczny kod zerowania liczników i działał, co jest dokładnie tym, co sprawiło, że ten sam pomysł wyglądał na bezpieczny do ponownego użycia w buildzie Pascala siedzącym tuż obok w tym samym repozytorium
Dlaczego build Delphi nie trafił na ten sam błąd?
Demo PDFViewer w Delphi nigdy nie zależało od tego, czy pętla zauważy zmienioną granicę, ponieważ jego ścieżka anulowania odwija się przez wyjątek zamiast przez porównanie. Jego najgłębsza pętla wywołuje procedurę RTL Abort w chwili, gdy Cancel stanie się true, co zgłasza cichy EAbort, który propaguje wprost przez wszystkie trzy zagnieżdżone pętle for do handlera opakowanego wokół całego bloku drukowania
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;
Wyjątek nie obchodzi, ile pętli for oddziela miejsce, w którym jest zgłoszony, od handlera, który go przechwytuje, co jest dokładnie właściwością, jakiej potrzebuje ten problem. Ta odporność nie była celową obroną przed opisanym powyżej zachowaniem zamykania granicy — autor dema w Delphi po prostu sięgnął po inne narzędzie. Ucieczka oparta na wyjątkach wciąż jest warta nazwania jako solidniejszy wzorzec: przetrwa dodanie czwartego poziomu zagnieżdżenia później, podczas gdy łańcuch ręcznie umieszczonych instrukcji Break trzeba pamiętać i dodawać ponownie za każdym razem, gdy zmienia się zagnieżdżenie pętli
Poprawka: Break na każdym poziomie zagnieżdżenia, bramkowany flagą PrintSucceeded
Poprawka wydana w PDFiumPas v2.27.0 zachowuje strukturę trzech pętli w demach przeglądarki Lazarus i C++Builder, ale czyni anulowanie jawnym na każdym poziomie, i oddziela zatrzymanie pętli od wysłania zadania do flagi, która jest odczytywana dopiero po całkowitym zakończeniu pętli
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 jest celowo obliczana raz, natychmiast po wyjściu z potrójnie zagnieżdżonej pętli, z niczego więcej niż not Cancel. Nic wewnątrz ciała pętli samo nie decyduje, czy zadanie się powiodło — pętla może zakończyć się tylko na dwa sposoby, wyczerpaniem zbierań, stron i kopii, albo trafieniem na łańcuch Break, który wyzwala Cancel, a PrintSucceeded odczytuje wynik po fakcie, zamiast śledzić go w miarę postępu pętli. Obliczenie tego w ten sposób jest tym, co czyni wybór bloku finally między Printer.EndDoc a Printer.Abort godnym zaufania: nigdy nie uruchamia się, zanim pętla faktycznie się nie ustali
Audytowanie własnych pętli drukowania sterowanych anulowaniem
Trzy sprawdzenia sięgają daleko poza tę jedną procedurę drukowania. Nigdy nie zakładaj, że pętla for w Pascalu zauważy zmianę zmiennej granicznej po rozpoczęciu; jeśli pętla musi zakończyć się wcześniej, powiedz to bezpośrednio za pomocą Break, na każdym poziomie zagnieżdżenia, który anulowanie musi przekroczyć, nie tylko na najgłębszym. Preferuj mechanizm ucieczki, który odwija się z konstrukcji, taki jak wyjątek, gdy tylko zagnieżdżenie jest na tyle głębokie, że pominięty Break jest prawdopodobny — para Abort/EAbort w demie Delphi dostała to za darmo. Bramkuj każdy krok zatwierdzający, taki jak EndDoc, flagą obliczaną ściśle po pętli, nigdy w jej wnętrzu, tak aby zadanie, które zatrzymało się wcześnie, nigdy nie mogło zostać pomylone z takim, które się zakończyło
Ten sam przegląd, który naprawił tę pętlę, zaostrzył też sposób, w jaki dziewięć dem przeglądarki obsługuje klawiaturę, gdy widoczny jest przycisk anulowania: teraz połykają każdy klawisz oprócz Esc, więc przypadkowy Ctrl+P czy Ctrl+F podczas aktywnego drukowania lub wyszukiwania nie może już uruchomić drugiego na wierzchu tego pierwszego. Ta poprawka reentrancji to inny błąd o innym mechanizmie, ale wyszedł z tego samego przeglądu tego, co Cancel faktycznie robi w połowie operacji. Nawyk wart wyniesienia z obu poprawek to bardziej nawyk testowania niż kodowania: ćwicz anulowanie na każdym kompilatorze, do którego faktycznie trafia współdzielona procedura drukowania, ponieważ pomysł taki jak czyszczenie liczników i zaufanie, że pętla to zauważy, może przetrwać testowanie pod C++Builder, dotrzeć do buildu Free Pascal niezweryfikowany, czytać się identycznie w źródle, i zawieść w sposób, którego nikt nie widzi, dopóki ktoś nie potrzyma Anuluj wystarczająco długo, żeby zobaczyć, że zadanie i tak się kończy. Co do ustawień drukowania, na których siedzi ta logika anulowania, przewodnik po drukowaniu dokumentów PDF za pomocą komponentu VCL PDFium omawia resztę
Ta poprawka anulowania druku została wydana jako część komponentu PDFium dla Delphi, C++Buildera i FPC/Lazarus, który uruchamia ten sam zestaw dem na wszystkich trzech kompilatorach przy każdym wydaniu, tak aby luki takie jak ta były wychwytywane przez macierz buildów, a nie zgłoszenie do wsparcia technicznego