Articolo tecnico

FillChar su un Risultato di Funzione Perde Stringhe in Object Pascal

Una funzione Delphi o FPC che restituisce un record non ottiene un Result fresco e azzerato a ogni chiamata. Quella variabile Result nascosta parte a zero esattamente una volta, e nulla la riazzera automaticamente tra una chiamata e l'altra, quindi svuotarla all'ingresso è compito proprio della funzione. Fai quella pulizia con FillChar(Result, SizeOf(Result), 0) e, dalla seconda chiamata in poi, la routine sovrascrive un riferimento vivo a stringa o array dinamico invece di rilasciarlo, rendendo orfano qualunque blocco heap a cui quel riferimento puntasse

Lo scenario in cui questo morde è banale. Un processo batch apre una pila di PDF di terze parti e percorre ogni annotazione su ogni pagina, estraendo il testo del commento in un log di audit. Nulla in quel ciclo sembra pericoloso: ogni chiamata è una semplice funzione che restituisce un semplice record, nessun puntatore in vista, nulla che assomigli a gestione manuale della memoria. Il conteggio dei riferimenti dentro un record è una semplice regola di contabilità di Object Pascal, non una stranezza specifica di una singola libreria, e qualsiasi codebase Delphi o FPC che mescola FillChar con tipi record che contengono stringhe o array dinamici è esposta allo stesso difetto

Perché FillChar su un Risultato Record Perde Stringhe?

FillChar perde stringhe perché non ha idea di che tipo di dato stia sovrascrivendo. FillChar(X, Count, Value) funziona su qualsiasi variabile: prende un blocco non tipizzato di Count byte e stampiglia ognuno di essi con Value, ed è questo l'intero contratto. Questo è esattamente ciò che rende FillChar veloce e general-purpose, perché non ispeziona mai il tipo di X e non si dirama mai in base a cosa significhino i byte sottostanti. Un campo UnicodeString o WideString dentro un record non è il carattere stesso; è un puntatore a un blocco heap che porta un conteggio di riferimenti prima dei dati carattere. FillChar vede una manciata di byte che capitano di contenere un valore puntatore e li sovrascrive con zero esattamente come farebbe con un campo Integer o Double. Il puntatore scompare, il conteggio di riferimenti che avrebbe dovuto decrementare prima non viene mai toccato, e il blocco a cui puntava resta allocato senza più nulla che vi faccia riferimento

Come il Compilatore Traccia Stringhe e Array Dinamici Dentro un Record

Object Pascal chiama gestito un tipo quando il compilatore deve eseguire codice extra per mantenerlo corretto attraverso assegnazione e uscita dall'ambito. I tipi stringa lunga come AnsiString, UnicodeString, e WideString qualificano, e così anche gli array dinamici, le interfacce, e i Variant, insieme a qualsiasi record o array a dimensione fissa che ne contenga uno come campo. Per ogni campo gestito, il compilatore emette silenziosamente la contabilità che altrimenti sarebbe tediosa e facile da sbagliare a mano: incrementa un conteggio di riferimenti all'assegnazione, lo decrementa quando la variabile contenitrice viene sovrascritta o esce dall'ambito, e libera il blocco sottostante una volta che quel conteggio raggiunge zero. Quel meccanismo è il motivo per cui il normale codice Pascal non alloca né rilascia mai manualmente una string, e perché assegnare un array dinamico a un altro è un'operazione economica e sicura invece di un ciclo di copia manuale. System.Default e Finalize sono i due modi documentati per invocare a richiesta quella stessa logica di rilascio, e sono ciò che il codice di pulizia di un record dovrebbe chiamare invece di un riempimento di memoria grezzo

type
  TLineItem = record
    Description: string;  // managed: reference-counted
    Quantity: Integer;    // unmanaged: plain ordinal
  end;

function GetLineItem(Index: Integer): TLineItem;
begin
  FillChar(Result, SizeOf(Result), 0);  // clears bytes, not the reference
  Result.Quantity := Source[Index].Qty;
  Result.Description := Source[Index].Text;
end;

