Technický článek

Chyba smyčky for ve FPC: klik na Zrušit tisk nezastaví

Kliknutí na Zrušit během tiskové úlohy v prohlížeči PDFium Component občas neudělalo nic: smyčka pokračovala ve vykreslování každé zbývající stránky a kopie a úloha se přesto dostala na tiskárnu. Příčinou byla smyčka for ve Free Pascalu, která pevně stanoví svou horní mez jednou, na vstupu do smyčky, takže vynulování proměnné za CopyCount nebo ToPage uprostřed smyčky nezměnilo nic, co už běželo. Chyba mezí smyčky for je páté úskalí, které vzešlo ze stejného auditu kompatibility Delphi/FPC v PDFiumPas, který vyprodukoval čtyři další záludnosti napříč kompilátory, a na rozdíl od nich žije celé uvnitř tiskové rutiny prohlížeče — trvalo testerovi, který držel Zrušit po celou dlouhou úlohu, aby si skutečně všiml, že se tiskárna nikdy nezastavila

Proč tisková úloha pokračuje i po kliknutí na Zrušit?

Tisková úloha pokračovala, protože kontrola zrušení resetovala jen proměnné, které živily meze smyčky, ne samotné smyčky, které si Free Pascal už uzamkl v okamžiku, kdy každá smyčka začala. Obsluha SpeedButtonPrintClick za tlačítkem Tisk v demech PDFViewer a MultiPageViewer staví každou úlohu ze tří vnořených smyček: vnější smyčka přes sady sešitých kopií, prostřední smyčka přes stránky, a vnitřní smyčka přes nesešité kopie stejné stránky. PrintDialog.Collate rozhoduje, které počítadlo, CollateCopyCount nebo CopyCount, skutečně drží požadovaný počet kopií, zatímco druhé zůstává na jedné. Zrušení musí zasáhnout skrz všechny tři úrovně najednou, a první verze tohoto kódu se o to snažila resetováním samotných mezních proměnných v okamžiku, kdy se Cancel stal 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

Záměr čte dost jasně: pokud počítadla, která definují, kolik sešitých sad, stránek a kopií zbývá, všechna spadnou na nulu, smyčkám by měla dojít práce a měly by samy propadnout dál. Printer.EndDoc pak běžel bezpodmínečně po smyčce bez ohledu na to, jak skončila, takže i úloha, o které si uživatel myslel, že ji zastavil, se přesto odeslala do spooleru s každou stránkou vykreslenou dřív, než si kliknutí všiml

Co si Free Pascal zamkne, když smyčka for začne

Free Pascal vyhodnotí konečnou hodnotu smyčky for přesně jednou, v okamžiku, kdy smyčka začíná, a nikdy víc po celou dobu jejího života. for Page := FromPage to ToPage do přečte ToPage jedinkrát, aby spočítal, kolik iterací spustit, a poté už smyčka nemá žádný další zájem o proměnnou jménem ToPage, jen o počet iterací, který si už zachytila. Nastavení ToPage := 0 zevnitř těla smyčky mění proměnnou, kterou už běžící smyčka nekonzultuje, což je přesně důvod, proč mohl být Cancel true, zatímco tiskárna pokračovala v přijímání stránek po dalších několik iterací, někdy všechny z nich

Tento vzor je naprosto rozumný zvyk přenesený z C nebo C++, a demo prohlížeče PDFium Component pro C++Builder zrcadlí to pascalové dost blízko na to, aby bylo srovnání přímé. Jeho for (Copy = 1; Copy <= CopyCount; Copy++) znovu testuje Copy <= CopyCount proti živé hodnotě CopyCount při každém průchodu, takže vynulování počítadla tam skutečně ukončí smyčku na dalším kontrolním bodě. Demo pro C++Builder neslo identický kód „vynuluj počítadla" a fungovalo, což je přesně to, co udělalo stejnou myšlenku bezpečnou na znovupoužití v sestavení Pascalu sedícím hned vedle ve stejném repozitáři

Proč sestavení pro Delphi na stejnou chybu nenarazilo?

Demo PDFViewer pro Delphi nikdy nezáviselo na tom, že si smyčka všimne změněné meze, protože jeho cesta zrušení se odvíjí přes výjimku místo přes porovnání. Jeho nejvnitřnější smyčka volá proceduru RTL Abort v okamžiku, kdy se Cancel stane true, což vyvolá tichou EAbort, jež propaguje přímo skrz všechny tři vnořené smyčky for až k obsluze obalené kolem celého tiskového bloku

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;

