Teknisk artikel

FPC for-løkke-bug: at klikke Annuller stopper ikke udskrivning

At klikke Annuller under et udskriftsjob i en PDFium-komponent-fremviser gjorde nogle gange ingenting: løkken blev ved med at gengive hver resterende side og kopi, og jobbet nåede stadig printeren. Årsagen var Free Pascals for-løkke, som fastsætter sin øvre grænse én gang ved løkke-indgang, så at nulstille variablen bag CopyCount eller ToPage midt i løkken ændrede intet, der allerede kørte. For-løkke-grænse-buggen er den femte faldgrube, der kom ud af den samme PDFiumPas Delphi/FPC-kompatibilitets-audit, der producerede fire andre kryds-kompiler-faldgruber, og i modsætning til de fire bor den helt inde i en fremvisers udskriftsrutine — det krævede en tester der holdt Annuller nede gennem et langt job for rent faktisk at bemærke, at printeren aldrig stoppede

Hvorfor fortsætter udskriftsjobbet, efter Annuller er klikket?

Udskriftsjobbet fortsatte, fordi annullerings-tjekket kun nulstillede de variabler, der fodrede løkke-grænserne, ikke selve løkkerne, som Free Pascal allerede havde låst fast, da hver løkke startede. SpeedButtonPrintClick-handleren bag Print-knappen i PDFViewer- og MultiPageViewer-demoerne bygger hvert job fra tre indlejrede løkker: en ydre løkke over samlede kopisæt, en midterste løkke over sider, og en indre løkke over ikke-samlede kopier af den samme side. PrintDialog.Collate afgør, hvilken tæller, CollateCopyCount eller CopyCount, der rent faktisk holder det anmodede antal kopier, mens den anden forbliver på én. Annullering skal nå gennem alle tre niveauer på én gang, og den første version af denne kode forsøgte at gøre det ved at nulstille selve grænse-variablerne, i det øjeblik Cancel blev sand

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

Hensigten læses klart nok: hvis tællerne, der definerer hvor mange samlinger, sider og kopier der er tilbage, alle falder til nul, bør løkkerne løbe tør for arbejde og falde igennem af sig selv. Printer.EndDoc kørte derefter ubetinget efter løkken uanset, hvordan den sluttede, så selv et job en bruger troede, de havde stoppet, blev stadig sendt til spooleren med hver side gengivet, før klikket blev bemærket

Hvad Free Pascal låser fast, når en for-løkke starter

Free Pascal evaluerer en for-løkkes slutværdi præcis én gang, i det øjeblik løkken begynder, og aldrig igen gennem den løkkes levetid. for Page := FromPage to ToPage do læser ToPage én enkelt gang for at beregne, hvor mange iterationer der skal køres, og derefter har løkken ingen yderligere interesse i en variabel ved navn ToPage, kun i det iterationsantal, den allerede indfangede. At sætte ToPage := 0 fra inde i løkkens krop ændrer en variabel, den kørende løkke ikke længere konsulterer, hvilket er præcis, hvorfor Cancel kunne være sand, mens printeren blev ved med at modtage sider i flere iterationer endnu, nogle gange alle sammen

Det mønster er en helt fornuftig vane at bringe med fra C eller C++, og PDFium-komponentens C++Builder-fremviser-demo spejler den Pascal-demo tæt nok til at gøre sammenligningen direkte. Dens for (Copy = 1; Copy <= CopyCount; Copy++) gentester Copy <= CopyCount mod den levende værdi af CopyCount ved hvert gennemløb, så at nulstille tælleren der rent faktisk afslutter løkken ved det næste tjek. C++Builder-demoen bar den identiske nulstil-tællerne-kode, og den fungerede, hvilket er præcis, hvad der fik den samme idé til at se sikker ud at genbruge i Pascal-buildet, der sad lige ved siden af den i det samme repository

Hvorfor ramte Delphi-buildet ikke den samme bug?

Delphi-PDFViewer-demoen var aldrig afhængig af, at en løkke bemærkede en ændret grænse, fordi dens annullerings-vej ruller op gennem en undtagelse frem for en sammenligning. Dens inderste løkke kalder RTL'ens Abort-procedure, i det øjeblik Cancel bliver sand, hvilket kaster en tavs EAbort, der forplanter sig lige gennem alle tre indlejrede for-løkker til en handler pakket rundt om hele udskriftsblokken

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;