var
  Item: TLineItem;
  I: Integer;
begin
  for I := 0 to High(Source) do
  begin
    Item := GetLineItem(I);  // second pass onward: leaks the prior Description
    Log.Add(Item.Description);
  end;
end;

Perché la Perdita Inizia Solo alla Seconda Chiamata?

La prima chiamata in un ciclo è sempre innocua, il che è esattamente ciò che rende questo difetto facile da perdere nei test. Una variabile locale di un tipo record gestito parte a zero, e nulla la riazzera automaticamente tra un passaggio di ciclo e il successivo, quindi la prima volta che un ciclo assegna il valore di ritorno di una funzione a quella variabile, il suo campo Description o ContentsText è ancora nil. FillChar sovrascrive nil con zero, il che non cambia nulla per quanto riguarda il conteggio dei riferimenti, e la chiamata ritorna sembrando interamente corretta. La seconda chiamata è diversa: la stessa variabile locale contiene già qualunque cosa la prima chiamata vi abbia scritto, e il Result della nuova chiamata viene scritto direttamente in quella stessa memoria invece che in memoria fresca e vuota. FillChar in cima a quella seconda chiamata azzera un campo che non è più nil, e tutto ciò che segue da quello schema di byte è silenziosamente sbagliato da quel momento in poi. Un test che chiama la funzione una volta sola e ispeziona il risultato non vedrà mai il problema; solo un ciclo, o qualsiasi percorso di codice che chiama la funzione ripetutamente contro la stessa destinazione, lo espone

Una Perdita Reale: Annotazioni, Segnalibri e Record di Link

PDFiumPas distribuiva esattamente questo difetto prima della versione 1.56.4, in tre funzioni che restituiscono ciascuna un record che contiene almeno un campo gestito: il lettore di annotazioni a livello di pagina restituisce un TPdfAnnotation che porta le stringhe ContentsText e AuthorText, il lettore di segnalibri restituisce un TBookmark che porta una stringa Title, e il lettore di annotazioni link restituisce un TLinkAnnotation che porta una stringa ActionPath e un array dinamico Points. Tutte e tre iniziavano con la stessa forma mostrata sotto: pulire Result con un FillChar grezzo, poi riempire i campi uno alla volta dai dati di pagina sottostanti. Percorrere ogni annotazione su una pagina una alla volta, il modo normale di costruire un elenco di audit o un pannello di revisione, chiamava il lettore di annotazioni in un ciclo e perdeva il testo dell'annotazione precedente a ogni passaggio dopo il primo; un PDF creato con un numero insolitamente grande di annotazioni con testo poteva far crescere la memoria di un processo a lunga esecuzione per tutto il tempo in cui quel processo continuava a girare. La correzione ha toccato una riga in ciascuna funzione: sostituire FillChar(Result, SizeOf(Result), 0) con Result := Default(TPdfAnnotation) è bastato, perché assegnare Default a un record gestito esegue la normale sequenza rilascia-poi-azzera del compilatore invece di un riempimento di memoria grezzo

function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
  Annotation: FPDF_ANNOTATION;
  ContentLength: LongWord;
begin
  Annotation := FPDFPage_GetAnnot(Page, Index);
  FillChar(Result, SizeOf(Result), 0);   // clears bytes, not a live reference
  Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
  ContentLength := FPDFAnnot_GetStringValue(Annotation,
    FPDFANNOT_TEXTTYPE_Contents, nil, 0);
  if ContentLength >= 4 then
  begin
    SetLength(Result.ContentsText, ContentLength div 2 - 1);
    FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
      Pointer(Result.ContentsText), ContentLength);
  end;
end;

Lo Stesso Rischio Dietro un Parametro var

