Article technique

Bogue de boucle for FPC : cliquer sur Annuler n'arrête pas l'impression

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