Articolo tecnico

Bug del Ciclo for di FPC: Cliccare Annulla non Ferma la Stampa

Cliccare Annulla durante un lavoro di stampa in un visualizzatore del componente PDFium a volte non faceva nulla: il ciclo continuava a renderizzare ogni pagina e copia rimanente, e il lavoro raggiungeva comunque la stampante. La causa era il ciclo for di Free Pascal, che fissa il proprio limite superiore una sola volta all'ingresso nel ciclo, quindi azzerare la variabile dietro CopyCount o ToPage a metà ciclo non cambiava nulla di già in esecuzione. Il bug dei limiti del ciclo for è la quinta insidia emersa dallo stesso audit di compatibilità Delphi/FPC di PDFiumPas che ha prodotto altre quattro trappole cross-compiler, e a differenza di quelle quattro vive interamente dentro la routine di stampa di un visualizzatore — c'è voluto un tester che tenesse premuto Annulla durante un lavoro lungo per accorgersi realmente che la stampante non si fermava mai

Perché il lavoro di stampa continua dopo aver cliccato Annulla?

Il lavoro di stampa continuava perché il controllo di cancellazione azzerava solo le variabili che alimentavano i limiti del ciclo, non i cicli stessi, che Free Pascal aveva già bloccato all'avvio di ciascun ciclo. Il gestore SpeedButtonPrintClick dietro il pulsante Stampa nelle demo PDFViewer e MultiPageViewer costruisce ogni lavoro da tre cicli annidati: un ciclo esterno sui set di copie collazionate, un ciclo intermedio sulle pagine, e un ciclo interno sulle copie non collazionate della stessa pagina. PrintDialog.Collate decide quale contatore, CollateCopyCount o CopyCount, contenga effettivamente il numero di copie richiesto, mentre l'altro resta a uno. La cancellazione deve raggiungere tutti e tre i livelli contemporaneamente, e la prima versione di questo codice cercava di farlo azzerando le variabili di limite stesse nel momento in cui Cancel diventava vero

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'intento si legge abbastanza chiaramente: se i contatori che definiscono quante collazioni, pagine e copie rimangono scendono tutti a zero, i cicli dovrebbero esaurire il lavoro e uscire da soli. Printer.EndDoc girava poi incondizionatamente dopo il ciclo indipendentemente da come fosse terminato, quindi anche un lavoro che un utente credeva di aver fermato veniva comunque inviato allo spooler con ogni pagina renderizzata prima che il clic venisse notato

Cosa blocca Free Pascal quando inizia un ciclo for

Free Pascal valuta il valore finale di un ciclo for esattamente una volta, nel momento in cui il ciclo inizia, e mai più per l'intera vita di quel ciclo. for Page := FromPage to ToPage do legge ToPage una singola volta per calcolare quante iterazioni eseguire, e dopo di ciò il ciclo non ha più alcun interesse per una variabile chiamata ToPage, solo per il conteggio di iterazioni che ha già catturato. Impostare ToPage := 0 dall'interno del corpo del ciclo cambia una variabile che il ciclo in esecuzione non sta più consultando, ed è esattamente per questo che Cancel poteva essere vero mentre la stampante continuava a ricevere pagine per diverse altre iterazioni, a volte tutte

Quello schema è un'abitudine del tutto ragionevole da portarsi dietro da C o C++, e la demo del visualizzatore C++Builder del componente PDFium rispecchia quella Pascal abbastanza da vicino da rendere diretto il confronto. Il suo for (Copy = 1; Copy <= CopyCount; Copy++) riverifica Copy <= CopyCount contro il valore vivo di CopyCount a ogni passaggio, quindi azzerare lì il contatore termina genuinamente il ciclo al controllo successivo. La demo C++Builder portava il codice identico di azzeramento dei contatori e funzionava, il che è esattamente ciò che ha fatto sembrare sicuro riutilizzare la stessa idea nella build Pascal seduta proprio accanto nello stesso repository

Perché la build Delphi non ha incontrato lo stesso bug?

La demo PDFViewer di Delphi non dipendeva mai da un ciclo che notasse un limite cambiato, perché il suo percorso di cancellazione si srotola tramite un'eccezione invece che tramite un confronto. Il suo ciclo più interno chiama la procedura Abort dell'RTL nel momento in cui Cancel diventa vero, il che solleva un EAbort silenzioso che si propaga direttamente attraverso tutti e tre i cicli for annidati fino a un gestore avvolto attorno all'intero blocco di stampa

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;

