Teknisk artikkel

FPC for-løkke-bug: å klikke Avbryt stopper ikke utskrift

Å klikke Avbryt under en utskriftsjobb i en PDFium-komponent-fremviser gjorde noen ganger ingenting: løkken fortsatte å gjengi hver gjenværende side og kopi, og jobben nådde likevel skriveren. Årsaken var Free Pascals for-løkke, som fikserer sin øvre grense én gang ved løkkeinngang, så å nullstille variabelen bak CopyCount eller ToPage midt i løkken endret ingenting som allerede kjørte. For-løkke-grense-bugen er den femte fallgruven som kom ut av den samme PDFiumPas Delphi/FPC-kompatibilitetsrevisjonen som produserte fire andre kryss-kompilator-fallgruver, og i motsetning til de fire, bor den fullstendig inne i en fremvisers utskriftsrutine — det krevde en tester som holdt Avbryt gjennom en lang jobb, for faktisk å legge merke til at skriveren aldri stoppet

Hvorfor fortsetter utskriftsjobben etter at Avbryt klikkes?

Utskriftsjobben fortsatte fordi avbrytelsessjekken bare tilbakestilte variablene som matet løkkegrensene, ikke selve løkkene, som Free Pascal allerede hadde låst inn da hver løkke startet. SpeedButtonPrintClick-håndtereren bak Print-knappen i PDFViewer- og MultiPageViewer-demoene bygger hver jobb fra tre nøstede løkker: en ytre løkke over sammenstilte kopisett, en midtre løkke over sider, og en indre løkke over usammenstilte kopier av samme side. PrintDialog.Collate avgjør hvilken teller, CollateCopyCount eller CopyCount, som faktisk holder det forespurte antallet kopier, mens den andre forblir på én. Avbrytelse må rekke gjennom alle tre nivåene på én gang, og den første versjonen av denne koden prøvde å gjøre det ved å tilbakestille selve grensevariablene i det øyeblikket Cancel ble sann

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

Hensikten leser klart nok: hvis tellerne som definerer hvor mange sammenstillinger, sider, og kopier som gjenstår, alle faller til null, burde løkkene gå tom for arbeid og falle gjennom av seg selv. Printer.EndDoc kjørte deretter ubetinget etter løkken uansett hvordan den endte, så selv en jobb en bruker trodde de hadde stoppet, ble likevel sendt til spooleren med hver side gjengitt før klikket ble lagt merke til

Hva Free Pascal låser inn når en for-løkke starter

Free Pascal evaluerer en for-løkkes sluttverdi nøyaktig én gang, i det øyeblikket løkken begynner, og aldri igjen gjennom levetiden til den løkken. for Page := FromPage to ToPage do leser ToPage én enkelt gang for å beregne hvor mange iterasjoner som skal kjøres, og etter det har løkken ingen videre interesse i en variabel ved navn ToPage, bare i iterasjonstellingen den allerede fanget. Å sette ToPage := 0 fra inne i løkkekroppen endrer en variabel den kjørende løkken ikke lenger konsulterer, noe som er nøyaktig grunnen til at Cancel kunne være sann mens skriveren fortsatte å motta sider for flere iterasjoner til, noen ganger alle sammen

Det mønsteret er en fullstendig rimelig vane å bringe over fra C eller C++, og PDFium-komponentens C++Builder-fremviser-demo speiler Pascal-en tett nok til å gjøre sammenligningen direkte. Dens for (Copy = 1; Copy <= CopyCount; Copy++) re-tester Copy <= CopyCount mot den levende verdien av CopyCount ved hver passering, så å nullstille telleren der, avslutter faktisk løkken ved neste sjekk. C++Builder-demoen bar den identiske nullstill-tellerne-koden og den fungerte, noe som er nøyaktig det som fikk den samme idéen til å se trygg ut å gjenbruke i Pascal-bygget som sitter rett ved siden av det i det samme repositoryet

Hvorfor traff ikke Delphi-bygget den samme bugen?

Delphi PDFViewer-demoen var aldri avhengig av at en løkke la merke til en endret grense, fordi avbrytelsesveien dens vikler ut gjennom et unntak i stedet for en sammenligning. Den innerste løkken kaller RTL-ens Abort-prosedyre i det øyeblikket Cancel blir sann, noe som kaster en stille EAbort som forplanter seg rett gjennom alle tre nøstede for-løkker til en håndterer pakket rundt hele utskriftsblokken

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;

