Technisch artikel

FPC for-lus-bug: op Annuleren klikken stopt het afdrukken niet

Op Annuleren klikken tijdens een afdruktaak in een PDFium Component-viewer deed soms niets: de lus bleef elke resterende pagina en kopie renderen, en de taak bereikte de printer alsnog. De oorzaak was de for-lus van Free Pascal, die zijn bovengrens één keer vastzet bij binnenkomst van de lus, dus het op nul zetten van de variabele achter CopyCount of ToPage halverwege de lus veranderde niets aan wat al liep. De for-lus-grenzenbug is de vijfde valkuil die uit dezelfde PDFiumPas Delphi/FPC-compatibiliteitsaudit kwam die vier andere cross-compiler-valkuilen opleverde, en anders dan die vier zit deze volledig binnen de afdrukroutine van een viewer — het kostte een tester die tijdens een lange taak Annuleren ingedrukt hield om daadwerkelijk op te merken dat de printer nooit stopte

Waarom blijft de afdruktaak doorgaan nadat op Annuleren is geklikt?

De afdruktaak bleef doorgaan omdat de annuleringscontrole alleen de variabelen resette die de lusgrenzen voedden, niet de lussen zelf, die Free Pascal al had vastgezet zodra elke lus begon. De handler SpeedButtonPrintClick achter de knop Afdrukken in de demo's PDFViewer en MultiPageViewer bouwt elke taak op uit drie geneste lussen: een buitenste lus over gecollateerde kopiesets, een middelste lus over pagina's, en een binnenste lus over ongecollateerde kopieën van dezelfde pagina. PrintDialog.Collate bepaalt welke teller, CollateCopyCount of CopyCount, daadwerkelijk het gevraagde aantal kopieën bevat, terwijl de andere op één blijft staan. Annuleren moet alle drie niveaus tegelijk bereiken, en de eerste versie van deze code probeerde dat te doen door de grensvariabelen zelf te resetten zodra Cancel waar werd

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

De bedoeling leest duidelijk genoeg: als de tellers die bepalen hoeveel collaties, pagina's, en kopieën er nog resten allemaal naar nul zakken, zouden de lussen zonder werk moeten komen te zitten en er vanzelf uit moeten vallen. Printer.EndDoc draaide vervolgens onvoorwaardelijk na de lus, ongeacht hoe deze eindigde, dus zelfs een taak waarvan een gebruiker dacht deze te hebben gestopt, werd toch bij de spooler ingediend met elke pagina al gerenderd voordat de klik werd opgemerkt

Wat Free Pascal vastzet wanneer een for-lus start

Free Pascal evalueert de eindwaarde van een for-lus precies één keer, op het moment dat de lus begint, en nooit meer daarna voor de levensduur van die lus. for Page := FromPage to ToPage do leest ToPage één enkele keer om te berekenen hoeveel iteraties er moeten draaien, en daarna heeft de lus geen verdere interesse meer in een variabele genaamd ToPage, alleen in het aantal iteraties dat het al heeft vastgelegd. ToPage := 0 instellen van binnen de lusbody verandert een variabele die de lopende lus niet meer raadpleegt, en dat is precies waarom Cancel waar kon zijn terwijl de printer nog verschillende iteraties lang pagina's bleef ontvangen, soms allemaal

Dat patroon is een volkomen redelijke gewoonte om over te nemen uit C of C++, en de C++Builder-viewerdemo van PDFium Component weerspiegelt de Pascal-versie nauw genoeg om de vergelijking rechtstreeks te maken. Zijn for (Copy = 1; Copy <= CopyCount; Copy++) test Copy <= CopyCount bij elke doorgang opnieuw tegen de levende waarde van CopyCount, dus de teller daar op nul zetten beëindigt de lus werkelijk bij de volgende controle. De C++Builder-demo droeg dezelfde nul-de-tellers-code en het werkte, en dat is precies wat hetzelfde idee veilig deed lijken om te hergebruiken in de Pascal-build die er in dezelfde repository vlak naast lag

Waarom liep de Delphi-build niet tegen dezelfde bug aan?

De Delphi PDFViewer-demo was nooit afhankelijk van een lus die een veranderde grens opmerkt, omdat het annuleringspad afwikkelt via een uitzondering in plaats van een vergelijking. De meest binnenste lus roept de Abort-procedure van de RTL aan zodra Cancel waar wordt, wat een stille EAbort opwerpt die rechtstreeks door alle drie de geneste for-lussen propageert naar een handler die om het hele afdrukblok is gewikkeld

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;