Un'eccezione non si preoccupa di quanti cicli for separino il punto in cui viene sollevata dal gestore che la cattura, che è esattamente la proprietà di cui questo problema ha bisogno. Quella robustezza non era una difesa deliberata contro il comportamento di blocco del limite descritto sopra — l'autore della demo Delphi ha semplicemente scelto uno strumento diverso. La via di fuga basata su eccezione vale comunque la pena nominarla come lo schema più robusto: sopravvive all'aggiunta successiva di un quarto livello di annidamento, mentre una catena di istruzioni Break posizionate manualmente deve essere ricordata e riaggiunta ogni volta che l'annidamento del ciclo cambia

La correzione: Break a ogni livello di annidamento, controllato da un flag PrintSucceeded

La correzione distribuita in PDFiumPas v2.27.0 mantiene la struttura a tre cicli nelle demo del visualizzatore Lazarus e C++Builder ma rende esplicita la cancellazione a ogni livello, e separa l'arresto del ciclo dall'invio del lavoro in un flag che viene letto solo dopo che il ciclo è completamente terminato

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 viene deliberatamente calcolato una volta sola, immediatamente dopo l'uscita dal ciclo triplo annidato, a partire da nient'altro che not Cancel. Nulla dentro il corpo del ciclo ha la possibilità di decidere da sé se il lavoro sia riuscito — il ciclo può terminare solo in due modi, esaurendo collazioni, pagine e copie, oppure incontrando la catena di Break che Cancel attiva, e PrintSucceeded legge l'esito a posteriori invece di tracciarlo man mano che il ciclo procede. Calcolarlo in questo modo è ciò che rende affidabile la scelta del blocco finally tra Printer.EndDoc e Printer.Abort: non scatta mai prima che il ciclo si sia realmente assestato

Verificare i propri cicli di stampa guidati da cancellazione

Tre controlli si estendono ben oltre questa singola routine di stampa. Non presupporre mai che un ciclo for Pascal si accorga di una variabile di limite che cambia dopo il suo avvio; se un ciclo deve terminare in anticipo, dillo direttamente con Break, a ogni livello di annidamento che la cancellazione deve attraversare, non solo quello più interno. Preferisci un meccanismo di fuga che si srotoli per costruzione, come un'eccezione, una volta che l'annidamento è abbastanza profondo da rendere plausibile un Break mancato — la coppia Abort/EAbort della demo Delphi ha ottenuto questo gratuitamente. Blocca qualsiasi passaggio di commit come EndDoc dietro un flag calcolato rigorosamente dopo il ciclo, mai al suo interno, cosicché un lavoro terminato in anticipo non possa mai essere scambiato per uno concluso

La stessa revisione che ha corretto questo ciclo ha anche irrobustito come le nove demo del visualizzatore gestiscono la tastiera mentre un pulsante di annullamento è visibile: ora ingoiano ogni tasto eccetto Esc, cosicché un Ctrl+P o Ctrl+F accidentale durante una stampa o ricerca attiva non possa più avviarne una seconda sopra di essa. Questa correzione di rientranza è un bug diverso con un meccanismo diverso, ma è emersa dalla stessa revisione di cosa faccia realmente Cancel a metà operazione. L'abitudine che vale la pena portarsi via da entrambe le correzioni è più un'abitudine di test che di codifica: esercita la cancellazione su ogni compilatore a cui una routine di stampa condivisa effettivamente viene distribuita, perché un'idea come azzerare i contatori e fidarsi che il ciclo se ne accorga può sopravvivere ai test sotto C++Builder, raggiungere una build Free Pascal non verificata, leggersi identica nel sorgente, e fallire in un modo che nessuno vede finché qualcuno non tiene premuto Annulla abbastanza a lungo da vedere il lavoro concludersi comunque. Per la configurazione di stampa su cui risiede questa logica di cancellazione, la guida sulla stampa di documenti PDF con il componente VCL PDFium tratta il resto

Questa correzione di cancellazione della stampa è stata distribuita come parte del componente PDFium per Delphi, C++Builder e FPC/Lazarus, che esegue la stessa suite di demo su tutti e tre i compilatori a ogni release cosicché lacune come questa vengano intercettate da una matrice di build piuttosto che da un ticket di supporto