En undtagelse er ligeglad med, hvor mange for-løkker der adskiller punktet, hvor den rejses, fra handleren der fanger den, hvilket er præcis den egenskab, dette problem har brug for. Den robusthed var ikke et bevidst forsvar mod den grænse-låsnings-opførsel beskrevet ovenfor — Delphi-demoens forfatter greb simpelthen til et andet værktøj. Den undtagelses-baserede flugtvej er stadig værd at nævne som det mere robuste mønster: den overlever, at et fjerde indlejrings-niveau tilføjes senere, mens en kæde af manuelt placerede Break-sætninger skal huskes og gentilføjes, hver gang løkke-indlejringen ændres

Fixen: Break på hvert indlejrings-niveau, gated af et PrintSucceeded-flag

Fixen der blev sendt i PDFiumPas v2.27.0, beholder tre-løkke-strukturen i Lazarus- og C++Builder-fremviser-demoerne, men gør annullering eksplicit på hvert niveau, og den adskiller løkke-stopningen fra jobbet, der bliver indsendt, til et flag, der kun læses efter løkken er helt færdig

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 beregnes bevidst én gang, umiddelbart efter den tredobbelt-indlejrede løkke afsluttes, ud fra intet mere end not Cancel. Intet inde i løkkens krop får lov at beslutte på egen hånd, om jobbet lykkedes — løkken kan kun slutte på to måder, ved at løbe tør for samlinger, sider og kopier, eller ved at ramme den Break-kæde, Cancel udløser, og PrintSucceeded læser udfaldet bagefter frem for at spore det, mens løkken går. At beregne den på den måde er, hvad der gør finally-blokkens valg mellem Printer.EndDoc og Printer.Abort troværdigt: det udløses aldrig, før løkken rent faktisk har lagt sig til ro

At auditere ens egne annullerings-drevne udskriftsløkker

Tre tjek rejser langt forbi denne ene udskriftsrutine. Antag aldrig, at en Pascal for-løkke vil bemærke en grænse-variabel, der ændrer sig, efter den er startet; hvis en løkke skal afslutte tidligt, sig det direkte med Break, på hvert indlejrings-niveau annulleringen skal krydse, ikke kun det inderste. Foretræk en flugtmekanisme, der ruller op ved konstruktion, såsom en undtagelse, når indlejringen er dyb nok til, at en overset Break er plausibel — Delphi-demoens Abort/EAbort-par fik det gratis. Gate ethvert commit-trin som EndDoc bag et flag beregnet strengt efter løkken, aldrig inde i den, så et job der stoppede tidligt, aldrig kan blive forvekslet med ét, der blev færdigt

Den samme gennemgang, der fixede denne løkke, strammede også, hvordan de ni fremviser-demoer håndterer tastaturet, mens en annuller-knap er synlig: de sluger nu hver tast undtagen Esc, så en vildfaren Ctrl+P eller Ctrl+F under en aktiv udskrift eller søgning ikke længere kan starte en anden oven på den. Den genindtrædelsesfix er en anden bug med en anden mekanisme, men den kom ud af den samme gennemgang af, hvad Cancel rent faktisk gør midt i en operation. Vanen værd at tage med fra begge fixes er mere en test-vane end en kodnings-vane: udøv annullering på hver kompiler en delt udskriftsrutine rent faktisk sendes til, fordi en idé som at rydde tællerne og stole på, at løkken bemærker det, kan overleve testning under C++Builder, nå et Free Pascal-build uverificeret, læse identisk i kilden, og fejle på en måde, ingen ser, før nogen holder Annuller nede længe nok til at se jobbet blive færdigt alligevel. For den udskrifts-opsætning denne annullerings-logik sidder oven på, dækker gennemgangen af udskrivning af PDF-dokumenter med PDFium-VCL-komponenten resten

Denne udskrifts-annullerings-fix blev sendt som en del af PDFium-komponenten til Delphi, C++Builder og FPC/Lazarus, som kører den samme demo-suite på tværs af alle tre kompilere ved hver udgivelse, så huller som dette fanges af en build-matrix frem for en supportsag