Et unntak bryr seg ikke om hvor mange for-løkker som skiller punktet der det kastes, fra håndtereren som fanger det, noe som er nøyaktig egenskapen dette problemet trenger. Den robustheten var ikke et bevisst forsvar mot grense-låsings-oppførselen beskrevet ovenfor — forfatteren av Delphi-demoen grep bare til et annet verktøy. Den unntaksbaserte utveien er fortsatt verdt å navngi som det mer robuste mønsteret: den overlever at et fjerde nøstingsnivå legges til senere, mens en kjede av manuelt plasserte Break-setninger må huskes og legges til på nytt hver gang løkkenøstingen endrer seg

Løsningen: Break på hvert nøstingsnivå, styrt av et PrintSucceeded-flagg

Løsningen som ble levert i PDFiumPas v2.27.0, beholder tre-løkke-strukturen i Lazarus- og C++Builder-fremviser-demoene, men gjør avbrytelse eksplisitt på hvert nivå, og den skiller løkkestansen fra at jobben sendes inn, ved å legge det inn i et flagg som bare leses etter at løkken har fullstendig avsluttet

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 bevisst én gang, umiddelbart etter at den tre-nøstede løkken avslutter, fra ikke noe mer enn not Cancel. Ingenting inne i løkkekroppen får bestemme på egen hånd om jobben lyktes — løkken kan bare avslutte på to måter, ved å gå tom for sammenstillinger, sider, og kopier, eller ved å treffe Break-kjeden Cancel utløser, og PrintSucceeded leser utfallet i etterkant snarere enn å spore det etter hvert som løkken går. Å beregne det på den måten er det som gjør finally-blokkens valg mellom Printer.EndDoc og Printer.Abort pålitelig: det utløses aldri før løkken faktisk har lagt seg

Å revidere dine egne avbrytelses-drevne utskriftsløkker

Tre sjekker reiser godt utover denne ene utskriftsrutinen. Anta aldri at en Pascal for-løkke vil legge merke til en grensevariabel som endres etter at den har startet; hvis en løkke trenger å avslutte tidlig, si det direkte med Break, på hvert nøstingsnivå avbrytelsen trenger å krysse, ikke bare det innerste. Foretrekk en utvei-mekanisme som vikler ut ved konstruksjon, slik som et unntak, når nøstingen er dyp nok til at en glemt Break er plausibel — Delphi-demoens Abort/EAbort-par fikk dette gratis. Sett enhver commit-trinn som EndDoc bak et flagg beregnet strengt etter løkken, aldri inni den, slik at en jobb som stoppet tidlig, aldri kan mistas for en som fullførte

Den samme gjennomgangen som fikset denne løkken, strammet også inn hvordan de ni fremviser-demoene håndterer tastaturet mens en avbryt-knapp er synlig: de svelger nå hver tast unntatt Esc, slik at en villfaren Ctrl+P eller Ctrl+F under en aktiv utskrift eller søk ikke lenger kan starte en andre oppå den. Denne reentrancy-fiksen er en annen bug med en annen mekanisme, men den kom ut av den samme gjennomgangen av hva Cancel faktisk gjør midt i en operasjon. Vanen verdt å ta med seg fra begge fiksene er mer en testevane enn en kodevane: øv avbrytelse på hver kompilator en delt utskriftsrutine faktisk sendes til, fordi en idé som å tømme tellerne og stole på at løkken legger merke til det, kan overleve testing under C++Builder, nå et Free Pascal-bygg uverifisert, lese identisk i kildekoden, og feile på en måte ingen ser før noen holder Avbryt nede lenge nok til å se jobben fullføre likevel. For utskriftsoppsettet denne avbrytelseslogikken sitter oppå, dekker gjennomgangen av å skrive ut PDF-dokumenter med PDFium VCL-komponenten resten

Denne utskrift-avbrytelses-fiksen ble levert som en del av PDFium-komponenten for Delphi, C++Builder, og FPC/Lazarus, som kjører den samme demo-suiten på tvers av alle tre kompilatorer ved hver utgivelse, slik at gap som dette fanges av en build-matrise snarere enn en support-sak