Il lettore di segnalibri mostra una versione più sottile dello stesso problema, perché il record che viene pulito con FillChar non è il Result proprio della funzione ma un parametro var una chiamata più in basso. SetBookmarkData accetta il proprio output come var Data: TBookmark e in passato puliva Data in cima al proprio corpo con FillChar; GetBookmark, la funzione pubblica che effettivamente restituisce un TBookmark, chiama SetBookmarkData e passa il proprio Result direttamente come quell'argomento var. Un parametro var viene passato per riferimento, quindi Data dentro SetBookmarkData e Result dentro GetBookmark sono la stessa memoria sotto due nomi, e qualunque rischio di aliasing si applichi al Result proprio di una funzione si applica altrettanto direttamente a qualsiasi routine helper che lo riceva per riferimento. Revisionare solo le funzioni che dichiarano letteralmente un tipo di ritorno record perde questa forma; la ricerca deve seguire anche ogni parametro var e out in cui un Result venga inoltrato

procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
  BufferSize: LongWord;
begin
  Data := Default(TBookmark);   // fixed: was FillChar(Data, SizeOf(Data), 0)
  Data.Handle := Bookmark;
  if Bookmark <> nil then
  begin
    BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
    if BufferSize >= 4 then
    begin
      SetLength(Data.Title, BufferSize div 2 - 1);
      FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
    end;
  end;
end;

function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
  CheckActive;
  SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;

Quando FillChar È Ancora la Chiamata Giusta?

FillChar resta corretto, e spesso leggermente più economico, per un record costruito interamente da ordinali, campi in virgola mobile, array a dimensione fissa di quei tipi, o altri semplici record fatti degli stessi, perché non c'è nulla al suo interno che il compilatore debba finalizzare. Il tipo rettangolo proprio di PDFiumPas è esattamente questo caso: TPdfRectangle porta quattro campi Double e nient'altro, e pulirne uno con FillChar non rilascia nulla perché non c'è nulla con conteggio di riferimenti da rilasciare. Il controllo che separa i due casi è semplice da enunciare: qualche campo del record, a qualsiasi profondità di annidamento, ha tipo string, AnsiString, WideString, un array dinamico, un'interfaccia, o un Variant? Un record può sembrare perfettamente numerico al livello superiore e comunque fallire quel test se uno dei suoi campi è esso stesso un record che seppellisce una stringa qualche livello più in basso, quindi il controllo deve seguire i record annidati fino in fondo invece di fermarsi all'elenco di campi più esterno. Verificare una codebase esistente per questo schema è meccanico piuttosto che esaustivo: cerca ogni chiamata FillChar il cui target sia una variabile record, poi controlla l'elenco di campi di quel record contro l'elenco di tipi gestiti sopra. L'audit v1.56.4 proprio di PDFiumPas ha eseguito esattamente quella ricerca sull'intera libreria e ha trovato questa esposizione in un'unica unit; ogni altro punto di chiamata FillChar stava già pulendo un semplice record numerico, dove FillChar era, e resta, lo strumento giusto

Lo stesso comportamento del compilatore che rende pericoloso qui un Result riutilizzato guida anche una famiglia correlata di disaccordi tra Delphi e FPC altrove in questa codebase; un articolo di approfondimento sulle insidie cross-compiler tratta un caso in cui FPC e Delphi non concordano su esattamente quando un temporaneo di risultato-record venga finalizzato dentro una singola espressione, un sintomo diverso dello stesso fatto sottostante che il Result record di una funzione non è sempre la memoria fresca e privata che sembra essere. Il ciclo di annotazioni usato come esempio ricorrente in tutto questo articolo non è ipotetico, nemmeno: è lo stesso percorso pagina-per-pagina che scriveresti mentre costruisci un pannello di revisione annotazioni, che è esattamente la forma di codice che ha trasformato un FillChar di una riga in una lenta perdita di memoria in primo luogo

Nulla di tutto ciò richiede di cambiare libreria o inseguire un bug nel codice compilato di qualcun altro: è una proprietà del linguaggio Object Pascal stesso, una con cui ogni sviluppatore Delphi e FPC lavora quotidianamente, e la correzione è una singola chiamata di funzione una volta che sai cosa cercare. Le API di annotazioni, segnalibri e annotazioni link descritte qui fanno parte del componente PDFium per Delphi, C++Builder e Lazarus/FPC, insieme al resto della superficie di lettura, rendering e annotazione PDF trattata altrove su questo blog