Cliquer sur Annuler pendant une tâche d'impression dans une visionneuse du composant PDFium ne faisait parfois rien : la boucle continuait de rendre chaque page et copie restante, et la tâche atteignait tout de même l'imprimante. La cause était la boucle for de Free Pascal, qui fixe sa borne supérieure une fois à l'entrée de la boucle, si bien que mettre à zéro la variable derrière CopyCount ou ToPage en cours de boucle ne changeait rien à ce qui était déjà en cours d'exécution. Le bogue de bornes de boucle for est le cinquième piège issu du même audit de compatibilité Delphi/FPC de PDFiumPas qui a produit quatre autres pièges de compilation croisée, et contrairement à ces quatre, il vit entièrement à l'intérieur de la routine d'impression d'une visionneuse — il a fallu un testeur maintenant Annuler pendant une longue tâche pour remarquer que l'imprimante ne s'arrêtait jamais
Pourquoi la tâche d'impression continue-t-elle après un clic sur Annuler ?
La tâche d'impression continuait car la vérification d'annulation ne réinitialisait que les variables qui alimentaient les bornes de boucle, pas les boucles elles-mêmes, que Free Pascal avait déjà verrouillées au démarrage de chaque boucle. Le gestionnaire SpeedButtonPrintClick derrière le bouton Imprimer dans les démonstrations PDFViewer et MultiPageViewer construit chaque tâche à partir de trois boucles imbriquées : une boucle externe sur les jeux de copies assemblées, une boucle intermédiaire sur les pages, et une boucle interne sur les copies non assemblées de la même page. PrintDialog.Collate décide quel compteur, CollateCopyCount ou CopyCount, détient réellement le nombre de copies demandé, tandis que l'autre reste à un. Annuler doit atteindre les trois niveaux à la fois, et la première version de ce code tentait de le faire en réinitialisant les variables de borne elles-mêmes au moment où Cancel devenait vrai
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
L'intention se lit assez clairement : si les compteurs qui définissent combien d'assemblages, de pages, et de copies restent tombent tous à zéro, les boucles devraient épuiser leur travail et se terminer d'elles-mêmes. Printer.EndDoc s'exécutait ensuite inconditionnellement après la boucle quelle que soit la façon dont elle s'était terminée, si bien que même une tâche qu'un utilisateur croyait avoir arrêtée était tout de même soumise au spouleur avec chaque page rendue avant que le clic ne soit remarqué
Ce que Free Pascal verrouille au démarrage d'une boucle for
Free Pascal évalue la valeur finale d'une boucle for exactement une fois, au moment où la boucle commence, et jamais plus pendant toute la durée de vie de cette boucle. for Page := FromPage to ToPage do lit ToPage une seule fois pour calculer combien d'itérations exécuter, et après cela la boucle n'a plus aucun intérêt pour une variable nommée ToPage, seulement pour le compte d'itérations qu'elle a déjà capturé. Régler ToPage := 0 depuis l'intérieur du corps de boucle change une variable que la boucle en cours ne consulte plus, ce qui explique précisément pourquoi Cancel pouvait être vrai pendant que l'imprimante continuait de recevoir des pages pendant plusieurs itérations supplémentaires, parfois toutes
Ce schéma est une habitude tout à fait raisonnable à reprendre de C ou C++, et la démonstration de visionneuse C++Builder du composant PDFium reflète celle en Pascal d'assez près pour rendre la comparaison directe. Son for (Copy = 1; Copy <= CopyCount; Copy++) retteste Copy <= CopyCount contre la valeur vivante de CopyCount à chaque passage, si bien que mettre le compteur à zéro là met effectivement fin à la boucle à la prochaine vérification. La démonstration C++Builder portait le code identique de mise-à-zéro-des-compteurs et il fonctionnait, ce qui est précisément ce qui a rendu la même idée sûre en apparence à réutiliser dans la version Pascal assise juste à côté dans le même dépôt
Pourquoi la version Delphi n'a-t-elle pas rencontré le même bogue ?
La démonstration PDFViewer Delphi n'a jamais dépendu d'une boucle remarquant une borne modifiée, car son chemin d'annulation se déroule via une exception plutôt qu'une comparaison. Sa boucle la plus interne appelle la procédure Abort du RTL au moment où Cancel devient vrai, ce qui lève un EAbort silencieux qui se propage directement à travers les trois boucles for imbriquées jusqu'à un gestionnaire enveloppant tout le bloc d'impression
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;
Une exception ne se soucie pas du nombre de boucles for qui séparent le point où elle est levée du gestionnaire qui l'intercepte, ce qui est précisément la propriété dont ce problème a besoin. Cette robustesse n'était pas une défense délibérée contre le comportement de verrouillage de borne décrit ci-dessus — l'auteur de la démonstration Delphi a simplement recouru à un outil différent. L'échappatoire basée sur l'exception mérite tout de même d'être nommée comme le schéma le plus solide : elle survit à l'ajout ultérieur d'un quatrième niveau d'imbrication, tandis qu'une chaîne d'instructions Break placées manuellement doit être mémorisée et rajoutée à chaque changement de l'imbrication de boucle
La correction : Break à chaque niveau d'imbrication, conditionné par un drapeau PrintSucceeded
La correction livrée dans PDFiumPas v2.27.0 conserve la structure à trois boucles dans les démonstrations de visionneuse Lazarus et C++Builder mais rend l'annulation explicite à chaque niveau, et sépare l'arrêt de boucle de la soumission de la tâche dans un drapeau qui n'est lu qu'après que la boucle s'est complètement terminée
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 est délibérément calculé une fois, immédiatement après la sortie de la boucle triplement imbriquée, à partir de rien de plus que not Cancel. Rien à l'intérieur du corps de boucle ne décide par lui-même si la tâche a réussi — la boucle ne peut se terminer que de deux façons, épuiser les assemblages, pages, et copies, ou atteindre la chaîne Break que Cancel déclenche, et PrintSucceeded lit le résultat après coup plutôt que de le suivre au fur et à mesure de la boucle. Le calculer ainsi est ce qui rend digne de confiance le choix du bloc finally entre Printer.EndDoc et Printer.Abort : il ne se déclenche jamais avant que la boucle ne se soit réellement stabilisée
Auditer vos propres boucles d'impression pilotées par annulation
Trois vérifications voyagent bien au-delà de cette seule routine d'impression. Ne supposez jamais qu'une boucle for Pascal remarquera qu'une variable de borne change après son démarrage ; si une boucle doit se terminer tôt, dites-le directement avec Break, à chaque niveau d'imbrication que l'annulation doit traverser, pas seulement le plus interne. Préférez un mécanisme d'échappement qui se déroule par construction, comme une exception, dès que l'imbrication est assez profonde pour qu'un Break manqué soit plausible — la paire Abort/EAbort de la démonstration Delphi a obtenu cela gratuitement. Conditionnez toute étape de validation comme EndDoc derrière un drapeau calculé strictement après la boucle, jamais à l'intérieur, afin qu'une tâche qui s'est arrêtée tôt ne puisse jamais être confondue avec une qui s'est terminée
La même relecture qui a corrigé cette boucle a aussi resserré la façon dont les neuf démonstrations de visionneuse gèrent le clavier pendant qu'un bouton d'annulation est visible : elles avalent désormais chaque touche sauf Échap, si bien qu'un Ctrl+P ou Ctrl+F égaré pendant une impression ou une recherche active ne peut plus en démarrer une seconde par-dessus. Cette correction de réentrance est un bogue différent avec un mécanisme différent, mais elle est issue de la même relecture de ce que Cancel fait réellement en cours d'opération. L'habitude qui mérite d'être retenue des deux corrections est davantage une habitude de test qu'une habitude de codage : exercez l'annulation sur chaque compilateur vers lequel une routine d'impression partagée est réellement livrée, car une idée comme effacer les compteurs et faire confiance à la boucle pour le remarquer peut survivre aux tests sous C++Builder, atteindre une version Free Pascal non vérifiée, se lire identiquement dans le source, et échouer d'une façon que personne ne voit jusqu'à ce que quelqu'un maintienne Annuler assez longtemps pour voir la tâche se terminer tout de même. Pour la configuration d'impression sur laquelle repose cette logique d'annulation, le guide sur l'impression de documents PDF avec le composant VCL PDFium couvre le reste
Cette correction d'annulation d'impression a été livrée dans le cadre du composant PDFium pour Delphi, C++Builder, et FPC/Lazarus, qui exécute la même suite de démonstrations sur les trois compilateurs à chaque version afin que des lacunes comme celle-ci soient détectées par une matrice de build plutôt que par un ticket de support