Een uitzondering trekt zich niets aan van hoeveel for-lussen het punt waar deze wordt opgeworpen scheiden van de handler die deze afvangt, en dat is precies de eigenschap die dit probleem nodig heeft. Die robuustheid was geen doelbewuste verdediging tegen het hierboven beschreven grensvastzet-gedrag — de auteur van de Delphi-demo greep gewoon naar een ander gereedschap. De op uitzonderingen gebaseerde ontsnapping is nog steeds het waard om te benoemen als het steviger patroon: het overleeft het later toevoegen van een vierde nestniveau, terwijl een keten van handmatig geplaatste Break-statements onthouden en steeds opnieuw toegevoegd moet worden telkens als de lusnesting verandert

De oplossing: Break op elk nestniveau, gepoort door een PrintSucceeded-vlag

De fix die uitkwam in PDFiumPas v2.27.0 behoudt de drie-lussen-structuur in de Lazarus- en C++Builder-viewerdemo's maar maakt annulering expliciet op elk niveau, en scheidt het stoppen van de lus van het indienen van de taak in een vlag die pas wordt gelezen nadat de lus volledig is afgerond

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 wordt doelbewust één keer berekend, direct nadat de drievoudig geneste lus eindigt, uit niets meer dan not Cancel. Niets binnen de lusbody krijgt de kans om zelf te beslissen of de taak is geslaagd — de lus kan alleen op twee manieren eindigen, zonder collaties, pagina's, en kopieën komen te zitten, of de Break-keten raken die Cancel triggert, en PrintSucceeded leest de uitkomst achteraf in plaats van deze bij te houden terwijl de lus voortgaat. Het op die manier berekenen is wat de keuze van het finally-blok tussen Printer.EndDoc en Printer.Abort betrouwbaar maakt: het gaat nooit af voordat de lus daadwerkelijk tot rust is gekomen

Uw eigen annuleer-gedreven afdruklussen auditen

Drie controles reiken ver voorbij deze ene afdrukroutine. Neem nooit aan dat een Pascal-for-lus zal opmerken dat een grensvariabele verandert nadat deze is gestart; als een lus vroegtijdig moet eindigen, zeg dat dan rechtstreeks met Break, op elk nestniveau dat de annulering moet oversteken, niet alleen het meest binnenste. Geef de voorkeur aan een ontsnappingsmechanisme dat door constructie afwikkelt, zoals een uitzondering, zodra de nesting diep genoeg is dat een gemiste Break plausibel is — het paar Abort/EAbort van de Delphi-demo kreeg dit gratis. Poort elke commit-stap zoals EndDoc achter een vlag die strikt na de lus wordt berekend, nooit erbinnen, zodat een taak die vroegtijdig stopte nooit kan worden verward met een taak die is voltooid

Dezelfde review die deze lus repareerde, strakte ook aan hoe de negen viewerdemo's het toetsenbord afhandelen terwijl een annuleerknop zichtbaar is: ze slikken nu elke toets in behalve Esc, zodat een verdwaalde Ctrl+P of Ctrl+F tijdens een actief afdrukken of zoeken er niet langer een tweede bovenop kan starten. Deze reentrancy-fix is een andere bug met een ander mechanisme, maar kwam voort uit dezelfde review van wat Cancel daadwerkelijk doet halverwege een bewerking. De gewoonte die het waard is om uit beide fixes mee te nemen, is meer een testgewoonte dan een codeergewoonte: oefen annulering uit op elke compiler waarnaar een gedeelde afdrukroutine daadwerkelijk wordt uitgeleverd, want een idee als de tellers wissen en erop vertrouwen dat de lus het opmerkt, kan testen onder C++Builder overleven, een Free Pascal-build ongeverifieerd bereiken, identiek lezen in de broncode, en falen op een manier die niemand ziet totdat iemand Annuleren lang genoeg ingedrukt houdt om de taak toch te zien afronden. Voor de afdrukopzet waar deze annuleringslogica bovenop zit, behandelt de walkthrough over het afdrukken van PDF-documenten met de PDFium VCL-component de rest

Deze afdrukannuleringsfix werd uitgebracht als onderdeel van de PDFium Component voor Delphi, C++Builder, en FPC/Lazarus, dat bij elke release dezelfde demosuite over alle drie de compilers draait, zodat gaten als deze door een build-matrix worden opgevangen in plaats van door een supportticket