Technischer Artikel

FPC-for-Schleifen-Bug: Klick auf Abbrechen stoppt Drucken nicht

Ein Klick auf Abbrechen während eines Druckauftrags in einem PDFium-Komponente-Viewer bewirkte manchmal nichts: Die Schleife rendert weiter jede verbleibende Seite und Kopie, und der Auftrag erreichte trotzdem den Drucker. Die Ursache war Free Pascals for-Schleife, die ihre Obergrenze einmalig beim Schleifeneintritt festlegt, sodass das Nullen der Variable hinter CopyCount oder ToPage mitten in der Schleife an dem, was bereits lief, nichts änderte. Der For-Schleifen-Grenzen-Bug ist der fünfte Fallstrick, der aus demselben PDFiumPas-Delphi/FPC-Kompatibilitäts-Audit hervorging, der vier weitere Cross-Compiler-Fallstricke hervorbrachte, und anders als diese vier lebt er vollständig innerhalb der Druckroutine eines Viewers — es brauchte einen Tester, der während eines langen Auftrags Abbrechen gedrückt hielt, um überhaupt zu bemerken, dass der Drucker nie stoppte

Warum läuft der Druckauftrag weiter, nachdem Abbrechen geklickt wurde?

Der Druckauftrag lief weiter, weil die Abbruchprüfung nur die Variablen zurücksetzte, die die Schleifengrenzen speisten, nicht die Schleifen selbst, die Free Pascal bereits fixiert hatte, als jede Schleife startete. Der SpeedButtonPrintClick-Handler hinter dem Druck-Button in den PDFViewer- und MultiPageViewer-Demos baut jeden Auftrag aus drei verschachtelten Schleifen: eine äußere Schleife über zusammengetragene Kopiensätze, eine mittlere Schleife über Seiten und eine innere Schleife über nicht zusammengetragene Kopien derselben Seite. PrintDialog.Collate entscheidet, welcher Zähler, CollateCopyCount oder CopyCount, tatsächlich die angeforderte Kopienanzahl hält, während der andere bei eins bleibt. Abbrechen muss alle drei Ebenen gleichzeitig erreichen, und die erste Version dieses Codes versuchte das, indem sie die Grenzvariablen selbst zurücksetzte, in dem Moment, in dem Cancel wahr wurde

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

Die Absicht liest sich klar genug: Wenn die Zähler, die definieren, wie viele Zusammentragungen, Seiten und Kopien verbleiben, alle auf null fallen, sollten den Schleifen die Arbeit ausgehen und sie von selbst durchfallen. Printer.EndDoc lief dann bedingungslos nach der Schleife, unabhängig davon, wie sie endete, sodass sogar ein Auftrag, von dem ein Benutzer glaubte, ihn gestoppt zu haben, trotzdem an den Spooler übergeben wurde, mit jeder Seite gerendert, bevor der Klick bemerkt wurde

Was Free Pascal fixiert, wenn eine for-Schleife startet

Free Pascal wertet den Endwert einer for-Schleife genau einmal aus, in dem Moment, in dem die Schleife beginnt, und nie wieder für die Lebensdauer dieser Schleife. for Page := FromPage to ToPage do liest ToPage einmal, um zu berechnen, wie viele Iterationen laufen sollen, und danach hat die Schleife kein weiteres Interesse an einer Variable namens ToPage, nur an der Iterationsanzahl, die sie bereits erfasst hat. ToPage := 0 aus dem Schleifenrumpf zu setzen ändert eine Variable, die die laufende Schleife nicht mehr konsultiert, genau deshalb konnte Cancel wahr sein, während der Drucker noch mehrere weitere Iterationen Seiten empfing, manchmal alle

Dieses Muster ist eine vollkommen vernünftige Gewohnheit, aus C oder C++ übernommen, und die C++Builder-Viewer-Demo der PDFium-Komponente spiegelt die Pascal-Version eng genug, um den Vergleich direkt zu machen. Ihr for (Copy = 1; Copy <= CopyCount; Copy++) prüft Copy <= CopyCount bei jedem Durchlauf erneut gegen den lebenden Wert von CopyCount, sodass das Nullen des Zählers dort die Schleife bei der nächsten Prüfung tatsächlich beendet. Die C++Builder-Demo trug identischen Zähler-nullenden Code und er funktionierte, was genau dieselbe Idee sicher wiederverwendbar aussehen ließ im Pascal-Build, der direkt daneben in demselben Repository saß

Warum traf der Delphi-Build nicht auf denselben Bug?

Die Delphi-PDFViewer-Demo hing nie davon ab, dass eine Schleife eine geänderte Grenze bemerkt, weil ihr Abbruchpfad sich über eine Exception statt über einen Vergleich abwickelt. Ihre innerste Schleife ruft die RTL-Prozedur Abort auf, in dem Moment, in dem Cancel wahr wird, was ein stilles EAbort auslöst, das direkt durch alle drei verschachtelten for-Schleifen zu einem um den gesamten Druckblock gewickelten Handler propagiert

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;

