Artykuł techniczny

Błąd pętli for w FPC: kliknięcie Anuluj nie zatrzymuje drukowania

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