Teknisk artikkel

FillChar på et funksjonsresultat lekker strenger i Object Pascal

En Delphi- eller FPC-funksjon som returnerer en record, får ikke en fersk, nullstilt Result ved hvert kall. Den skjulte Result-variabelen starter på null nøyaktig én gang, og ingenting nullstiller den automatisk på nytt mellom kall, så å tømme den ved inngang er funksjonens egen jobb. Gjør den tømmingen med FillChar(Result, SizeOf(Result), 0), og fra det andre kallet og utover overskriver rutinen en levende streng- eller dynamisk-array-referanse i stedet for å frigjøre den, og gjør heap-blokken den referansen pekte på, foreldreløs

Scenarioet der dette biter, er hverdagslig. En batch-prosess åpner en stabel med tredjeparts-PDF-er og går gjennom hver annotering på hver side, og trekker kommentarteksten inn i en revisjonslogg. Ingenting ved den løkken ser farlig ut: hvert kall er en ren funksjon som returnerer en ren record, ingen pekere i sikte, ingenting som ligner manuell minnehåndtering i det hele tatt. Referansetelling inne i en record er en ren Object Pascal-regnskapsregel, ikke en særegenhet spesifikk for noe enkelt bibliotek, og enhver Delphi- eller FPC-kodebase som blander FillChar med record-typer som holder strenger eller dynamiske arrayer, er eksponert for den samme defekten

Hvorfor lekker FillChar på et record-resultat strenger?

FillChar lekker strenger fordi den ikke har noen anelse om hva slags data den overskriver. FillChar(X, Count, Value) fungerer på hvilken som helst variabel: den tar en utypet blokk med Count byte og stempler hver eneste av dem med Value, og det er hele kontrakten. Det er nøyaktig det som gjør FillChar rask og generell, fordi den aldri inspiserer typen til X og aldri forgrener seg på hva de underliggende bytene betyr. Et UnicodeString- eller WideString-felt inne i en record er ikke tegnene selv; det er en peker til en heap-blokk som bærer en referansetelling foran tegndataen. FillChar ser en håndfull byte som tilfeldigvis holder en pekerverdi, og overskriver dem med null nøyaktig slik den ville overskrevet et Integer- eller Double-felt. Pekeren forsvinner, referansetellingen den skulle dekrementert først, blir aldri rørt, og blokken den pekte på, blir stående allokert uten noe igjen som refererer til den

Hvordan kompilatoren sporer strenger og dynamiske arrayer inne i en record

Object Pascal kaller en type administrert (managed) når kompilatoren må kjøre ekstra kode for å holde den korrekt gjennom tildeling og scope-avslutning. Lang-strengtyper som AnsiString, UnicodeString, og WideString kvalifiserer, og det gjør også dynamiske arrayer, grensesnitt, og Varianter, sammen med enhver record eller fast array som inneholder en av dem som et felt. For hvert administrert felt, utsteder kompilatoren stille regnskapsføringen som ellers ville vært kjedelig og lett å få feil for hånd: øk en referansetelling ved tildeling, dekrementer den når den holdende variabelen overskrives eller går ut av scope, og frigjør den underliggende blokken når den tellingen når null. Det maskineriet er grunnen til at vanlig Pascal-kode aldri manuelt allokerer eller frigjør en string, og hvorfor å tildele ett dynamisk array til et annet er en billig, trygg operasjon snarere enn en manuell kopieringsløkke. System.Default og Finalize er de to dokumenterte måtene å påkalle den samme frigjøringslogikken på forespørsel, og de er hva en records tømmingskode bør kalle i stedet for en rå minnefylling

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;

Hvorfor starter lekkasjen først ved det andre kallet?

Det første kallet i en løkke er alltid harmløst, noe som er nøyaktig det som gjør denne defekten lett å overse i testing. En lokal variabel av en administrert record-type starter på null, og ingenting nullstiller den automatisk på nytt mellom én løkke-passering og den neste, så den første gangen en løkke tildeler en funksjons returverdi til den variabelen, er dens Description- eller ContentsText-felt fortsatt nil. FillChar overskriver nil med null, noe som ikke endrer noe med hensyn til referansetellingen, og kallet returnerer og ser helt korrekt ut. Det andre kallet er annerledes: den samme lokale variabelen holder allerede hva det første kallet skrev inn i den, og den nye kallets Result skrives direkte inn i den samme lagringen i stedet for inn i ferskt, tomt minne. FillChar øverst i det andre kallet nullstiller et felt som ikke lenger er nil, og alt nedstrøms for det bytemønsteret er stille feil fra da av. En test som kaller funksjonen én gang og inspiserer resultatet, vil aldri se problemet; bare en løkke, eller enhver kodevei som kaller funksjonen gjentatte ganger mot den samme destinasjonen, eksponerer det

En ekte lekkasje: annoteringer, bokmerker, og lenke-records