Einer Exception ist es egal, wie viele for-Schleifen den Punkt, an dem sie ausgelöst wird, vom Handler trennen, der sie fängt, genau die Eigenschaft, die dieses Problem braucht. Diese Robustheit war keine bewusste Verteidigung gegen das oben beschriebene Grenzen-Fixierungs-Verhalten — der Autor der Delphi-Demo griff einfach zu einem anderen Werkzeug. Der exception-basierte Ausstieg lohnt sich trotzdem als das stabilere Muster zu benennen: Er übersteht eine später hinzugefügte vierte Verschachtelungsebene, während eine Kette manuell platzierter Break-Anweisungen bei jeder Änderung der Schleifenverschachtelung neu bedacht und ergänzt werden muss

Die Lösung: Break auf jeder Verschachtelungsebene, gesteuert durch ein PrintSucceeded-Flag

Die in PDFiumPas v2.27.0 ausgelieferte Lösung behält die Drei-Schleifen-Struktur in den Lazarus- und C++Builder-Viewer-Demos bei, macht Abbrechen aber auf jeder Ebene explizit, und trennt das Anhalten der Schleife von der Auftragsübergabe in ein Flag, das erst gelesen wird, nachdem die Schleife vollständig fertig ist

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 wird absichtlich einmal berechnet, unmittelbar nachdem die dreifach verschachtelte Schleife endet, aus nichts weiter als not Cancel. Nichts innerhalb des Schleifenrumpfs entscheidet für sich allein, ob der Auftrag erfolgreich war — die Schleife kann nur auf zwei Arten enden, indem ihr Zusammentragungen, Seiten und Kopien ausgehen, oder indem sie die von Cancel ausgelöste Break-Kette trifft, und PrintSucceeded liest das Ergebnis im Nachhinein, statt es während des Schleifenlaufs zu verfolgen. Es so zu berechnen ist das, was die Wahl des finally-Blocks zwischen Printer.EndDoc und Printer.Abort vertrauenswürdig macht: Sie feuert nie, bevor die Schleife sich tatsächlich niedergelassen hat

Eigene abbrechbare Druckschleifen auditieren

Drei Prüfungen reichen weit über diese eine Druckroutine hinaus. Nehmen Sie nie an, eine Pascal-for-Schleife bemerkt eine sich ändernde Grenzvariable, nachdem sie gestartet ist; muss eine Schleife früh enden, sagen Sie das direkt mit Break, auf jeder Verschachtelungsebene, die der Abbruch überqueren muss, nicht nur der innersten. Bevorzugen Sie einen Ausstiegsmechanismus, der sich konstruktionsbedingt abwickelt, etwa eine Exception, sobald die Verschachtelung tief genug ist, dass ein verpasstes Break plausibel ist — das Delphi-Demo-Paar Abort/EAbort bekam das gratis. Sichern Sie jeden Commit-Schritt wie EndDoc hinter einem Flag ab, das strikt nach der Schleife berechnet wird, nie innerhalb davon, sodass ein früh gestoppter Auftrag nie mit einem fertiggestellten verwechselt werden kann

Dieselbe Überprüfung, die diese Schleife reparierte, straffte auch, wie die neun Viewer-Demos die Tastatur behandeln, während ein Abbrechen-Button sichtbar ist: Sie schlucken jetzt jede Taste außer Esc, sodass ein verirrtes Strg+P oder Strg+F während eines aktiven Drucks oder einer aktiven Suche nicht mehr einen zweiten Vorgang darüber starten kann. Diese Reentrancy-Lösung ist ein anderer Bug mit einem anderen Mechanismus, aber sie kam aus derselben Überprüfung dessen, was Cancel tatsächlich mitten in der Operation tut. Die Gewohnheit, die es sich lohnt, aus beiden Lösungen mitzunehmen, ist eher eine Test- als eine Codier-Gewohnheit: Testen Sie Abbrechen auf jedem Compiler, an den eine gemeinsame Druckroutine tatsächlich ausgeliefert wird, denn eine Idee wie das Löschen der Zähler und das Vertrauen darauf, dass die Schleife es bemerkt, kann Tests unter C++Builder überstehen, ungeprüft einen Free-Pascal-Build erreichen, sich im Quellcode identisch lesen, und auf eine Weise scheitern, die niemand sieht, bis jemand Abbrechen lange genug gedrückt hält, um zuzusehen, wie der Auftrag trotzdem fertig wird. Für den Druck-Aufbau, auf dem diese Abbruchlogik sitzt, behandelt der Durchgang zum Drucken von PDF-Dokumenten mit der PDFium-VCL-Komponente den Rest

Diese Druckabbruch-Lösung wurde als Teil der PDFium-Komponente für Delphi, C++Builder und FPC/Lazarus ausgeliefert, die bei jeder Version dieselbe Demo-Suite über alle drei Compiler laufen lässt, sodass Lücken wie diese von einer Build-Matrix abgefangen werden statt von einem Support-Ticket