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