PDFiumPas leverte nøyaktig denne defekten før versjon 1.56.4, i tre funksjoner som hver returnerer en record som holder minst ett administrert felt: side-nivå-annoterings-leseren returnerer en TPdfAnnotation som bærer ContentsText- og AuthorText-strenger, bokmerke-leseren returnerer en TBookmark som bærer en Title-streng, og lenke-annoterings-leseren returnerer en TLinkAnnotation som bærer en ActionPath-streng og et Points-dynamisk-array. Alle tre åpnet med samme form vist nedenfor: tøm Result med en rå FillChar, fyll deretter feltene ett om gangen fra den underliggende sidedataen. Å gå gjennom hver annotering på en side én om gangen, den vanlige måten å bygge en revisjonsliste eller et gjennomgangspanel på, kalte annoterings-leseren i en løkke og lekket den forrige annoteringens tekst ved hver passering etter den første; en PDF laget med et uvanlig stort antall tekstbærende annoteringer kunne øke en langvarig prosess' minne så lenge den prosessen fortsatte å kjøre. Fiksen rørte én linje i hver funksjon: å erstatte FillChar(Result, SizeOf(Result), 0) med Result := Default(TPdfAnnotation) var nok, fordi å tildele Default til en administrert record kjører kompilatorens vanlige frigjør-så-tøm-sekvens i stedet for en rå minnefylling

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;

Den samme faren bak en var-parameter

Bokmerke-leseren viser en mer subtil versjon av det samme problemet, fordi recorden som tømmes med FillChar, ikke er funksjonens egen Result, men en var-parameter ett kall lenger ned. SetBookmarkData tar utdataen sin som var Data: TBookmark og pleide å tømme Data øverst i kroppen sin med FillChar; GetBookmark, den offentlige funksjonen som faktisk returnerer en TBookmark, kaller SetBookmarkData og sender sin egen Result rett gjennom som det var-argumentet. En var-parameter overføres ved referanse, så Data inne i SetBookmarkData og Result inne i GetBookmark er den samme lagringen under to navn, og hvilken som helst alias-risiko som gjelder for en funksjons egen Result, gjelder like direkte for enhver hjelperutine som mottar den ved referanse. Å gjennomgå bare funksjonene som bokstavelig deklarerer en record-returtype, overser denne formen; søket må følge hver var- og out-parameter en Result videresendes inn i, også

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;

Når er FillChar fortsatt det riktige valget?

FillChar er fortsatt korrekt, og ofte noe billigere, for en record bygget utelukkende av ordinaler, flyttallsfelt, fast-størrelse-arrayer av de samme, eller andre rene records laget av det samme, fordi det ikke er noe i den for kompilatoren å finalisere. PDFiumPas' eget rektangel-type er nøyaktig det tilfellet: TPdfRectangle holder fire Double-felt og ingenting annet, og å tømme en med FillChar frigjør ingenting fordi det ikke er noe referansetellet å frigjøre. Sjekken som skiller de to tilfellene, er enkel å uttrykke: har noe felt av recorden, på hvilken som helst nøstingsdybde, typen string, AnsiString, WideString, et dynamisk array, et grensesnitt, eller en Variant? En record kan se perfekt numerisk ut på toppnivå og likevel feile den testen hvis ett av feltene dens selv er en record som begraver en streng noen lag ned, så sjekken må følge nøstede records hele veien gjennom i stedet for å stoppe ved den ytterste feltlisten. Å revidere en eksisterende kodebase for dette mønsteret er mekanisk snarere enn uttømmende: søk etter hvert FillChar-kall hvis mål er en record-variabel, sjekk deretter den recordens feltliste mot listen over administrerte typer ovenfor. PDFiumPas' egen v1.56.4-revisjon kjørte nøyaktig det søket på tvers av hele biblioteket og fant denne eksponeringen i én enhet (unit); hvert annet FillChar-kallsted tømte allerede en ren numerisk record, der FillChar var, og forblir, det riktige verktøyet

Den samme kompilatoroppførselen som gjør en gjenbrukt Result farlig her, driver også en beslektet familie av Delphi-versus-FPC-uenigheter andre steder i denne kodebasen; en følgeartikkel om kryss-kompilator-fallgruver dekker et tilfelle der FPC og Delphi er uenige om nøyaktig når en record-resultat-midlertidig-variabel finaliseres inne i ett enkelt uttrykk, et annet symptom på det samme underliggende faktumet at en funksjons record-Result ikke alltid er den ferske, private lagringen den ser ut til å være. Annoterings-løkken brukt som det gjennomgående eksempelet i hele denne artikkelen, er heller ikke hypotetisk: det er den samme side-for-side-gjennomgangen man ville skrevet mens man bygger et annoterings-gjennomgangspanel, noe som er nøyaktig kodeformen som gjorde en én-linjes FillChar til en langsom minnelekkasje i utgangspunktet

Ingenting av dette krever å bytte biblioteker eller jage en bug i andres kompilerte kode: det er en egenskap ved selve Object Pascal-språket, en hver Delphi- og FPC-utvikler jobber med daglig, og fiksen er ett enkelt funksjonskall når man først vet å se etter det. Annoterings-, bokmerke-, og lenke-annoterings-API-ene beskrevet her følger med som en del av PDFium-komponenten for Delphi, C++Builder, og Lazarus/FPC, sammen med resten av PDF-lesings-, gjengivelses-, og annoterings-overflaten dekket andre steder på denne bloggen