Výjimce je jedno, kolik smyček for odděluje místo, kde je vyvolána, od obsluhy, která ji zachytí, což je přesně vlastnost, kterou tento problém potřebuje. Tato robustnost nebyla záměrná obrana proti chování zamykání mezí popsanému výše — autor dema pro Delphi prostě sáhl po jiném nástroji. Únik založený na výjimce přesto stojí za pojmenování jako pevnější vzor: přežije, když se později přidá čtvrtá úroveň vnoření, zatímco řetěz ručně umístěných příkazů Break se musí pamatovat a znovu přidávat pokaždé, když se vnoření smyčky změní

Oprava: Break na každé úrovni vnoření, hlídané příznakem PrintSucceeded

Oprava, která se dodala v PDFiumPas v2.27.0, ponechává třísmyčkovou strukturu v demech prohlížeče pro Lazarus a C++Builder, ale dělá zrušení explicitní na každé úrovni, a odděluje zastavení smyčky od odeslání úlohy do příznaku, který se čte až po tom, co smyčka úplně dokončila

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 se záměrně spočítá jednou, okamžitě po výstupu z trojnásobně vnořené smyčky, z ničeho víc než not Cancel. Nic uvnitř těla smyčky se samo nerozhoduje, zda úloha uspěla — smyčka může skončit jen dvěma způsoby, dojde ji práce se sešitými sadami, stránkami a kopiemi, nebo narazí na řetěz Break, který spouští Cancel, a PrintSucceeded čte výsledek dodatečně, místo aby jej sledoval za pochodu smyčky. Spočítání tímto způsobem je to, co dělá výběr bloku finally mezi Printer.EndDoc a Printer.Abort důvěryhodným: nikdy se nespustí dřív, než se smyčka skutečně usadila

Audit vlastních tiskových smyček řízených zrušením

Tři kontroly cestují daleko za tuto jednu tiskovou rutinu. Nikdy nepředpokládejte, že si smyčka for v Pascalu všimne měnící se mezní proměnné poté, co začala; pokud smyčka potřebuje skončit dřív, řekněte to přímo přes Break, na každé úrovni vnoření, kterou zrušení potřebuje přejít, ne jen na té nejvnitřnější. Upřednostněte únikový mechanismus, který se odvíjí konstrukčně, jako výjimka, jakmile je vnoření dost hluboké na to, aby byl zmeškaný Break pravděpodobný — pár Abort/EAbort dema pro Delphi to dostal zadarmo. Podmiňte jakýkoli krok potvrzení jako EndDoc příznakem spočítaným striktně po smyčce, nikdy uvnitř ní, takže úloha, která skončila předčasně, nemůže být nikdy zaměněna za dokončenou

Stejná revize, která opravila tuto smyčku, také zpřísnila, jak devět dem prohlížeče zpracovává klávesnici, zatímco je viditelné tlačítko zrušení: teď pohltí každou klávesu kromě Esc, takže zbloudilé Ctrl+P nebo Ctrl+F během aktivního tisku nebo hledání už nemůže nad tím spustit druhou instanci. Tato oprava reentrance je jiná chyba s jiným mechanismem, ale vzešla ze stejné revize toho, co Cancel vlastně dělá uprostřed operace. Zvyk, který stojí za odnesení z obou oprav, je spíš testovací zvyk než kódovací: procvičte zrušení na každém kompilátoru, na který sdílená tisková rutina skutečně jde, protože myšlenka jako vyčištění počítadel a spoléhání na to, že si toho smyčka všimne, dokáže přežít testování pod C++Builderem, dosáhnout sestavení Free Pascalu neověřená, číst se ve zdroji identicky, a selhat způsobem, který nikdo neuvidí, dokud někdo nepodrží Zrušit dost dlouho, aby sledoval, jak úloha přesto doběhne. Pro tiskové nastavení, na kterém tato logika zrušení sedí, popisuje zbytek průvodce tiskem dokumentů PDF s komponentou PDFium VCL

Tato oprava zrušení tisku se dodala jako součást komponenty PDFium pro Delphi, C++Builder a FPC/Lazarus, která při každém vydání spouští stejnou sadu dem napříč všemi třemi kompilátory, aby podobné mezery zachytila spíš buildová matice než lístek podpory